From cc30e7dd2429eac58756768ecd4519f07d5e00fa Mon Sep 17 00:00:00 2001 From: hyperpolymath <6759885+hyperpolymath@users.noreply.github.com> Date: Sat, 26 Sep 2026 19:45:39 +0000 Subject: [PATCH 1/8] Harden launcher generation and align repository metadata Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- .github/CODE_OF_CONDUCT.md | 13 + .github/CONTRIBUTING.md | 3 + .github/ISSUE_TEMPLATE/bug_report.yml | 40 +++ .github/ISSUE_TEMPLATE/config.yml | 6 + .github/ISSUE_TEMPLATE/feature_request.yml | 24 ++ .github/SECURITY.md | 19 ++ .github/dependabot.yml | 26 +- .machine_readable/6a2/0-AI-MANIFEST.a2ml | 2 + .machine_readable/6a2/AGENTIC.a2ml | 49 ++- .machine_readable/6a2/ECOSYSTEM.a2ml | 25 +- .machine_readable/6a2/META.a2ml | 35 +-- .machine_readable/6a2/NEUROSYM.a2ml | 37 ++- .machine_readable/6a2/PLAYBOOK.a2ml | 179 ++++------- .machine_readable/6a2/README.adoc | 35 ++- .machine_readable/6a2/STATE.a2ml | 84 +++-- .../contractiles/Adjustfile.a2ml | 81 +---- .../contractiles/Intentfile.a2ml | 106 ++----- .machine_readable/contractiles/Justfile | 43 ++- .machine_readable/contractiles/Mustfile.a2ml | 104 +++--- .machine_readable/contractiles/Trustfile.a2ml | 95 ++---- 0-AI-MANIFEST.a2ml | 44 +-- CHANGELOG.adoc | 24 +- CODE_OF_CONDUCT.adoc | 2 + Cargo.toml | 4 +- EXPLAINME.adoc | 44 +-- GOVERNANCE.adoc | 74 ++--- Justfile | 43 ++- MAINTAINERS | 44 +-- MAINTAINERS.adoc | 3 +- README.adoc | 132 ++++---- SECURITY.adoc | 7 +- crates/launcher-common/Cargo.toml | 8 +- crates/launcher-common/src/config.rs | 194 +++++++++++- crates/launcher-common/src/discovery.rs | 2 +- crates/launcher-common/src/fs_utils.rs | 160 ++++++++++ crates/launcher-common/src/integration.rs | 235 +++++++++++--- crates/launcher-common/src/lib.rs | 28 +- crates/launcher-common/src/metadata_block.rs | 17 +- crates/launcher-common/src/standard.rs | 13 +- crates/launcher-common/src/template.rs | 183 ++++++++--- .../tests/fixtures/metadata_block/README.adoc | 2 + crates/launcher/Cargo.toml | 2 +- crates/launcher/src/cmd_config.rs | 9 +- crates/launcher/src/cmd_mint.rs | 28 +- crates/launcher/src/cmd_realign.rs | 29 +- crates/launcher/src/cmd_standard.rs | 88 +++++- crates/launcher/src/main.rs | 18 +- ...nch-protection-remediation-2026-04-10.adoc | 2 + docs/compliance-audit-2026-04-10.adoc | 8 +- docs/launcher-exceptions-2026-04-10.adoc | 4 +- docs/ruleset-audit-2026-04-10/README.adoc | 4 +- ...FER-VERIFICATION-CHECKLIST-2026-04-22.adoc | 2 + docs/tech-debt-2026-05-26.adoc | 2 + examples/README.adoc | 2 + examples/stapeln.launcher.fixture.a2ml | 5 +- manifest.scm | 3 + mise.toml | 65 +--- templates/launcher.sh.tera | 295 ++++++++++++++---- 58 files changed, 1790 insertions(+), 1045 deletions(-) create mode 100644 .github/CODE_OF_CONDUCT.md create mode 100644 .github/ISSUE_TEMPLATE/bug_report.yml create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/feature_request.yml create mode 100644 .github/SECURITY.md create mode 100644 crates/launcher-common/src/fs_utils.rs diff --git a/.github/CODE_OF_CONDUCT.md b/.github/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..dfc1614 --- /dev/null +++ b/.github/CODE_OF_CONDUCT.md @@ -0,0 +1,13 @@ + +# Code of Conduct + +This project follows the [Contributor Covenant, version 2.1](https://www.contributor-covenant.org/version/2/1/code_of_conduct/), with the project-specific reporting and enforcement process described in [`../CODE_OF_CONDUCT.adoc`](../CODE_OF_CONDUCT.adoc). + +Participation is expected to be respectful, inclusive, and free of harassment. Unacceptable conduct includes personal attacks, discriminatory language, intimidation, doxxing, unwanted sexual attention, and retaliation against someone who reports a concern. + +## Reporting + +Report conduct concerns privately to **j.d.a.jewell@open.ac.uk**. Do not use a public issue for a sensitive report. The maintainer will review reports discreetly and may remove content, restrict participation, or take other appropriate action. The full policy is in [`../CODE_OF_CONDUCT.adoc`](../CODE_OF_CONDUCT.adoc). diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 553670b..f9bbf97 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -1,3 +1,6 @@ +# SPDX-License-Identifier: CC-BY-SA-4.0 +# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell + # Contributing Contributions are welcome! Please: diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..afd259e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,40 @@ +# SPDX-License-Identifier: CC-BY-SA-4.0 +# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +name: Bug report +description: Report a reproducible defect. +labels: ["bug", "needs-triage"] +body: + - type: markdown + attributes: + value: For security vulnerabilities, use the private reporting path in `.github/SECURITY.md`; do not open a public issue. + - type: textarea + id: observed + attributes: + label: Observed behavior + description: Include the exact command and output. + validations: + required: true + - type: textarea + id: expected + attributes: + label: Expected behavior + validations: + required: true + - type: textarea + id: reproduction + attributes: + label: Minimal reproduction + validations: + required: true + - type: input + id: version + attributes: + label: Version or commit + validations: + required: true + - type: input + id: environment + attributes: + label: OS and Rust version + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..a8426e6 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,6 @@ +# SPDX-License-Identifier: CC-BY-SA-4.0 +blank_issues_enabled: true +contact_links: + - name: Security vulnerability + url: https://github.com/hyperpolymath/launch-scaffolder/security/advisories/new + about: Report vulnerabilities privately; do not use a public issue. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..5f96949 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,24 @@ +# SPDX-License-Identifier: CC-BY-SA-4.0 +# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +name: Feature request +description: Propose a scoped improvement. +labels: ["enhancement", "needs-triage"] +body: + - type: textarea + id: problem + attributes: + label: Problem to solve + validations: + required: true + - type: textarea + id: proposal + attributes: + label: Proposed behavior + validations: + required: true + - type: textarea + id: alternatives + attributes: + label: Alternatives and compatibility impact + validations: + required: false diff --git a/.github/SECURITY.md b/.github/SECURITY.md new file mode 100644 index 0000000..097207f --- /dev/null +++ b/.github/SECURITY.md @@ -0,0 +1,19 @@ + +# Security Policy + +## Reporting a vulnerability + +Please report security vulnerabilities privately through [GitHub Security Advisories](https://github.com/hyperpolymath/launch-scaffolder/security/advisories/new). Do not open a public issue or pull request for an undisclosed vulnerability. + +If GitHub Advisories are unavailable, email **j.d.a.jewell@open.ac.uk** with the subject `Security report: launch-scaffolder`. Include the affected version or commit, impact, and reproducible steps. Please do not include secrets belonging to other people. + +## Response + +The maintainer aims to acknowledge reports within 48 hours and provide an initial triage within seven days. Fix and disclosure timing will be coordinated with the reporter; these are targets, not guarantees. + +## Supported versions + +Security fixes target the latest code on `main`. Older generated launchers are not automatically updated; re-mint them with a current `launch-scaffolder` release. diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 4d9ae2f..0e9f34d 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,6 +1,6 @@ # SPDX-License-Identifier: MPL-2.0 # Dependabot configuration for RSR-compliant repositories -# Covers common ecosystems - remove unused ones for your project +# Only ecosystems used by this Rust/SPARK repository are configured. version: 2 updates: @@ -25,27 +25,3 @@ updates: # current Dependabot behaviour. See rsr-template-repo commit 78b050e # and 007-lang/audits/audit-dependabot-automation-gap-2026-04-17.md. open-pull-requests-limit: 0 - - # Elixir/Mix - - package-ecosystem: "mix" - directory: "/" - schedule: - interval: "weekly" - - # Node.js/npm - - package-ecosystem: "npm" - directory: "/" - schedule: - interval: "weekly" - - # Python/pip - - package-ecosystem: "pip" - directory: "/" - schedule: - interval: "weekly" - - # Nix flakes - - package-ecosystem: "nix" - directory: "/" - schedule: - interval: "weekly" diff --git a/.machine_readable/6a2/0-AI-MANIFEST.a2ml b/.machine_readable/6a2/0-AI-MANIFEST.a2ml index cede8a9..e9126ce 100644 --- a/.machine_readable/6a2/0-AI-MANIFEST.a2ml +++ b/.machine_readable/6a2/0-AI-MANIFEST.a2ml @@ -1,3 +1,5 @@ +# SPDX-License-Identifier: CC-BY-SA-4.0 +# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell # AI Manifest for 6a2 Directory ## Purpose diff --git a/.machine_readable/6a2/AGENTIC.a2ml b/.machine_readable/6a2/AGENTIC.a2ml index 919e611..e047f96 100644 --- a/.machine_readable/6a2/AGENTIC.a2ml +++ b/.machine_readable/6a2/AGENTIC.a2ml @@ -1,12 +1,13 @@ # SPDX-License-Identifier: MPL-2.0 # Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) # -# AGENTIC.a2ml — AI agent constraints and capabilities -# Defines what AI agents can and cannot do in this repository. +# AGENTIC.a2ml — AI agent constraints and capabilities for launch-scaffolder. [metadata] -version = "0.1.0" -last-updated = "2026-04-11" +format = "a2ml" +schema = "agentic" +schema-version = "0.1" +last-updated = "2026-09-26" [agent-permissions] can-edit-source = true @@ -16,17 +17,14 @@ can-edit-config = true can-create-files = true [agent-constraints] -# What AI agents must NOT do: -# - Never use banned language patterns (believe_me, unsafeCoerce, etc.) -# - Never commit secrets or credentials -# - Never use banned languages (TypeScript, Python, Go, etc.) -# - Never place state files in repository root (must be in .machine_readable/) -# - Never relicense an existing file, and never run an automated licence -# sweep (LICENCE-POLICY.adoc A2). New files get correct SPDX from birth. -# - Never assume a licence. Read standards/LICENCE-POLICY.adoc: Rule 1 -# defaults to MPL-2.0 (code) / CC-BY-SA-4.0 (prose), but Rule 3 -# (co-developed), Rule 4 (network-deployed services) and Rule 5 -# (games) are AGPL-3.0-or-later, and Rule 2 names the PMPL register. +# - Never commit secrets, credentials, or private user data. +# - Keep repository code in Rust; generated launchers are Bash via Tera. +# - Do not add Python, TypeScript, or Go source to this Rust-primary repository. +# - Never switch from the session's fixed Arena branch or rewrite .git state. +# - Preserve existing licenses; new files require an appropriate SPDX header. +# - Read .github/SECURITY.md and keep undisclosed vulnerability reports private. +# - Treat unmarked desktop entries, launcher binaries, and icons as user-owned. +# - Do not describe macOS/Windows native provisioning or placeholder APIs as done. [maintenance-integrity] fail-closed = true @@ -35,22 +33,11 @@ allow-silent-skip = false require-rerun-after-fix = true release-claim-requires-hard-pass = true -# ============================================================================ -# METHODOLOGY (ADR-002) -# ============================================================================ -# Detailed methodology configuration lives in: -# .machine_readable/bot_directives/methodology.a2ml -# .machine_readable/bot_directives/coverage.a2ml -# .machine_readable/bot_directives/debt.a2ml -# -# AGENTIC.a2ml declares WHAT agents can do (permissions, gating). -# bot_directives/ declares HOW agents should work (methodology). - [methodology] -instructions-dir = ".machine_readable/bot_directives/" -default-mode = "hybrid" +instructions-dir = ".machine_readable/contractiles/" +default-mode = "inspect, implement, validate, document" [automation-hooks] -# on-enter: Read 0-AI-MANIFEST.a2ml, then STATE.a2ml, then bot_directives/ -# on-exit: Update STATE.a2ml, coverage.a2ml, and debt.a2ml with session outcomes -# on-commit: Run just validate-rsr +# on-enter: Read root 0-AI-MANIFEST.a2ml, then this directory's STATE.a2ml. +# on-exit: Update STATE.a2ml and relevant repository documentation with outcomes. +# on-validation: Run `just validate` and the launcher-artifact workflow when possible. diff --git a/.machine_readable/6a2/ECOSYSTEM.a2ml b/.machine_readable/6a2/ECOSYSTEM.a2ml index 673d18a..00ac245 100644 --- a/.machine_readable/6a2/ECOSYSTEM.a2ml +++ b/.machine_readable/6a2/ECOSYSTEM.a2ml @@ -8,20 +8,19 @@ format = "a2ml" schema = "ecosystem" schema-version = "0.1" # ISO 8601 UTC timestamp (see STATE.a2ml for convention). -updated = "2026-04-10T20:00:00Z" +updated = "2026-09-26T00:00:00Z" [ecosystem] version = "0.1" name = "launch-scaffolder" type = "cross-cutting-tool" -purpose = "Generate/install/maintain cross-platform desktop launchers for every hyperpolymath project from a single declarative spec." +purpose = "Generate and maintain portable launcher scripts from a shared standard, with native Linux desktop provisioning." position-in-ecosystem = """ launch-scaffolder is estate-wide tooling. It sits between the launcher -*standard* (lives in standards/ monorepo) and every project that needs a -desktop launcher. It is a *producer* of launcher scripts, not a runtime -library — its output is consumed by desktop environments, not by other -hyperpolymath tools at runtime. +*standard* (maintained in the standards monorepo) and projects that need a +launcher. It produces scripts and manages Linux desktop entries; it is not a +runtime library. Native macOS and Windows provisioning remain unsupported. """ [[related-projects]] @@ -75,7 +74,7 @@ detail = "Process-kind launcher." [[related-projects]] name = "hyperpolymath-ecosystem" relationship = "umbrella" -detail = "Every hyperpolymath repo that ships a desktop launcher is a potential consumer. The 5 declared exceptions in docs/launcher-exceptions-2026-04-10.md are the current non-consumers and have explicit migration triggers." +detail = "Every hyperpolymath repo that ships a desktop launcher is a potential consumer. The 5 declared exceptions in docs/launcher-exceptions-2026-04-10.adoc are the current non-consumers and have explicit migration triggers." [[related-projects]] name = "invariant-path" @@ -90,9 +89,9 @@ relationship = "declared-exception" detail = "Stays hand-written — remote web app with gossamer fallback and bespoke modes." [integration-points] -input-config-format = "A2ML (TOML-compatible today) at /.launcher.a2ml" -input-standard = "praxis DEED (DEED v1.0.0), launcher-standard_praxis.deed, located via the :priority search ladder the deed declares for itself under (resolution) -> standard-search" -output-launcher = "POSIX bash script at /-launcher.sh (chmod 755)" -output-desktop-entry = "Freedesktop .desktop file written by the generated script's --integ mode" -output-bundle-macos = ".app bundle written by the generated script's --integ mode" -output-shortcut-win = ".lnk or .bat written by the generated script's --integ mode" +input-config-format = "TOML content under the legacy /.launcher.a2ml filename; DEED migration is tracked with standards#960" +input-standard = "Praxis DEED v1.0.0, launcher-standard_praxis.deed, located via the :priority search ladder declared under (resolution) -> standard-search" +output-launcher = "Bash script at /-launcher.sh (mode 0755 unless --no-chmod)" +output-desktop-entry = "Freedesktop .desktop files installed by the native Linux provisioner or generated-shell fallback" +output-bundle-macos = "Not implemented; native macOS integration is planned" +output-shortcut-win = "Not implemented; native Windows integration is planned" diff --git a/.machine_readable/6a2/META.a2ml b/.machine_readable/6a2/META.a2ml index f5058fa..05f367a 100644 --- a/.machine_readable/6a2/META.a2ml +++ b/.machine_readable/6a2/META.a2ml @@ -10,7 +10,7 @@ schema = "meta" schema-version = "0.1" # ISO 8601 UTC with `Z` suffix — same-day intermittent writes stay # distinguishable in git history. See STATE.a2ml for the same convention. -updated = "2026-04-10T20:00:00Z" +updated = "2026-09-26T00:00:00Z" [project] name = "launch-scaffolder" @@ -129,7 +129,7 @@ present for each kind. consequences = """ Most launchers fit one of the three shapes cleanly. 5 outliers are declared exceptions and stay hand-written until the scaffolder grows a -custom-modes hook (see docs/launcher-exceptions-2026-04-10.md). +custom-modes hook (see docs/launcher-exceptions-2026-04-10.adoc). """ [[architecture-decisions]] @@ -155,13 +155,13 @@ build/dev noise (`target/`, `.git/`, `node_modules/`, `_exploratory/`, `.archive*/`) — never directories that might hold real work. The scaffolder's own `examples/stapeln.launcher.a2ml` was renamed to `examples/stapeln.launcher.fixture.a2ml` as the first consumer of the -new rule, and the convention is documented in `examples/README.md`. +new rule, and the convention is documented in `examples/README.adoc`. """ consequences = """ Fixtures can live anywhere, including inside consumer repos, without interfering with estate walks. Contributors must name new fixtures with the `.fixture.` infix — there is no directory-based safety net. -The `examples/README.md` is the single contributor-facing source of +The `examples/README.adoc` is the single contributor-facing source of truth for the rule. """ @@ -231,12 +231,11 @@ title = "Hand-rolled metadata-block parser, not coerced TOML" status = "accepted" date = "2026-04-10T20:00:00Z" context = """ -cmd_config needs to get/set/validate fields inside the +Historically, cmd_config needed to get/set/validate fields inside the `# @a2ml-metadata begin ... # @a2ml-metadata end` block embedded at the -top of every generated launcher. The format is `#`-prefixed lines, -wrapped in `(` / `)`, with scalar values in double quotes and list -values on their own lines inside `[ ... ]`. It is neither standard -TOML nor any other off-the-shelf format. +top of generated launchers. The legacy format is `#`-prefixed lines, wrapped in `(` / `)`, with +scalar values in double quotes and list values on their own lines inside +`[ ... ]`. Current launchers emit a DEED `@launcher-deed` block instead. """ decision = """ Write a small hand-rolled scanner in @@ -249,12 +248,9 @@ of the target line, preserving column alignment and surrounding whitespace. """ consequences = """ -The format is 100% controlled by `launcher.sh.tera`, so brittleness is -bounded. `REQUIRED_SCALAR_KEYS` is a module-level const — the single -source of truth for what `config validate` enforces. List keys are -read-only via `config get`; `config set` on a list key returns a -structured error. Round-trip (parse → rewrite → reparse) is covered -by tests. +The legacy format is retained for reads and safe scalar edits, while +current DEED blocks are parsed and validated but not edited by `config set`. +List keys remain read-only, and round-trip behavior is covered by tests. """ # --------------------------------------------------------------------------- @@ -262,11 +258,12 @@ by tests. # --------------------------------------------------------------------------- [development-practices] -build = "cargo build --release" -test = "cargo test --release" -lint = "cargo clippy --workspace --all-targets -- -D warnings" +build = "cargo build --locked --workspace" +test = "cargo test --locked --workspace" +lint = "cargo clippy --locked --workspace --all-targets -- -D warnings" format = "cargo fmt --all" -pre-commit = "just pre-commit (fmt-check + lint + test)" +pre-commit = "just pre-commit (fmt-check + check + lint + test)" +validate = "just validate (fmt-check + check + lint + test + standard validation)" license-header = "SPDX-License-Identifier: MPL-2.0 on every .rs, .tera, .a2ml, .adoc, .sh file" author = "Jonathan D.A. Jewell <6759885+hyperpolymath@users.noreply.github.com>" diff --git a/.machine_readable/6a2/NEUROSYM.a2ml b/.machine_readable/6a2/NEUROSYM.a2ml index 1acf7a3..cc56cc2 100644 --- a/.machine_readable/6a2/NEUROSYM.a2ml +++ b/.machine_readable/6a2/NEUROSYM.a2ml @@ -1,23 +1,30 @@ # SPDX-License-Identifier: MPL-2.0 # Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) # -# NEUROSYM.a2ml — Neurosymbolic integration metadata -# Configuration for Hypatia scanning and symbolic reasoning. +# NEUROSYM.a2ml — Analysis and policy metadata for launch-scaffolder. [metadata] -version = "0.1.0" -last-updated = "2026-04-11" +format = "a2ml" +schema = "neurosym" +schema-version = "0.1" +last-updated = "2026-09-26" -[hypatia-config] -scan-enabled = true -scan-depth = "standard" # quick | standard | deep -report-format = "logtalk" +[static-analysis] +security-workflow = ".github/workflows/hypatia-scan.yml" +codeql-workflow = ".github/workflows/codeql.yml" +review-mode = "human-reviewed" -[symbolic-rules] -# Custom symbolic rules for this project -# - { name = "no-unsafe-ffi", pattern = "believe_me|unsafeCoerce", severity = "critical" } +[project-rules] +# High-risk paths to review with every relevant change: +# - templates/launcher.sh.tera: shell quoting, process IDs, filesystem targets +# - crates/launcher-common/src/config.rs: descriptor validation +# - crates/launcher-common/src/integration.rs: desktop files and ownership +# - crates/launcher-common/src/fs_utils.rs: atomic file replacement +# +# No neural model, Logtalk runtime, or generated model configuration is used +# by this repository. Hypatia/CodeQL behavior is defined by the workflows. -[neural-config] -# Neural pattern detection settings -# confidence-threshold = 0.85 -# model = "hypatia-v2" +[validation-evidence] +rust-tests = "just test" +strict-lints = "just lint" +rendered-shell = ".github/workflows/launcher-artefacts.yml (bash/ShellCheck gates)" diff --git a/.machine_readable/6a2/PLAYBOOK.a2ml b/.machine_readable/6a2/PLAYBOOK.a2ml index 676ec4c..db98bf8 100644 --- a/.machine_readable/6a2/PLAYBOOK.a2ml +++ b/.machine_readable/6a2/PLAYBOOK.a2ml @@ -1,137 +1,70 @@ # SPDX-License-Identifier: MPL-2.0 # Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) # -# PLAYBOOK.a2ml — Operational playbook -# Runbooks, incident response, deployment procedures. +# PLAYBOOK.a2ml — Operational playbook for launch-scaffolder. [metadata] -version = "0.1.0" -last-updated = "2026-04-11" +version = "0.2.0" +last-updated = "2026-09-26" -[deployment] -# method = "gitops" # gitops | manual | ci-triggered -# target = "container" # container | binary | library | wasm +[project] +repository = "hyperpolymath/launch-scaffolder" +branch = "main" +source-language = "Rust" +template-language = "Bash via Tera" [incident-response] -# 1. Check .machine_readable/STATE.a2ml for current status -# 2. Review recent commits and CI results -# 3. Run `just validate` to check compliance -# 4. Run `just security` to audit for vulnerabilities +# 1. Read .machine_readable/6a2/STATE.a2ml and the latest CHANGELOG.adoc entry. +# 2. Reproduce with the smallest per-app TOML descriptor and generated script. +# 3. For security reports, follow .github/SECURITY.md; do not open a public issue. +# 4. Run `just validate` when Rust tooling is available; run the launcher-artifact +# workflow for rendered shell syntax, ShellCheck, test-count, and fixture gates. +# 5. For integration incidents, preserve unmarked files; the provisioner refuses +# to claim or delete them. Inspect the per-target marker before recovery. -[release-process] -# 1. Update version in STATE.a2ml, META.a2ml, Justfile -# 2. Run `just release-preflight` (validate + quality + security + maint-hard-pass) -# 3. Optional local permission hardening: `just perms-snapshot && just perms-lock` -# 4. Tag and push -# 5. Restore local permissions if needed: `just perms-restore` -# 6. Run `just container-push` if applicable - -[maintenance-operations] -# Baseline audit: -# just maint-audit -# Hard release gate: -# just maint-hard-pass -# Permission audit: -# just perms-audit - -[rsr-repo-skeleton] -# Canonical organisation of any RSR-derived repository. -# Used by tooling, human onboarding, and the scheduled downstream sweep agent. -# The 5-PR cleanup pattern (below) brings a non-conforming repository into -# compliance with this skeleton. -# -# This section is the single source of truth for "what does an RSR repo look -# like?". Other docs (TOPOLOGY, AUDIT, etc.) describe the repo at hand; -# this describes the canonical shape that all RSR repos share. +[development-commands] +format = "just fmt" +format-check = "just fmt-check" +check = "just check" +lint = "just lint" +test = "just test" +validate = "just validate" +standard-show = "just standard" +standard-validate = "just validate-standard" +secret-scan = "just secret-scan-trufflehog (requires trufflehog)" -skeleton-version = "1.0" -last-updated = "2026-04-30" -authority-allowlist = ".machine_readable/root-allow.txt" -enforcement-workflow = ".github/workflows/estate-rules.yml" - -# === Required at root === -# README.adoc High-level pitch (project entry point) -# AUDIT.adoc Local gate summary (release-readiness) -# EXPLAINME.adoc Developer deep-dive (architecture & invariants) -# 0-AI-MANIFEST.a2ml AI agent work-allocation policy -# LICENSE Repo license (root-bound by convention) -# CHANGELOG.md One of the recognised .md exceptions (see below) -# Justfile Task runner — thin, imports per-section files from build/just/ -# coordination.k9 Repo-local session binding - -# === Required directories === -# .github/ CONTRIBUTING.md, CODE_OF_CONDUCT.md, SECURITY.md, workflows/ -# .machine_readable/ AI manifests (0.1-AI-MANIFEST.a2ml), 6a2/ checkpoints, -# contractiles/, configs/, anchors/, policies/, scripts/, svc/ -# build/ contractile.just, flake.nix, guix.scm, Containerfile, -# just/*.just (Justfile section imports) -# docs/ onboarding/, status/, architecture/, governance/ (all .adoc) -# session/ dispatch.sh, custom-checks.k9, local-hooks.sh -# src/ Project source (Idris2 ABI under abi/, Zig FFI under ffi/) -# tests/, benches/, examples/, features/, scripts/, verification/, container/ - -# === Documentation format rule === -# `.adoc` is the default for all general docs (TOPOLOGY, READINESS, ROADMAP, -# TEST-NEEDS, PROOF-NEEDS, PROOF-STATUS, llm-warmup-*, etc.). -# -# `.md` is reserved ONLY for files GitHub's community-health rules -# special-case by name: -# CONTRIBUTING.md CODE_OF_CONDUCT.md SECURITY.md CHANGELOG.md -# -# Enforcement: `scripts/check-no-md-in-docs.sh` (fails if any *.md under docs/). +[release-process] +# 1. Update workspace versions, Cargo.lock, CHANGELOG.adoc, and machine-readable state. +# 2. Run `just validate` and the launcher-artifact workflow. +# 3. Review CodeQL, Hypatia, governance, and Dependabot results. +# 4. Create/publish releases only after a versioned release has been prepared. +# 5. Do not claim macOS/Windows native integration; only Linux is implemented. -# === Banned: ziguage === -# V (vlang.io) is banned estate-wide. Replaced by `zig-unified-api-adapter` -# (16 endpoints + transaction-based firewall gating). Do not introduce -# zig code, scaffolders, or references. Note that Coq theorem files use -# the same `.v` extension and are unaffected — the rule looks at content -# patterns, not the extension. -# -# Enforcement: `scripts/check-no-vlang.sh`. +[repository-layout] +readme = "README.adoc" +architecture-guide = "EXPLAINME.adoc" +state = ".machine_readable/6a2/STATE.a2ml" +standards-copy = "standards/launcher-standard_praxis.deed" +launcher-template = "templates/launcher.sh.tera" +per-app-descriptor = "examples/stapeln.launcher.fixture.a2ml" +test-suite = "crates/launcher-common/src and crates/launcher/src unit tests" +community-health = ".github/CONTRIBUTING.md, .github/CODE_OF_CONDUCT.md, .github/SECURITY.md" -# === Justfile structure (post-split) === -# The root Justfile is thin — it holds `set` directives, project metadata -# variables, and the `default`/`help`/`info` recipes. Each major section -# lives in its own file under build/just/ and is brought in via `import?`. -# -# Imported sections (in the canonical split): -# build/just/init.just INIT recipe (template bootstrap) -# build/just/assess.just self-assess + verify (OpenSSF compliance) -# build/just/validate.just validate-rsr/state/ai-install + aggregate -# build/just/proofs.just proof-check-{all,idris2,lean4,agda,coq}, -# proof-scan-dangerous, proof-status -# build/just/groove.just Groove protocol setup (after zig removed) -# -# Daily-use recipes (BUILD, TEST, LINT, RUN, DEPS, DOCS, CONTAINER, CI, -# SECURITY, STATE, GUIX/NIX, MATRIX, VERSION CONTROL, UTILITIES, SESSION) -# stay in the root Justfile where users expect to find them. - -# === 5-PR cleanup pattern === -# Apply these branches (in order) to bring a non-conforming downstream repo -# into compliance with this skeleton: -# -# 1. chore/root-cleanup Relocate root sprawl per root-allow.txt; add -# scripts/check-root-shape.sh; remove stub -# health files shadowed by .github/ versions. -# 2. chore/remove-zig Purge zig remnants (gen-v-connector recipe, -# "V-TRIPLE" section header, "V-triple -# connectors" comment in groove.a2ml). -# 3. chore/md-to-adoc Port general docs in docs/ from .md to .adoc; -# update validate-template.sh to accept .adoc -# fallbacks. -# 4. chore/estate-rules-ci Add scripts/check-no-md-in-docs.sh + check-no- -# vlang.sh + .github/workflows/estate-rules.yml. -# 5. chore/-hygiene Repo-specific drift cleanup (case collisions, -# template-derivation drift in titles, etc.). +[format-policy] +# General project documentation uses AsciiDoc (.adoc). GitHub-recognized +# community-health documents and issue forms live under .github/ as Markdown +# or YAML. The current per-app descriptor contents are TOML despite their +# .a2ml names; migration to the normative DEED descriptor format is tracked +# with hyperpolymath/standards#960. Do not silently rename or reinterpret them. -# === Reference scripts === -# scripts/check-root-shape.sh Root allowlist validator -# scripts/check-no-md-in-docs.sh AsciiDoc-by-default validator -# scripts/check-no-vlang.sh zig ban validator -# scripts/validate-template.sh Aggregate RSR compliance (workflows, SPDX, etc.) +[known-limitations] +# Native desktop integration is Linux-only. `platform.rs`, `integrity.rs`, and +# `exceptions.rs` contain placeholders and are not advertised as implemented. +# Golden mint coverage exists for the committed Stapeln fixture, not all seven +# estate consumers. Five declared exceptions remain hand-written. -# === Reference memory entries (for AI agents) === -# feedback_adoc_default_md_for_githealth AsciiDoc-by-default rule -# feedback_v_lang_banned zig ban -# project_zig_unified_api Replacement for v-triple/zig -# feedback_gh_workflow_scope OAuth scope for workflow files +[standards-alignment] +# The canonical launcher standard is maintained in hyperpolymath/standards at +# launcher/launcher-standard_praxis.deed. The repository carries a baked copy; +# compare them before changing the vendored standard. RSR's maximal template +# inventory is not a checklist to copy wholesale into this instantiated project. diff --git a/.machine_readable/6a2/README.adoc b/.machine_readable/6a2/README.adoc index c851cfb..0fcef5a 100644 --- a/.machine_readable/6a2/README.adoc +++ b/.machine_readable/6a2/README.adoc @@ -1,20 +1,29 @@ // SPDX-License-Identifier: CC-BY-SA-4.0 -// Copyright (c) Jonathan D.A. Jewell -# A2ML 6a2 Directory +// Copyright (c) 2026 Jonathan D.A. Jewell += Machine-readable project state -This directory contains the 6 core A2ML machine-readable metadata files for this repository. +This directory contains project-specific metadata and operational records for +`launch-scaffolder`. These files describe this repository; they are not a +verbatim checklist from the RSR template. -## Files +== Files -- `AGENTIC.a2ml` - AI agent operational gating, safety controls -- `ECOSYSTEM.a2ml` - Project ecosystem position, relationships, explicit boundaries -- `META.a2ml` - Architecture decisions (ADRs), development practices, design rationale -- `NEUROSYM.a2ml` - Symbolic semantics, composition algebra -- `PLAYBOOK.a2ml` - Executable plans, operational runbooks -- `STATE.a2ml` - Project state, phase, milestones, session history +* `AGENTIC.a2ml` — agent capabilities, constraints, and validation expectations +* `ECOSYSTEM.a2ml` — consumers, standards relationship, and integration boundaries +* `META.a2ml` — architecture decisions and design rationale +* `NEUROSYM.a2ml` — analysis workflows and high-risk code paths +* `PLAYBOOK.a2ml` — operational and release guidance +* `STATE.a2ml` — implementation status, completed work, and next steps -## Standards Compliance +`0-AI-MANIFEST.a2ml` in this directory indexes the six core files. The root +`0-AI-MANIFEST.a2ml` points agents to the canonical sources and current project +constraints. Repository contractiles live in `../contractiles/`. -These files follow the A2ML Format Family specification from: -https://github.com/hyperpolymath/standards/tree/main/a2ml +== Format notes +The `.a2ml` suffix identifies machine-readable estate metadata; several files +use TOML-compatible syntax, while contractiles have their own structured +format. The launcher standard itself is DEED at +`../../standards/launcher-standard_praxis.deed`. Per-app descriptors still +contain TOML under the legacy `.launcher.a2ml` name; migration is tracked with +the standards repository. diff --git a/.machine_readable/6a2/STATE.a2ml b/.machine_readable/6a2/STATE.a2ml index ccab76f..1ed4bd5 100644 --- a/.machine_readable/6a2/STATE.a2ml +++ b/.machine_readable/6a2/STATE.a2ml @@ -11,39 +11,35 @@ schema-version = "0.1" # `updated` is ISO 8601 UTC with a `Z` suffix. Same-day intermittent # writes are distinguished by the time component so git history # shows the progression of checkpoints. -updated = "2026-04-10T21:09:09Z" +updated = "2026-09-26T00:00:00Z" author = "Jonathan D.A. Jewell" [project] name = "launch-scaffolder" version = "0.1.0" phase = "alpha" -one-line = "Rust/SPARK cross-platform desktop launcher minter/provisioner/configurator." +one-line = "Rust and shell launcher minter/configurator with Linux desktop provisioning." [current-position] -milestone = "phase-4-config-inspector" -milestone-complete = true -completion-percent = 65 +milestone = "phase-5-launcher-hardening" +milestone-complete = false +completion-percent = 75 summary = """ -Mint + realign + provision all working end-to-end. Provision is -option-(b): desktop integration (.desktop writing, icon copy, launcher -install, best-effort gio/update-desktop-database) lives in the Rust -binary in `launch-scaffolder-common::integration`. The generated -shell script's --integ / --disinteg arms now fast-path to the binary -via `launch-scaffolder provision --integ "$CONFIG_FILE" --no-confirm` -when the binary is on PATH, and fall back to the in-script shell -implementation otherwise — so the binary is authoritative without -making itself a hard dependency. CONFIG_FILE is embedded into each -rendered launcher by the template. Bulk provision requires --all for -safety, prompts before touching $HOME unless --no-confirm is set, -supports --force for reinstall, and has a --dry-run preview. - -Discovery (walk + prune + fixture filter) is now shared via -`launch-scaffolder-common::discovery`, consumed by both realign and -provision. Nine passing unit tests cover discovery, integration -rendering, standard parsing, and config validation. The 7 live -launchers in the estate have been realigned to pick up the new -delegation arms. 5 declared exceptions remain out of scope. +The five CLI subcommands (mint, realign, provision, config, standard) are +implemented. Launcher artefact CI runs locked Rust checks, enforces a test +count floor, re-mints a committed DEED fixture, checks fixture currency, and +ShellChecks the generated script. The template now shell-quotes config +values, validates app identifiers and URLs, uses private per-app XDG PID/log +folders, validates PID content before signaling, and routes browser aliases +through --auto. Native Linux integration writes atomically and only replaces +or removes files carrying a launch-scaffolder ownership marker. GitHub +community health files and project-specific contractiles are present. + +Still outstanding: native macOS/Windows integration, all-seven-consumer +golden coverage, implementations for the platform/integrity/exceptions +library placeholders, the per-app descriptor migration to DEED, and the five +hand-written estate exceptions. The CLI's native integration backend remains +Linux-only; do not present it as fully cross-platform yet. """ [[completed]] @@ -91,7 +87,7 @@ item = "cmd_realign (bulk re-mint): estate-root default, --search-root override, on = "2026-04-10" [[completed]] -item = "Fixture/live suffix convention: `.launcher.fixture.a2ml` vs `.launcher.a2ml`. Renamed examples/stapeln.launcher.a2ml -> examples/stapeln.launcher.fixture.a2ml, documented in examples/README.md" +item = "Fixture/live suffix convention: `.launcher.fixture.a2ml` vs `.launcher.a2ml`. Renamed examples/stapeln.launcher.a2ml -> examples/stapeln.launcher.fixture.a2ml, documented in examples/README.adoc" on = "2026-04-10" [[completed]] @@ -111,7 +107,7 @@ item = "Template delegation: launcher.sh.tera now embeds CONFIG_FILE and its --i on = "2026-04-10T19:08:08Z" [[completed]] -item = "launch-scaffolder-common::metadata_block: hand-rolled parser and in-place rewriter for the `# @a2ml-metadata begin...end` block embedded in every generated launcher. Scalar + list keys, REQUIRED_SCALAR_KEYS exported from one place, 8 unit tests covering parse, validate, rewrite round-trip, unterminated-block error, list-key set rejection" +item = "launch-scaffolder-common::metadata_block: hand-rolled parser and in-place rewriter for the `# @a2ml-metadata begin...end` block embedded in generated launchers at the time. Current launchers use DEED; legacy scalar editing remains supported, REQUIRED_SCALAR_KEYS exported from one place, 8 unit tests covering parse, validate, rewrite round-trip, unterminated-block error, list-key set rejection" on = "2026-04-10T19:30:49Z" [[completed]] @@ -127,27 +123,51 @@ item = "Wave 1 remediation: 21 MISSING repos brought to the 5-rule reference sta on = "2026-04-10T21:00:00Z" [[completed]] -item = "Documentation sweep: README.adoc commands + status overhauled, META.a2ml ADR-006/007/008/009 added, 0-AI-MANIFEST.a2ml fixture-suffix invariant added, ECOSYSTEM.a2ml timestamp refreshed, new docs/branch-protection-remediation-2026-04-10.md post-mortem, audit artefacts moved out of /tmp into docs/ruleset-audit-2026-04-10/" +item = "Documentation sweep: README.adoc commands + status overhauled, META.a2ml ADR-006/007/008/009 added, 0-AI-MANIFEST.a2ml fixture-suffix invariant added, ECOSYSTEM.a2ml timestamp refreshed, new docs/branch-protection-remediation-2026-04-10.adoc post-mortem, audit artefacts moved out of /tmp into docs/ruleset-audit-2026-04-10/" on = "2026-04-10T21:09:09Z" +[[completed]] +item = "Implement standard show/validate CLI; update launcher mode surface with --version and browser/web aliases" +on = "2026-09-26" + +[[completed]] +item = "Harden template rendering against shell injection; validate project identity, metadata and runtime URLs" +on = "2026-09-26" + +[[completed]] +item = "Move default PID/log files to private per-app XDG locations; validate PID state and reject shared writable directories" +on = "2026-09-26" + +[[completed]] +item = "Make file replacement atomic; mark managed desktop entries and refuse clobber/removal of unmarked integrations" +on = "2026-09-26" + +[[completed]] +item = "Add GitHub-recognized security and conduct policies; tailor stale template contractiles and repo state" +on = "2026-09-26" + [[next]] priority = 1 -item = "Golden-file regression tests pinning mint output for the 7 managed launchers" +item = "Add native macOS and Windows integration backends and platform CI" [[next]] priority = 2 -item = "Add a macOS integration backend to launch-scaffolder-common::integration (currently Linux-only)" +item = "Convert per-app launcher descriptors from TOML/A2ML naming to the normative DEED format with standards#960" [[next]] priority = 3 -item = "SPARK integration hook for integrity.rs via Zig FFI" +item = "Implement and test platform, integrity, and exception-merge library APIs before advertising them" + +[[next]] +priority = 4 +item = "Add golden mint fixtures for each managed estate consumer and plan migration of five declared exceptions" [blockers] -# None as of 2026-04-10 — phase-1 delivered cleanly. +# No external blockers are recorded as of 2026-09-26. [exceptions] # The 5 launchers NOT migrated to scaffolder management today, with reasons. -# Full rationale in docs/launcher-exceptions-2026-04-10.md. +# Full rationale in docs/launcher-exceptions-2026-04-10.adoc. hypatia = "remote + gossamer fallback + bespoke CLI modes" invariant-path = "Rust CLI wrapper with scan/cli/status modes over cargo run" opsm = "interactive bash environment setup (sources ~/.bashrc.d/tools/opsm)" diff --git a/.machine_readable/contractiles/Adjustfile.a2ml b/.machine_readable/contractiles/Adjustfile.a2ml index 6f01e89..8b4fd57 100644 --- a/.machine_readable/contractiles/Adjustfile.a2ml +++ b/.machine_readable/contractiles/Adjustfile.a2ml @@ -1,72 +1,25 @@ # SPDX-License-Identifier: MPL-2.0 -# Adjustfile — Drift-tolerance contract for rsr-template-repo -# Author: Jonathan D.A. Jewell -# -# Cumulative-drift catchment: tolerance bands + corrective actions. -# Authority: advisory (Yard) — continue-with-warnings; auto_fix where deterministic. -# Run with: adjust check -# Fix with: adjust fix (applies deterministic patches; advisory otherwise) +# Adjustfile — known, bounded repository drift @abstract: -Drift tolerances and corrective actions for rsr-template-repo. Unlike -MUST (hard gate), ADJUST tracks cumulative drift against tolerance bands -and proposes corrective actions. Advisory — it warns and trends, it does -not block. +Tracks known limitations and their remediation boundary. This is an advisory +record; hard checks live in Mustfile.a2ml and CI. @end -## Template Drift +## Known Limitations -### placeholder-drift -- description: Template placeholders should be replaced when copied -- tolerance: 0 placeholder markers in copied repos -- corrective: Search and replace all {{PLACEHOLDER}} markers -- severity: advisory -- notes: This check only applies to repos that copied from this template +### legacy-per-app-config-format +- description: Per-app descriptors retain the .a2ml filename and TOML syntax while the estate migrates them to DEED +- severity: tracked +- owner: launch-scaffolder maintainers +- reference: hyperpolymath/standards#960 -### template-version-drift -- description: Template version should match RSR spec version -- tolerance: Template version matches current RSR spec -- corrective: Update template to match latest RSR spec -- severity: advisory +### linux-only-native-provisioning +- description: Native Rust desktop integration is currently Linux-only +- severity: tracked +- corrective: Add and test macOS and Windows backends before claiming native cross-platform provisioning -## Documentation Drift - -### readme-completeness -- description: README should document all template features -- tolerance: README covers all contractiles and directory structure -- corrective: Update README.adoc with missing sections -- severity: advisory - -### example-accuracy -- description: Examples in documentation should match actual template content -- tolerance: All code examples in docs are accurate -- corrective: Audit and fix examples in documentation -- severity: advisory - -## Structural Drift - -### contractile-sync -- description: All contractiles should have matching a2ml and ncl implementations -- tolerance: Every .a2ml has a corresponding .ncl -- corrective: Generate missing .ncl files from .a2ml -- severity: advisory - -### no-broken-symlinks -- description: No broken symbolic links in template structure -- tolerance: 0 broken symlinks -- corrective: Run symlink-check script -- severity: advisory - -## Accessibility Drift - -### adoc-not-md -- description: Template docs should prefer AsciiDoc -- tolerance: New prose docs are *.adoc -- corrective: Convert any new *.md to *.adoc -- severity: advisory - -### spdx-header-consistency -- description: All template files have correct SPDX headers -- tolerance: 0 files missing SPDX-License-Identifier -- corrective: Add SPDX headers to files that need them -- severity: advisory +### placeholder-library-modules +- description: platform, integrity and exceptions modules are placeholders and are not used by the CLI +- severity: tracked +- corrective: Implement against normative standards before exposing as supported APIs diff --git a/.machine_readable/contractiles/Intentfile.a2ml b/.machine_readable/contractiles/Intentfile.a2ml index ef74f45..ad869a6 100644 --- a/.machine_readable/contractiles/Intentfile.a2ml +++ b/.machine_readable/contractiles/Intentfile.a2ml @@ -1,99 +1,45 @@ # SPDX-License-Identifier: MPL-2.0 -# Intentfile (A2ML Canonical) — north-star contractile for rsr-template-repo -# Author: Jonathan D.A. Jewell +# Intentfile — launch-scaffolder project intent # -# Paired runner: intend.ncl -# Verb: intend -# -# Semantics: North-star contractile. Declares BOTH concrete committed -# next-actions AND horizon aspirations the project wishes to -# become. Two sections share one file because they answer -# the same question at different ranges: -# [[intents]] — "we WILL do this; track progress" -# status: declared → in_progress → done | -# deferred | retired -# [[wishes]] — "we WISH this were true; revisit later" -# status: declared → in_progress → achieved | -# abandoned -# grouped by horizon: near / mid / far. -# Non-gating — this is a report, not a gate. See the `must` -# contractile for hard gates. +# A north-star record for this repository, not a duplicate of the RSR template. @abstract: -North-star contractile for rsr-template-repo. This repository is the -canonical template for Rhodium Standard Repository compliance. It provides -the scaffold that all hyperpolymath repos should copy and customize. +Intent and aspiration for a Rust/SPARK CLI that mints, validates, provisions, +and realigns launcher scripts from a declarative per-app config and the +estate's normative launcher standard. @end ## Purpose -The rsr-template-repo serves as the master template for all hyperpolymath -repositories. It contains the complete set of contractile files, machine-readable -specifications, and governance documentation that define the Rhodium Standard. - -Every new repository in the hyperpolymath estate should be initialized by -copying this template and substituting the placeholder values with -repo-specific content. - -## Anti-Purpose - -This repository is NOT: -- A general-purpose project scaffold for external use (hyperpolymath-only) -- A replacement for per-repo customization (all files must be bespoke) -- A static template that never changes (evolves with RSR spec) -- A runtime library or framework (build-time only) +The project turns launcher scripts into reproducible artefacts. A change to +the launcher policy should be made once in the standard or generator and then +re-minted, not hand-copied across application repositories. -## If In Doubt +## Boundaries -If you are unsure whether a change is in scope, ask. Sensitive areas: -- .machine_readable/ contractile definitions -- RSR specification files -- Governance templates -- License policy documents +- The tool owns config validation, rendering, launcher metadata, and Linux desktop integration. +- It does not own application startup behavior beyond configured argv/search candidates. +- macOS and Windows desktop integration, SPARK-backed integrity APIs, and the five declared legacy exceptions remain explicit follow-up work. +- The emitted script must safely treat config values as data, not shell source. ## Committed Next-Actions -### repo-initialization -- description: Provide just copy-and-substitute template for new repos -- probe: test -f scripts/init-repo.sh -- status: done -- notes: Run with source scripts/init-repo.sh +### standard-cli +- description: Provide direct CLI inspection and semantic validation of the resolved launcher standard +- status: completed +- evidence: crates/launcher/src/cmd_standard.rs and standard validation tests -### contractile-completeness -- description: Every RSR contractile has an a2ml and ncl implementation -- probe: ls .machine_readable/contractiles/*.a2ml | wc -l | grep -q "^6$" +### standards-compliance +- description: Keep launcher modes, metadata and resolution aligned with the current DEED standard - status: in_progress -- notes: Currently 6 contractile verbs: intend, must, trust, adjust, bust, dust +- evidence: standards/launcher-standard_praxis.deed and standard validation tests -### automation-scripts -- description: All repetitive tasks have just recipes -- probe: grep -c "^# " Justfile | grep -q "^[6-9][0-9]*$" +### generated-artifact-safety +- description: Keep shell quoting, state-file safety and desktop integration ownership checks covered by regression tests - status: in_progress +- evidence: Rust tests, fixture currency gate, ShellCheck -## Wishes - -### Near Horizon - -#### cross-repo-validation -- description: Tooling to validate all repos against RSR spec -- horizon: near -- status: declared - -#### automated-substitution -- description: Script to automate repo-specific substitution in template -- horizon: near -- status: declared - -### Mid Horizon - -#### formal-verification -- description: Idris2 proofs for all critical contractile invariants -- horizon: mid -- status: declared - -### Far Horizon - -#### ecosystem-visualization -- description: Interactive graph of all hyperpolymath repos and dependencies -- horizon: far +### cross-platform-integration +- description: Add and test native macOS and Windows provisioning backends - status: declared +- evidence: Linux-only support is documented in README.adoc diff --git a/.machine_readable/contractiles/Justfile b/.machine_readable/contractiles/Justfile index e9e3e4c..adfb82e 100644 --- a/.machine_readable/contractiles/Justfile +++ b/.machine_readable/contractiles/Justfile @@ -6,23 +6,27 @@ default: # Build debug build: - cargo build --workspace + cargo build --locked --workspace # Build release (optimised, stripped, single-file binary) release: - cargo build --workspace --release + cargo build --locked --workspace --release # Run the binary with args run *args: - cargo run -p launch-scaffolder -- {{args}} + cargo run --locked -p launch-scaffolder -- {{args}} # Run all tests test: - cargo test --workspace + cargo test --locked --workspace + +# Check all targets without changing the lockfile +check: + cargo check --locked --workspace --all-targets # Clippy — strict lint: - cargo clippy --workspace --all-targets -- -D warnings + cargo clippy --locked --workspace --all-targets -- -D warnings # Format fmt: @@ -34,7 +38,7 @@ fmt-check: # Install the binary to ~/.cargo/bin install: - cargo install --path crates/launcher + cargo install --locked --path crates/launcher # Uninstall uninstall: @@ -45,38 +49,42 @@ clean: cargo clean # Pre-commit check sequence -pre-commit: fmt-check lint test +pre-commit: fmt-check check lint test + +# Full local validation sequence +validate: fmt-check check lint test validate-standard + @echo "✓ validation passed" -# Full CI sequence -ci: fmt-check lint test +# CI sequence +ci: validate @echo "✓ CI passed" # Print the baked-in launcher standard standard: - cargo run -p launch-scaffolder -- standard show + cargo run --locked -p launch-scaffolder -- standard show # Validate the launcher standard file validate-standard: - cargo run -p launch-scaffolder -- standard validate + cargo run --locked -p launch-scaffolder -- standard validate # Smoke test: mint a launcher from the stapeln example into /tmp smoke-mint: - cargo run -p launch-scaffolder -- mint examples/stapeln.launcher.a2ml -o /tmp/stapeln-launcher.sh + cargo run --locked -p launch-scaffolder -- mint examples/stapeln.launcher.fixture.a2ml -o /tmp/stapeln-launcher.sh @echo "Smoke test: mint produced /tmp/stapeln-launcher.sh" # Mint a launcher in-place. Pass the path to an .launcher.a2ml file. # Example: just mint /var/mnt/eclipse/repos/aerie/aerie.launcher.a2ml mint config: - cargo run --release -p launch-scaffolder -- mint {{config}} + cargo run --locked --release -p launch-scaffolder -- mint {{config}} # Re-mint every scaffolder-managed launcher in the estate. Edit the list # when adding/removing managed repos. Exceptions live in -# docs/launcher-exceptions-2026-04-10.md. +# docs/launcher-exceptions-2026-04-10.adoc. mint-all: #!/usr/bin/env bash set -euo pipefail BIN="./target/release/launch-scaffolder" - [ -x "$BIN" ] || cargo build --release + [ -x "$BIN" ] || cargo build --locked --release for cfg in \ /var/mnt/eclipse/repos/aerie/aerie.launcher.a2ml \ /var/mnt/eclipse/repos/developer-ecosystem/burble/burble.launcher.a2ml \ @@ -91,7 +99,8 @@ mint-all: # Generate cargo docs doc: - cargo doc --workspace --no-deps --open + cargo doc --locked --workspace --no-deps --open secret-scan-trufflehog: - @command -v trufflehog >/dev/null && trufflehog filesystem . --only-verified || true + @command -v trufflehog >/dev/null || { echo "trufflehog is required for this scan" >&2; exit 127; } + trufflehog filesystem . --only-verified diff --git a/.machine_readable/contractiles/Mustfile.a2ml b/.machine_readable/contractiles/Mustfile.a2ml index 55f8ab4..0cf8444 100644 --- a/.machine_readable/contractiles/Mustfile.a2ml +++ b/.machine_readable/contractiles/Mustfile.a2ml @@ -1,102 +1,82 @@ # SPDX-License-Identifier: MPL-2.0 -# Mustfile — Physical state contract for rsr-template-repo -# Author: Jonathan D.A. Jewell +# Mustfile — launch-scaffolder repository invariants # -# What MUST be true about this repository. Hard requirements. -# Run with: must check -# Fix with: must fix (where a deterministic fix exists) +# Hard requirements tailored to this Rust/SPARK launcher minter. +# The authoritative executable aggregate is `just validate`. @abstract: -Physical-state invariants for rsr-template-repo. This is the canonical -RSR template repository. These are hard requirements — CI and pre-commit -hooks fail if any check fails. +Physical-state invariants for hyperpolymath/launch-scaffolder. Checks cover +community health, the Rust workspace, its machine-readable state, and the +fixture/artifact workflow actually present in this repository. @end ## File Presence ### license-present -- description: LICENSE file must exist -- run: test -f LICENSE +- description: MPL-2.0 license and license text must exist +- run: test -f LICENSE && test -f LICENSES/MPL-2.0.txt - severity: critical ### readme-present -- description: README.adoc must exist +- description: Project README must exist - run: test -f README.adoc - severity: critical -### security-policy -- description: SECURITY.md must exist -- run: test -f SECURITY.md +### github-security-policy +- description: GitHub-recognized security policy must exist +- run: test -f .github/SECURITY.md - severity: critical -### ai-manifest -- description: 0-AI-MANIFEST.a2ml must exist -- run: test -f 0-AI-MANIFEST.a2ml +### community-health +- description: Contribution and conduct policies must be discoverable +- run: test -f .github/CONTRIBUTING.md && test -f .github/CODE_OF_CONDUCT.md - severity: critical ### governance-docs -- description: GOVERNANCE.adoc, MAINTAINERS.adoc, CODEOWNERS must exist +- description: Governance and maintainers records must exist - run: test -f GOVERNANCE.adoc && test -f MAINTAINERS.adoc && test -f .github/CODEOWNERS - severity: critical -### machine-readable-dir -- description: .machine_readable/ directory must exist -- run: test -d .machine_readable +### ai-manifest +- description: Agent entry manifest must exist +- run: test -f 0-AI-MANIFEST.a2ml - severity: critical -## Directory Structure +## Rust Workspace and Standards -### contractiles-complete -- description: All required contractile directories exist -- run: test -d .machine_readable/contractiles && test -d .machine_readable/contractiles/bust && test -d .machine_readable/contractiles/dust +### workspace-lock +- description: Workspace manifest and lockfile must be committed +- run: test -f Cargo.toml && test -f Cargo.lock - severity: critical -### contractiles-files-present -- description: All four primary contractile files exist -- run: test -f .machine_readable/contractiles/Intentfile.a2ml && test -f .machine_readable/contractiles/Mustfile.a2ml && test -f .machine_readable/contractiles/Trustfile.a2ml && test -f .machine_readable/contractiles/Adjustfile.a2ml +### launcher-standard +- description: Vendored launcher DEED standard must exist +- run: test -f standards/launcher-standard_praxis.deed - severity: critical -### bust-dust-files-present -- description: Bustfile and Dustfile exist in their directories -- run: test -f .machine_readable/contractiles/bust/Bustfile.a2ml && test -f .machine_readable/contractiles/dust/Dustfile.a2ml +### template-present +- description: Generator template and fixture config must exist +- run: test -f templates/launcher.sh.tera && test -f examples/stapeln.launcher.fixture.a2ml - severity: critical -### six-directory-present -- description: 6a2 directory exists with required files -- run: test -d .machine_readable/6a2 && test -f .machine_readable/6a2/META.a2ml && test -f .machine_readable/6a2/ECOSYSTEM.a2ml && test -f .machine_readable/6a2/STATE.a2ml && test -f .machine_readable/6a2/PLAYBOOK.a2ml && test -f .machine_readable/6a2/AGENTIC.a2ml && test -f .machine_readable/6a2/NEUROSYM.a2ml +### machine-readable-state +- description: Current state checkpoint and contractiles must exist +- run: test -f .machine_readable/6a2/STATE.a2ml && test -f .machine_readable/contractiles/Mustfile.a2ml && test -f .machine_readable/contractiles/Intentfile.a2ml && test -f .machine_readable/contractiles/Trustfile.a2ml && test -f .machine_readable/contractiles/Adjustfile.a2ml - severity: critical -### anchors-directory -- description: anchors directory exists in 6a2 -- run: test -d .machine_readable/6a2/anchors -- severity: warning - -### self-validating-structure -- description: self-validating directory has k9-svc and examples -- run: test -d .machine_readable/self-validating && test -d .machine_readable/self-validating/k9-svc && test -d .machine_readable/self-validating/examples -- severity: warning - -## Template Integrity - -### no-placeholder-values -- description: No placeholder values remain in template files -- run: test -z "$(grep -r '{{' .machine_readable/contractiles/ 2>/dev/null)" +### locked-ci +- description: Rust CI and generated-artifact gates must be configured +- run: test -f .github/workflows/rust-ci.yml && test -f .github/workflows/launcher-artefacts.yml - severity: critical -- notes: All placeholders must be substituted when copying this template -### template-readonly -- description: Template marker files are not modified -- run: grep -q 'RSR_TEMPLATE_DO_NOT_EDIT' .machine_readable/0.1-AI-MANIFEST.a2ml +### dependency-updates +- description: Cargo and GitHub Actions dependency updates must be monitored +- run: grep -q 'package-ecosystem: "cargo"' .github/dependabot.yml && grep -q 'package-ecosystem: "github-actions"' .github/dependabot.yml - severity: warning -## Git State - -### no-untracked-contractiles -- description: All contractile files are tracked in git -- run: test -z "$(git ls-files -o --exclude-standard .machine_readable/contractiles/ 2>/dev/null)" -- severity: critical +## Secret Safety -### signed-commits -- description: All commits must be signed -- run: git verify-commit HEAD +### no-environment-secret-files +- description: Local environment secrets must not be committed +- run: test ! -e .env && test ! -e .env.local && test ! -e .env.production - severity: critical diff --git a/.machine_readable/contractiles/Trustfile.a2ml b/.machine_readable/contractiles/Trustfile.a2ml index e2028b5..45759be 100644 --- a/.machine_readable/contractiles/Trustfile.a2ml +++ b/.machine_readable/contractiles/Trustfile.a2ml @@ -1,88 +1,37 @@ # SPDX-License-Identifier: MPL-2.0 -# Trustfile — Trust boundaries and integrity invariants for rsr-template-repo -# Author: Jonathan D.A. Jewell -# -# Defines what LLM/SLM agents are trusted to do without asking, and -# integrity invariants that verify the repo has not been tampered with. +# Trustfile — launch-scaffolder trust boundaries @abstract: -Trust boundaries and integrity checks for rsr-template-repo. This file -combines the trust-level definitions from the original TRUST.contractile -with the integrity invariants from the old Trustfile.a2ml. It defines -what AI agents may do autonomously and what requires human approval, -plus checks that verify repository integrity. +Defines the trust boundaries for generated launchers and the local account +files that provisioning may read, write, or remove. @end -## Trust Levels +## Agent Trust -The rsr-template-repo operates at trust level: maximal +Current trust level: standard -Trust levels: -- maximal: Agent may read, build, test, lint, format, heal freely. - Only destructive/external actions require approval. -- standard: Agent may read and build. Test/lint need approval. -- restricted: Agent may read only. All modifications need approval. -- minimal: Agent may read specific files only. Everything else blocked. +- Reading files, editing code/docs, and running local tests are permitted. +- Destructive operations outside the checkout, credential access, and network-side effects require explicit maintainer intent. +- Public issue templates direct vulnerability reports to GitHub Security Advisories. -Current trust level: maximal +## Runtime Invariants -## Integrity Invariants - -### Secrets - -#### no-secrets-committed -- description: No credential files in repo -- run: test ! -f .env && test ! -f credentials.json && test ! -f .env.local && test ! -f .env.production +### Config-is-data +- description: Untrusted config values must be shell-quoted and validated before rendering +- evidence: crates/launcher-common/src/template.rs and config.rs - severity: critical -#### no-private-keys -- description: No private key files committed -- run: "! find . -name '*.pem' -o -name '*.key' -o -name 'id_rsa' -o -name 'id_ed25519' 2>/dev/null | grep -v node_modules | head -1 | grep -q ." +### Per-user-state +- description: PID files are stored under per-app state directories and PID values are numeric before signaling +- evidence: templates/launcher.sh.tera - severity: critical -#### no-tokens-in-source -- description: No hardcoded API tokens in source -- run: "! grep -rE '(api[_-]?key|secret|token|password)\s*[:=]\s*[\"'\\''][A-Za-z0-9]{16,}' --include='*.js' --include='*.ts' --include='*.res' --include='*.py' . 2>/dev/null | grep -v node_modules | head -1 | grep -q ." +### Managed-install-ownership +- description: Provisioning must atomically replace files and must not overwrite or remove unmarked destinations +- evidence: crates/launcher-common/src/integration.rs - severity: critical -## Provenance - -#### author-correct -- description: Git author matches expected identity -- run: "git log -1 --format='%ae' | grep -qE '(hyperpolymath|j\\.d\\.a\\.jewell)'" -- severity: warning - -#### license-content -- description: LICENSE contains expected identifier -- run: grep -q 'PMPL\|MPL\|MIT\|Apache\|LGPL' LICENSE -- severity: warning - -## Template-Specific Trust - -### template-files-readonly -- description: Template scaffold files should not be modified except by maintainer -- run: test -z "$(git status --short .machine_readable/ 2>/dev/null | grep -v '^??' || true)" -- severity: advisory -- notes: Changes to template files require careful review - -### trust-deny-areas -- description: Sensitive areas from INTENT.contractile require explicit approval -- run: echo "Check .machine_readable/ contractiles and governance docs" -- severity: advisory -- areas: - - .machine_readable/ - - GOVERNANCE.adoc - - MAINTAINERS.adoc - - .github/CODEOWNERS - -## Container Security - -#### container-images-pinned -- description: Containerfile uses pinned base images -- run: test ! -f Containerfile || grep -q 'cgr.dev\|@sha256:' Containerfile -- severity: warning - -#### no-dockerfile -- description: No Dockerfile (use Containerfile) -- run: test ! -f Dockerfile -- severity: warning +### No-remote-code-pipelines +- description: Build and CI must not pipe downloaded code directly into a shell +- evidence: .github/workflows/ and Justfile +- severity: critical diff --git a/0-AI-MANIFEST.a2ml b/0-AI-MANIFEST.a2ml index 170d3fa..ddc57ad 100644 --- a/0-AI-MANIFEST.a2ml +++ b/0-AI-MANIFEST.a2ml @@ -7,7 +7,7 @@ format = "a2ml" [project-identity] name = "launch-scaffolder" -one-line = "Cross-platform launcher minter / provisioner / configurator. Generates compliant desktop launcher scripts from A2ML specs." +one-line = "Portable launcher minter/configurator with Linux desktop provisioning. Generates launcher scripts from per-app TOML descriptors and a DEED standard." kind = "standalone top-level repo" language-policy-tier = 1 # Rust/SPARK, CLI tool umbrella = "hyperpolymath ecosystem-wide tooling" @@ -23,8 +23,8 @@ templates = "templates/" examples = "examples/" docs = "docs/" ruleset-audit-archive = "docs/ruleset-audit-2026-04-10/" -fixture-naming-rule = "examples/README.md" -branch-protection-remediation = "docs/branch-protection-remediation-2026-04-10.md" +fixture-naming-rule = "examples/README.adoc" +branch-protection-remediation = "docs/branch-protection-remediation-2026-04-10.adoc" [invariants] scm-files-location = ".machine_readable/ ONLY — never root" @@ -34,7 +34,7 @@ license = "MPL-2.0" language = "Rust (== Rust/SPARK per terminology rule)" no-secrets-in-files = true spdx-headers-required = true -# Fixture-vs-live naming rule (see ADR-006 and examples/README.md): +# Fixture-vs-live naming rule (see ADR-006 and examples/README.adoc): # live configs use `.launcher.a2ml`, test fixtures use # `.launcher.fixture.a2ml`. The discovery walker treats any file ending # in `.launcher.fixture.a2ml` as out-of-band for estate walks. @@ -48,18 +48,18 @@ live-suffix = ".launcher.a2ml" standard-loader-entry-point = "launch-scaffolder-common::standard::LauncherStandard::resolve" [standards-compliance] -launcher-standard = "standards/rhodium-standard-repositories/spec/LANGUAGE-POLICY.adoc §Rust/SPARK" -a2ml-spec = "standards/a2ml/" +launcher-standard = "standards/launcher-standard_praxis.deed (baked copy; canonical source is standards/launcher/launcher-standard_praxis.deed in hyperpolymath/standards)" +per-app-descriptor = "TOML content with legacy .launcher.a2ml naming; DEED migration tracked at hyperpolymath/standards#960" rsr-template = "https://github.com/hyperpolymath/rsr-template-repo" -abi-ffi-standard = "hyperpolymath Idris2-ABI + Zig-FFI" -terminology = "When this manifest or README says 'Rust', always read as 'Rust/SPARK' (per LANGUAGE-POLICY.adoc §Terminology, 2026-04-10)." +standards-repository = "https://github.com/hyperpolymath/standards" +terminology = "The estate's canonical language policy (standards/0-canon/rsr/LANGUAGE-POLICY.adoc, 2026-09-22) uses Rust as shorthand for Rust/SPARK; this repository is Rust-primary and has no SPARK integration implemented yet." [session-startup] step-1 = "Read this manifest" step-2 = "Read README.adoc for architecture and current status" -step-3 = "Read STATE.a2ml for current implementation progress" +step-3 = "Read .machine_readable/6a2/STATE.a2ml for current implementation progress" step-4 = "Read standards/launcher-standard_praxis.deed for the spec being targeted" -step-5 = "Read examples/stapeln.launcher.a2ml for a worked per-app config" +step-5 = "Read examples/stapeln.launcher.fixture.a2ml for a worked per-app config" [purpose] problem = """ @@ -68,22 +68,24 @@ script that has to be kept in sync with the current launcher standard by hand. Drift is guaranteed. Spec changes mean editing 11+ files. """ solution = """ -Generate launcher scripts from a declarative A2ML spec. Launchers become -reproducible artefacts, not hand-edited drift. Bulk realignment after a -spec change is one command. +Generate portable launcher scripts from per-app descriptors and the +launcher DEED standard. The descriptors currently contain TOML under a +legacy .a2ml suffix; their migration is tracked separately. Launchers are +reproducible artefacts, and estate realignment is a single CLI operation. """ non-goals = [ - "Replacing desktop environment integration tools (xdg-desktop-menu, update-desktop-database) — we call them.", - "Inventing a new desktop file format — we emit the existing freedesktop and .lnk formats.", - "Solving the 'start the actual app' problem — launchers shell out to scripts/run.sh per the LM-LA-LIFECYCLE standard.", + "Replacing desktop environment integration tools — database refresh and trust marking are best-effort calls.", + "Inventing a desktop-entry format — Linux integration writes the existing freedesktop .desktop format.", + "Implementing application-specific startup behavior — the config supplies argv or an ordered startup-command search.", + "Claiming native macOS or Windows installation support before those backends exist.", ] [roadmap-phases] phase-1 = "DONE 2026-04-10 — config + standard + template + mint. 7 launchers migrated." phase-2 = "DONE 2026-04-10 — realign (bulk re-mint, estate walk, --dry-run/--check). Fixture-vs-live suffix convention introduced; ADR-006." phase-3 = "DONE 2026-04-10 — provision (native Rust integ/disinteg via launch-scaffolder-common::integration; ADR-008). Template delegation: generated scripts fast-path to the binary when on PATH." -phase-4 = "DONE 2026-04-10 — config (get/set/validate) on the embedded @a2ml-metadata block. Hand-rolled parser per ADR-009." -phase-5 = "IN PROGRESS — Golden-file regression tests + cross-platform CI matrix + macOS integration backend + `standard` subcommand." -phase-6 = "SPARK hook for integrity.rs via Zig FFI." -phase-7 = "Migrate the 5 declared exceptions (hypatia, invariant-path, opsm, ambientops, idaptik) once template supports their extra modes." -phase-8 = "Optional PanLL panel as a GUI layer over the CLI." +phase-4 = "DONE — config get/set/validate supports legacy @a2ml-metadata blocks; current generated launchers use @launcher-deed metadata and config set refuses to rewrite it." +phase-5 = "IN PROGRESS — standard show/validate CLI, shell/config hardening, atomic writes, and per-target Linux integration ownership are implemented; per-app DEED migration and non-Linux backends remain open." +phase-6 = "Implement platform, integrity, and exception-merge APIs with tests before considering an SPARK/FFI integration hook." +phase-7 = "Migrate the 5 declared exceptions (hypatia, invariant-path, opsm, ambientops, idaptik) after required custom-mode hooks exist." +phase-8 = "Optional GUI layer over the CLI; not a current deliverable." diff --git a/CHANGELOG.adoc b/CHANGELOG.adoc index 33fccc2..bfe02ab 100644 --- a/CHANGELOG.adoc +++ b/CHANGELOG.adoc @@ -1,14 +1,13 @@ +// SPDX-License-Identifier: CC-BY-SA-4.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell == Changelog All notable changes to `+launch-scaffolder+` will be documented in this file. -This file is generated from conventional commits by the -https://github.com/hyperpolymath/standards/blob/main/.github/workflows/changelog-reusable.yml[`+changelog-reusable.yml+`] -workflow (`+hyperpolymath/standards#206+`). Adopt the workflow in this -repo’s CI to keep this file in sync automatically — see -https://github.com/hyperpolymath/standards/blob/main/templates/cliff.toml[`+templates/cliff.toml+`] -for the canonical config. +This changelog is maintained manually. Automation can be adopted once the +repository uses the canonical reusable changelog workflow; no such workflow +is currently enabled here. The format follows https://keepachangelog.com/en/1.1.0/[Keep a Changelog]; this project aims to follow @@ -23,9 +22,16 @@ https://semver.org/spec/v2.0.0.html[Semantic Versioning]. delegation * feat(realign): implement cmd_realign + fixture-suffix convention * feat(mint): wire end-to-end mint pipeline + migrate 7 launchers +* feat(standard): implement `show` and semantic `validate` +* feat(launcher): implement `--version` and route `--browser`/`--web` to `--auto` ==== Fixed +* Prevent config values from being interpolated as shell source; validate app IDs and metadata. +* Keep launcher PID/log state in private per-app XDG directories and refuse unsafe shared state parents. +* Make install writes atomic, keep desktop entries owner-writable, and refuse to overwrite/remove unmarked files. +* Escape freedesktop metadata and Exec paths; validate runtime URLs as HTTP(S). +* Ensure fixture paths, changelog/community-health documents, and maintainer governance links match the repository. * fix(ci): sync hypatia-scan.yml to canonical (413: env.HOME+Phase-2+SARIF) (#5) * fix(ci): adopt canonical hypatia-scan.yml (env.HOME/scanner-layout + @@ -43,9 +49,7 @@ launchers === Pre-history Prior commits to this file’s introduction are recorded in git history -but not formally classified into Keep-a-Changelog sections. To backfill, -run `+git cliff -o CHANGELOG.md+` locally using the canonical -https://github.com/hyperpolymath/standards/blob/main/templates/cliff.toml[`+cliff.toml+`] -— this is one-shot mechanical work. +but not formally classified into Keep-a-Changelog sections. No automated +backfill workflow or git-cliff configuration is currently enabled. ''''' diff --git a/CODE_OF_CONDUCT.adoc b/CODE_OF_CONDUCT.adoc index ff3f3db..815e9e9 100644 --- a/CODE_OF_CONDUCT.adoc +++ b/CODE_OF_CONDUCT.adoc @@ -1,3 +1,5 @@ +// SPDX-License-Identifier: CC-BY-SA-4.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell == Code of Conduct === Our Pledge diff --git a/Cargo.toml b/Cargo.toml index 726e1c2..f3a48ee 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,7 +16,7 @@ license = "MPL-2.0" repository = "https://github.com/hyperpolymath/launch-scaffolder" homepage = "https://github.com/hyperpolymath/launch-scaffolder" readme = "README.adoc" -keywords = ["launcher", "desktop", "cross-platform", "deed", "rsr"] +keywords = ["launcher", "desktop", "deed", "rsr", "cli"] categories = ["command-line-utilities", "development-tools"] [workspace.dependencies] @@ -37,7 +37,7 @@ tera = "1.20" dirs = "5" walkdir = "2.5" -# Hashing for integrity manifests +# Checksums for the standard pin and test fixture manifests; integrity API is planned. sha2 = "0.10" hex = "0.4" diff --git a/EXPLAINME.adoc b/EXPLAINME.adoc index e1064cd..c433683 100644 --- a/EXPLAINME.adoc +++ b/EXPLAINME.adoc @@ -8,30 +8,31 @@ This file maps README.adoc claims to their implementation evidence for sceptical developers. -== "Build cross-platform desktop launchers from a declarative spec" +== "Mint portable launcher scripts and manage Linux desktop integration" [quote, README.adoc] ____ -launch-scaffolder is a single Rust binary that generates, installs, and -maintains cross-platform desktop launcher scripts for any hyperpolymath -project. +launch-scaffolder is a single Rust binary that generates and maintains +launcher scripts for hyperpolymath projects. Native provisioning is Linux-only; +macOS and Windows integration remain planned. ____ How this is implemented:: The Cargo workspace at link:Cargo.toml[`Cargo.toml`] defines two crates: link:crates/launcher-common/[`crates/launcher-common/`] (the library with all real logic) and link:crates/launcher/[`crates/launcher/`] (the thin CLI -binary). The four working subcommands each have a dedicated module: +binary). The five CLI subcommands each have a dedicated module: link:crates/launcher/src/cmd_mint.rs[`cmd_mint.rs`], link:crates/launcher/src/cmd_realign.rs[`cmd_realign.rs`], link:crates/launcher/src/cmd_provision.rs[`cmd_provision.rs`], -link:crates/launcher/src/cmd_config.rs[`cmd_config.rs`]. +link:crates/launcher/src/cmd_config.rs[`cmd_config.rs`], and +link:crates/launcher/src/cmd_standard.rs[`cmd_standard.rs`]. link:crates/launcher/src/main.rs[`main.rs`] dispatches via `clap`. Caveat:: -`cmd_standard.rs` exists but is a scaffold stub — the `standard` subcommand -is not yet wired. Cross-platform support is Linux-only for the `provision` -backend; macOS and Windows return `IntegError::UnsupportedPlatform`. +The `standard` subcommand implements `show` and semantic `validate`. +Cross-platform integration remains Linux-only; macOS and Windows return +`IntegError::UnsupportedPlatform`. == "The standard is a single file — changing it is a one-line edit" @@ -42,9 +43,10 @@ ____ How this is implemented:: link:standards/launcher-standard_praxis.deed[`standards/launcher-standard_praxis.deed`] -is the canonical specification, written in the estate's DEED v1.0.0 format and -parsed by +is this repository's vendored DEED v1.0.0 fallback, parsed by link:crates/launcher-common/src/deed.rs[`crates/launcher-common/src/deed.rs`]. +The canonical source is maintained at +link:https://github.com/hyperpolymath/standards/blob/main/launcher/launcher-standard_praxis.deed[`hyperpolymath/standards/launcher/launcher-standard_praxis.deed`]. It is baked into the binary at compile time via `include_str!()` in link:crates/launcher-common/src/standard.rs[`crates/launcher-common/src/standard.rs`] as a fallback. `LauncherStandard::resolve` walks: `--standard ` CLI flag @@ -94,14 +96,14 @@ link:crates/launcher-common/src/config.rs[`crates/launcher-common/src/config.rs` and rendered by link:crates/launcher-common/src/template.rs[`src/template.rs`] using the Tera template at link:templates/launcher.sh.tera[`templates/launcher.sh.tera`]. The generated scripts embed: -* `CONFIG_FILE=` — the source config path +* A shell-quoted `CONFIG_FILE=` — the source config path * A fast-path that delegates to `launch-scaffolder provision` when the binary is on `PATH` * A shell fallback for when the binary is absent Caveat:: 5 declared exceptions (hypatia, invariant-path, opsm, ambientops, idaptik) remain hand-written. Their migration triggers are documented in -link:docs/launcher-exceptions-2026-04-10.md[`docs/launcher-exceptions-2026-04-10.md`]. +link:docs/launcher-exceptions-2026-04-10.adoc[`docs/launcher-exceptions-2026-04-10.adoc`]. == "Native Rust .desktop writer for provision" @@ -145,6 +147,10 @@ Fedora 43 Atomic (Wayland/KDE). | Bash launcher template with variable substitution | Unique to this repo +| Generated artifact security +| Config interpolation is shell-quoted; state directories and managed integration targets are guarded +| Unique to this generator; covered by Rust regression tests + | Fixture-vs-live suffix convention | `.launcher.fixture.a2ml` excluded from estate walks | Formalised here; referenced in memory notes @@ -156,13 +162,9 @@ Fedora 43 Atomic (Wayland/KDE). == Known Gaps -`cmd_standard.rs` is a scaffold stub — the `standard` subcommand (which would -print or validate the current standard spec) is not yet implemented. - `crates/launcher-common/src/platform.rs`, `integrity.rs`, and `exceptions.rs` -are stubs. The `integrity` module (SHA-256 manifest generation) and -`exceptions` module (per-app override merge) are required for full spec -compliance. +are placeholders and are not called by the CLI. Linux is the only native +provisioning backend; macOS and Windows support and tests remain outstanding. -Golden-file regression tests pinning `mint` output for the 7 managed launchers -are planned but not yet written (see `tests/regression/`). +Mint output is pinned by a committed fixture and the CI artefact gate; golden +coverage for all seven estate consumers is not yet present. diff --git a/GOVERNANCE.adoc b/GOVERNANCE.adoc index 9b836fb..2ce741e 100644 --- a/GOVERNANCE.adoc +++ b/GOVERNANCE.adoc @@ -1,60 +1,40 @@ -== Governance +// SPDX-License-Identifier: CC-BY-SA-4.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell += Governance -=== Overview +== Overview -This project is governed by the following principles and structures to -ensure transparent, inclusive, and effective decision-making. +`launch-scaffolder` is a sole-maintainer project. The governance model is +intended to be transparent about review capacity rather than to imply a +maintainer quorum that does not exist. -=== Roles and Responsibilities +== Roles and responsibilities -==== Maintainers +The maintainer reviews and merges contributions, manages releases, triages +issues, maintains quality and security practices, and enforces the Code of +Conduct. Contributors are expected to follow the project standards, include +tests for code changes, and update relevant documentation. -Maintainers are responsible for: - Reviewing and merging pull requests - -Managing releases and versioning - Ensuring code quality and standards - -Triaging issues and bug reports - Community engagement and support +== Decision making -==== Contributors +* Routine fixes and documentation changes may be decided by the maintainer. +* Significant changes should be discussed in an issue or pull request before + implementation. +* Breaking changes require public discussion and a migration path. +* Where independent review is unavailable, the pull request should say so; + no two-maintainer approval requirement is claimed. -Contributors are expected to: - Follow the code of conduct - Submit -well-documented pull requests - Write tests for new functionality - -Maintain existing tests - Update documentation as needed +== Code of Conduct -=== Decision Making +All participants must follow link:CODE_OF_CONDUCT.adoc[`CODE_OF_CONDUCT.adoc`]. +Conduct concerns may be reported privately to j.d.a.jewell@open.ac.uk. -==== Minor Changes +== Communication and licensing -* Can be made by any maintainer -* Include bug fixes, documentation updates, dependency updates - -==== Major Changes - -* Require discussion in issues or pull requests -* Include new features, architectural changes, API changes -* Need approval from at least 2 maintainers - -==== Breaking Changes - -* Require RFC (Request for Comments) process -* Need approval from majority of maintainers -* Must include migration guide - -=== Code of Conduct - -All participants are expected to follow our Code of Conduct. Violations -can be reported to the maintainers. - -=== Communication - -* *Issues*: For bug reports and feature requests -* *Discussions*: For questions and general discussion -* *Pull Requests*: For code contributions - -=== Licensing - -All contributions are made under the terms of the repository’s LICENSE -file. By submitting a pull request, you agree to license your -contributions accordingly. +Use GitHub issues for bugs and feature requests and pull requests for proposed +changes. Contributions are licensed under the terms in the repository's +`LICENSE` file. ''''' -_Last updated: 2026-07-18_ +_Last reviewed: 2026-09-26_ diff --git a/Justfile b/Justfile index e9e3e4c..adfb82e 100644 --- a/Justfile +++ b/Justfile @@ -6,23 +6,27 @@ default: # Build debug build: - cargo build --workspace + cargo build --locked --workspace # Build release (optimised, stripped, single-file binary) release: - cargo build --workspace --release + cargo build --locked --workspace --release # Run the binary with args run *args: - cargo run -p launch-scaffolder -- {{args}} + cargo run --locked -p launch-scaffolder -- {{args}} # Run all tests test: - cargo test --workspace + cargo test --locked --workspace + +# Check all targets without changing the lockfile +check: + cargo check --locked --workspace --all-targets # Clippy — strict lint: - cargo clippy --workspace --all-targets -- -D warnings + cargo clippy --locked --workspace --all-targets -- -D warnings # Format fmt: @@ -34,7 +38,7 @@ fmt-check: # Install the binary to ~/.cargo/bin install: - cargo install --path crates/launcher + cargo install --locked --path crates/launcher # Uninstall uninstall: @@ -45,38 +49,42 @@ clean: cargo clean # Pre-commit check sequence -pre-commit: fmt-check lint test +pre-commit: fmt-check check lint test + +# Full local validation sequence +validate: fmt-check check lint test validate-standard + @echo "✓ validation passed" -# Full CI sequence -ci: fmt-check lint test +# CI sequence +ci: validate @echo "✓ CI passed" # Print the baked-in launcher standard standard: - cargo run -p launch-scaffolder -- standard show + cargo run --locked -p launch-scaffolder -- standard show # Validate the launcher standard file validate-standard: - cargo run -p launch-scaffolder -- standard validate + cargo run --locked -p launch-scaffolder -- standard validate # Smoke test: mint a launcher from the stapeln example into /tmp smoke-mint: - cargo run -p launch-scaffolder -- mint examples/stapeln.launcher.a2ml -o /tmp/stapeln-launcher.sh + cargo run --locked -p launch-scaffolder -- mint examples/stapeln.launcher.fixture.a2ml -o /tmp/stapeln-launcher.sh @echo "Smoke test: mint produced /tmp/stapeln-launcher.sh" # Mint a launcher in-place. Pass the path to an .launcher.a2ml file. # Example: just mint /var/mnt/eclipse/repos/aerie/aerie.launcher.a2ml mint config: - cargo run --release -p launch-scaffolder -- mint {{config}} + cargo run --locked --release -p launch-scaffolder -- mint {{config}} # Re-mint every scaffolder-managed launcher in the estate. Edit the list # when adding/removing managed repos. Exceptions live in -# docs/launcher-exceptions-2026-04-10.md. +# docs/launcher-exceptions-2026-04-10.adoc. mint-all: #!/usr/bin/env bash set -euo pipefail BIN="./target/release/launch-scaffolder" - [ -x "$BIN" ] || cargo build --release + [ -x "$BIN" ] || cargo build --locked --release for cfg in \ /var/mnt/eclipse/repos/aerie/aerie.launcher.a2ml \ /var/mnt/eclipse/repos/developer-ecosystem/burble/burble.launcher.a2ml \ @@ -91,7 +99,8 @@ mint-all: # Generate cargo docs doc: - cargo doc --workspace --no-deps --open + cargo doc --locked --workspace --no-deps --open secret-scan-trufflehog: - @command -v trufflehog >/dev/null && trufflehog filesystem . --only-verified || true + @command -v trufflehog >/dev/null || { echo "trufflehog is required for this scan" >&2; exit 127; } + trufflehog filesystem . --only-verified diff --git a/MAINTAINERS b/MAINTAINERS index 37f6411..2ffc060 100644 --- a/MAINTAINERS +++ b/MAINTAINERS @@ -1,43 +1,9 @@ # Maintainers -This file lists the current maintainers of this project. +This repository is maintained by Jonathan D.A. Jewell (@hyperpolymath). -## Active Maintainers +See [MAINTAINERS.adoc](MAINTAINERS.adoc) for responsibilities and the +contribution process. This is a sole-maintainer project; there is no +independent maintainer quorum requirement. -| Name | GitHub | Role | Since | -|------|--------|------|-------| -| Metadatastician | @metadatastician | Primary | Project Start | - -## Emeritus Maintainers - -None at this time. - -## Becoming a Maintainer - -To become a maintainer: - -1. Demonstrate consistent, high-quality contributions -2. Show understanding of the project's goals and architecture -3. Be active in code reviews and community discussions -4. Be nominated by an existing maintainer -5. Be approved by consensus of existing maintainers - -## Maintainer Responsibilities - -- Reviewing and merging pull requests -- Managing releases -- Triaging issues -- Enforcing code standards -- Mentoring new contributors -- Participating in decision-making - -## Maintainer Expectations - -- Respond to issues and PRs in a timely manner -- Follow the code of conduct -- Be transparent in decision-making -- Communicate clearly and respectfully - ---- - -*Last updated: 2026-07-18* +Last updated: 2026-09-26 diff --git a/MAINTAINERS.adoc b/MAINTAINERS.adoc index 97edfcd..0ac2776 100644 --- a/MAINTAINERS.adoc +++ b/MAINTAINERS.adoc @@ -1,6 +1,7 @@ // SPDX-License-Identifier: CC-BY-SA-4.0 // SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell = Maintainers +:revdate: 2026-09-26 :toc: preamble == Current Maintainers @@ -61,5 +62,5 @@ For questions about project governance: == See Also * link:GOVERNANCE.adoc[Governance Model] -* link:CODE_OF_CONDUCT.md[Code of Conduct] +* link:CODE_OF_CONDUCT.adoc[Code of Conduct] * link:.github/CONTRIBUTING.md[Contributing Guide] diff --git a/README.adoc b/README.adoc index 1976a21..b3395fe 100644 --- a/README.adoc +++ b/README.adoc @@ -1,4 +1,6 @@ -*Build cross-platform desktop launchers from a declarative spec.* +// SPDX-License-Identifier: CC-BY-SA-4.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +*Mint portable launcher scripts and manage Linux desktop integration from a declarative spec.* https://www.bestpractices.dev/en/projects/new?repo_url=https://github.com/hyperpolymath/launch-scaffolder[image:https://img.shields.io/badge/OpenSSF-Best_Practices-green?logo=opensourcesecurity[OpenSSF Best Practices]] @@ -18,9 +20,10 @@ working] == What it does `+launch-scaffolder+` is a single Rust binary that generates, installs, -and maintains cross-platform desktop launcher scripts for any -hyperpolymath project (or any other project that adopts the -hyperpolymath launcher standard). It turns launcher scripts into +and maintains launcher scripts for any hyperpolymath project (or any +other project that adopts the hyperpolymath launcher standard). Native +provisioning currently targets Linux; macOS and Windows integration remain +planned. It turns launcher scripts into *generated artefacts*, not hand-edited files. Inputs: @@ -70,7 +73,7 @@ file, not 11 near-identical launcher scripts. == Commands -All four working subcommands, in the order you’re likely to use them: +CLI subcommands, in the order you’re likely to use them: [source,bash] ---- @@ -103,18 +106,30 @@ launch-scaffolder provision --integ --all # everything in the estate launch-scaffolder provision --integ --all --no-confirm --force launch-scaffolder provision --integ --all --dry-run # preview only +# ─── standard ────────────────────────────────────────────────────────────── +launch-scaffolder standard show +launch-scaffolder standard validate + # ─── config ──────────────────────────────────────────────────────────────── -# Inspect or edit the `@a2ml-metadata` block embedded at the top of any -# generated launcher script. +# Read/validate either metadata dialect. `set` can edit legacy A2ML blocks; +# current DEED blocks are read-only and should be changed at the source config. launch-scaffolder config get ./stapeln-launcher.sh version launch-scaffolder config get ./stapeln-launcher.sh standards-compliance launch-scaffolder config validate ./stapeln-launcher.sh -launch-scaffolder config set ./stapeln-launcher.sh version 0.2.0 -# NOTE: `config set` rewrites the generated script in place and warns -# that `realign` will overwrite the change. The durable fix is to edit -# the source .launcher.a2ml and re-mint. +# Legacy launcher only: current @launcher-deed blocks are intentionally read-only. +launch-scaffolder config set ./legacy-launcher.sh version 0.2.0 +# For current launchers, edit the source config and re-mint. ---- +=== Managed integration ownership + +Both the native Rust provisioner and generated-shell fallback refuse to replace +or remove an existing unmarked launcher or desktop entry, even with `--force`. +Each desktop/launcher file is checked independently; installed icons carry a +sidecar ownership marker. Move or inspect a conflicting file rather than +expecting the tool to claim it automatically. Writes use same-filesystem atomic +replacement. + === Generated script → binary delegation Generated `+-launcher.sh+` scripts carry their own `+--integ+` / @@ -147,11 +162,10 @@ launch-scaffolder/ │ │ ├── integration.rs # Native Rust .desktop writer, │ │ │ # icon/launcher install, gio, │ │ │ # update-desktop-database -│ │ ├── metadata_block.rs # Parser + in-place rewriter for the -│ │ │ # embedded @a2ml-metadata block -│ │ ├── platform.rs # Linux/macOS/Windows dispatch (stub) -│ │ ├── integrity.rs # SHA-256 manifests (stub) -│ │ └── exceptions.rs # Standard + config + exception merge (stub) +│ │ ├── metadata_block.rs # Reads DEED and legacy metadata; edits legacy blocks +│ │ ├── platform.rs # Planned platform helpers (not wired yet) +│ │ ├── integrity.rs # Planned integrity manifests (not wired yet) +│ │ └── exceptions.rs # Planned exception merge (not wired yet) │ └── launcher/ # Thin CLI binary │ └── src/ │ ├── main.rs # clap dispatch @@ -159,21 +173,20 @@ launch-scaffolder/ │ ├── cmd_realign.rs # ✓ working │ ├── cmd_provision.rs # ✓ working (native Rust; option b) │ ├── cmd_config.rs # ✓ working -│ └── cmd_standard.rs # stub +│ └── cmd_standard.rs # standard show / validate ├── standards/ -│ └── launcher-standard_praxis.deed # Canonical standard (baked in) +│ └── launcher-standard_praxis.deed # Vendored/baked standard fallback ├── templates/ │ └── launcher.sh.tera # Bash launcher template rendered by Tera ├── examples/ -│ ├── README.md # Fixture-vs-live naming convention +│ ├── README.adoc # Fixture-vs-live naming convention │ └── stapeln.launcher.fixture.a2ml # Worked example (fixture suffix!) ├── docs/ -│ ├── launcher-exceptions-2026-04-10.md -│ ├── compliance-audit-2026-04-10.md -│ ├── branch-protection-remediation-2026-04-10.md +│ ├── launcher-exceptions-2026-04-10.adoc +│ ├── compliance-audit-2026-04-10.adoc +│ ├── branch-protection-remediation-2026-04-10.adoc │ └── ruleset-audit-2026-04-10/ # Audit artefacts (read-only record) -└── tests/ - └── regression/ # Golden-file regression fixtures (planned) +└── crates/launcher-common/tests/ # Parser, renderer, fixture and integration tests .... === Fixture-vs-live naming convention @@ -194,21 +207,21 @@ discovery + is_live_config` enforces this rule. Fixture files can live anywhere — including inside a consumer repo’s `+examples/+` directory — without being swept up by `+realign+` or `+provision+` `+--all+`. See -`+examples/README.md+` for the full contributor-facing version of the +`+examples/README.adoc+` for the full contributor-facing version of the rule. === Why Rust/SPARK -Per the hyperpolymath language policy (see -`+standards/rhodium-standard-repositories/spec/LANGUAGE-POLICY.adoc+`): +Per the hyperpolymath language policy +(link:https://github.com/hyperpolymath/standards/blob/main/0-canon/rsr/LANGUAGE-POLICY.adoc[canonical policy]): * *Rust* is the preferred language for CLI tools — zero-dep binary, fast cold start, strong types, excellent ecosystem (clap, tera, sha2, anyhow). * *"`Rust`" always means "`Rust with SPARK integration as the default -stance`"* — this tool is Rust-primary now, with SPARK/Ada hooks planned -for the correctness-critical `+integrity.rs+` path (called via Zig FFI -per the hyperpolymath ABI/FFI standard). +stance`"*. This project is Rust-primary and designed in that direction, +but it has no SPARK/FFI implementation today; the `+integrity+` module is +still a placeholder. === Where a launcher keeps its state @@ -223,26 +236,30 @@ world-writable space: |pid |`+$XDG_RUNTIME_DIR+`, else `+$XDG_STATE_HOME+`, else -`+~/.local/state+` — as `+-server.pid+` +`+~/.local/state+` — under `+launch-scaffolder//server.pid+` |`+[runtime]+` `+pid-file+` |log -|`+$XDG_STATE_HOME+`, else `+~/.local/state+` — as -`+-server.log+` +|`+$XDG_STATE_HOME+`, else `+~/.local/state+` — under +`+launch-scaffolder//server.log+` |`+[runtime]+` `+log-file+` |=== The log goes to the *state* directory rather than the runtime directory because it has to survive a logout, which `+$XDG_RUNTIME_DIR+` does not -promise. Both directories are created `+0700+` by the launcher before the -first write. +promise. The unique per-app default directories are created with mode +`+0700+`. Explicit override paths are never chmodded; the generated launcher +refuses a state directory that is not user-owned or is group/world-writable, +so a legacy `+/tmp+` override cannot change `/tmp` permissions or expose a PID +file to other users. Before 2026-09-25 both defaults were `+/tmp/-server.{pid,log}+`: world-writable, and predictable from nothing but the app name, so any local user could create or symlink the path before the launcher's first run and influence what it later killed or removed (issue #48). Launchers minted before that date keep their old paths until they are re-minted — set the -two keys explicitly, or re-mint, to move them. +two keys explicitly, or re-mint, to move them. The worked example now leaves +those overrides unset so it demonstrates the secure defaults. === Why a declarative format for inputs @@ -264,9 +281,9 @@ longer applies. == Status -*Alpha, ~65% complete. Four of five subcommands fully wired end-to-end; -one remains a stub (`+standard+`). Last updated 2026-04-10 (phase -`+phase-4-config-inspector+`).* +*Alpha. The five CLI subcommands (`+mint+`, `+realign+`, `+provision+`, `+config+`, +`+standard+`) are implemented; platform/integrity/exception-library modules and +non-Linux integration remain planned. Last reviewed 2026-09-26.* Implemented: @@ -287,8 +304,8 @@ runtime-kind validation icon/launcher install, best-effort `+gio+` + `+update-desktop-database+` (Linux only; macOS/Windows return `IntegError + UnsupportedPlatform`) -* [x] `+metadata_block+` module — hand-rolled parser and in-place -rewriter for the embedded `+@a2ml-metadata+` block +* [x] `+metadata_block+` module — reads current `+@launcher-deed+` and +legacy `+@a2ml-metadata+` blocks; safe scalar editing remains legacy-only * [x] `+templates/launcher.sh.tera+` — parameterised over `+runtime_kind+` `+∈+` `+{server-url,+` `+process,+` `+remote}+`; `+--integ+` / `+--disinteg+` fast-path to `+launch-scaffolder+` @@ -302,31 +319,28 @@ tolerant `+--all+`, `+--force+`, `+--no-confirm+`, `+--dry-run+`; bulk mode prompts before touching `+$HOME+` * [x] *`+config+`* subcommand — `+get+` / `+set+` / `+validate+`; -`+set+` preserves column alignment in the embedded metadata block -* [x] 17 unit tests passing across both crates +`+set+` safely edits legacy blocks and refuses current DEED blocks +* [x] *`+standard+`* subcommand — `+show+` / semantic `+validate+` +* [x] Generated launcher implements standard `+--version+` output and routes +`+--browser+` / `+--web+` to `+--auto+` +* [x] Unit and integration tests run by locked Rust CI; launcher-artifact CI enforces a 59-test floor, a current golden mint, and ShellCheck * [x] 7 launchers fully managed: aerie, burble, game-server-admin, nqc, panll, project-wharf, stapeln * [x] 5 declared exceptions documented in -`+docs/launcher-exceptions-2026-04-10.md+` +`+docs/launcher-exceptions-2026-04-10.adoc+` * [x] Fixture-vs-live file-naming convention documented and enforced * [x] All 7 managed launchers regenerated with template delegation arms (2026-04-10) -Remaining work (ordered by current priority in STATE.a2ml): +Remaining work (see `.machine_readable/6a2/STATE.a2ml` for the current checkpoint): -* [ ] Golden-file regression tests pinning mint output for the 7 managed -launchers -+ -* [ ] macOS integration backend in `launch-scaffolder-common + -integration` -* [ ] SPARK integration hook for `+integrity.rs+` via Zig FFI -* [ ] `+standard+` subcommand — still a scaffold stub -* [ ] `+platform+` module — still a stub (runtime dispatch handled -inside the generated script today) -* [ ] `+integrity+` module — still a stub (pending SHA-256 manifest -generator) -* [ ] `+exceptions+` module — still a stub (per-app override merge) -* [ ] Cross-platform CI (Linux/macOS/Windows matrix) +* [x] Mint output is currency-locked against the committed DEED fixture in +unit tests and CI; seven-consumer golden coverage remains future work +* [ ] macOS and Windows integration backends in `launch-scaffolder-common::integration` +* [ ] SPARK integration hook for a future implemented `+integrity+` API via Zig FFI +* [x] `+standard+` subcommand — `show` and semantic `validate` +* [ ] `+platform+`, `+integrity+` and `+exceptions+` library APIs — currently placeholders and explicitly not used by the CLI +* [ ] Cross-platform CI (Linux/macOS/Windows matrix); current CI runs on Linux * [ ] Migration of the 5 declared exceptions once the template grows custom-mode hooks @@ -378,7 +392,7 @@ at the new per-repo paths. * *project-wharf* — container/workload staging (process) *Declared exceptions* (still hand-written; see -`+docs/launcher-exceptions-2026-04-10.md+` for reasoning and migration +`+docs/launcher-exceptions-2026-04-10.adoc+` for reasoning and migration triggers): * `+hypatia+`, `+invariant-path+`, `+opsm+`, `+ambientops+`, `+idaptik+` diff --git a/SECURITY.adoc b/SECURITY.adoc index 3081ece..f8b4a20 100644 --- a/SECURITY.adoc +++ b/SECURITY.adoc @@ -1,4 +1,9 @@ -== Security Policy +// SPDX-License-Identifier: CC-BY-SA-4.0 += Security Policy + +GitHub's recognized community-health version is maintained at +link:.github/SECURITY.md[`.github/SECURITY.md`]. This document is the +AsciiDoc companion. === Reporting a Vulnerability diff --git a/crates/launcher-common/Cargo.toml b/crates/launcher-common/Cargo.toml index 2c6d593..025a766 100644 --- a/crates/launcher-common/Cargo.toml +++ b/crates/launcher-common/Cargo.toml @@ -10,7 +10,7 @@ license.workspace = true repository.workspace = true homepage.workspace = true readme.workspace = true -description = "Shared types, standard parser, template engine, and platform detection for the launch-scaffolder tool." +description = "Shared config, DEED standard, renderer, discovery, and Linux integration logic for launch-scaffolder." [dependencies] toml.workspace = true @@ -19,8 +19,10 @@ serde_json.workspace = true tera.workspace = true dirs.workspace = true walkdir.workspace = true -sha2.workspace = true -hex.workspace = true anyhow.workspace = true thiserror.workspace = true tracing.workspace = true + +[dev-dependencies] +sha2.workspace = true +hex.workspace = true diff --git a/crates/launcher-common/src/config.rs b/crates/launcher-common/src/config.rs index b4abe95..d10b088 100644 --- a/crates/launcher-common/src/config.rs +++ b/crates/launcher-common/src/config.rs @@ -92,17 +92,18 @@ pub struct Runtime { /// /// Default (when unset): `+$XDG_RUNTIME_DIR+`, falling back to /// `+$XDG_STATE_HOME+` and then to `+~/.local/state+`, as - /// `+-server.pid+`. Before 2026-09-25 the default was + /// `+launch-scaffolder//server.pid+`. Before 2026-09-25 the default was /// `+/tmp/-server.pid+` — world-writable and predicted entirely by /// the app name, so any local user could create or symlink the path /// before the launcher's first run and steer what it later killed or /// removed (#48). The default is emitted into the script as a SHELL - /// expression, not resolved here, because the launcher runs on the - /// user's machine rather than the one it was minted on; the script - /// creates the directory `+0700+` before it writes. + /// expression under a unique per-app directory. Explicit paths are shell- + /// quoted and their parent directory must be user-owned and not group/world + /// writable; the launcher never changes permissions on an explicit parent. /// - /// Set it to override, e.g. `+pid-file = "/var/run/myapp.pid"+`. A - /// leading `+~+` is expanded (see `+integration::expand_home+`). + /// Set it to override, e.g. `+pid-file = "~/run/myapp.pid"+`. A leading + /// `+~/+` is expanded at launcher runtime. Shared writable locations such + /// as `/tmp` are refused by the generated script. #[serde(default)] pub pid_file: Option, /// Where the generated launcher writes its log. @@ -123,6 +124,64 @@ fn default_wait_seconds() -> u32 { 15 } +/// Names become filenames, desktop IDs, and shell-visible identifiers. Keep +/// them a single safe path component and prevent control characters or option +/// injection into generated launcher paths. +fn validate_project_name(name: &str) -> Result<()> { + let mut bytes = name.bytes(); + let Some(first) = bytes.next() else { + anyhow::bail!("project.name must not be empty"); + }; + if name.len() > 80 { + anyhow::bail!("project.name must be at most 80 ASCII bytes"); + } + if !first.is_ascii_alphanumeric() + || !bytes.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-')) + { + anyhow::bail!( + "project.name must start with an ASCII letter or digit and contain only ASCII letters, digits, '.', '_' or '-'; got {name:?}" + ); + } + Ok(()) +} + +fn validate_display_value(field: &str, value: &str) -> Result<()> { + if value.trim().is_empty() { + anyhow::bail!("{field} must not be empty"); + } + validate_no_controls(field, value) +} + +fn validate_no_controls(field: &str, value: &str) -> Result<()> { + if value.chars().any(char::is_control) { + anyhow::bail!("{field} must not contain control characters"); + } + Ok(()) +} + +fn validate_url(url: &str) -> Result<()> { + validate_no_controls("runtime.url", url)?; + let authority = url + .strip_prefix("http://") + .or_else(|| url.strip_prefix("https://")) + .map(|rest| rest.split(['/', '?', '#']).next().unwrap_or_default()); + if url.chars().any(char::is_whitespace) + || authority.is_none_or(|host| { + host.is_empty() || host.starts_with(':') || host.contains('@') + }) + { + anyhow::bail!("runtime.url must be an absolute HTTP(S) URL with a host, no credentials, and no whitespace"); + } + Ok(()) +} + +fn validate_state_path(field: &str, path: &str) -> Result<()> { + if path.trim().is_empty() { + anyhow::bail!("{field} must not be empty"); + } + validate_no_controls(field, path) +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Icon { pub source: String, @@ -154,6 +213,62 @@ impl LauncherConfig { /// Shape-check the config. Runs after parsing so errors reference the /// *meaning* of the bad field, not the raw serde position. pub fn validate(&self) -> Result<()> { + validate_project_name(&self.project.name)?; + validate_display_value("project.display", &self.project.display)?; + if let Some(value) = &self.project.description { + validate_display_value("project.description", value)?; + } + if let Some(value) = &self.project.generic_name { + validate_display_value("project.generic-name", value)?; + } + if let Some(version) = &self.project.version { + if version.trim().is_empty() || version.chars().any(char::is_whitespace) { + anyhow::bail!("project.version must be non-empty and contain no whitespace"); + } + validate_no_controls("project.version", version)?; + } + if let Some(license) = &self.project.license { + validate_display_value("project.license", license)?; + } + for category in &self.project.categories { + if category.is_empty() + || !category + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'-') + { + anyhow::bail!( + "project.categories must contain ASCII alphanumeric/hyphen tokens; got {category:?}" + ); + } + } + if self.repo.path.trim().is_empty() { + anyhow::bail!("repo.path must not be empty"); + } + validate_no_controls("repo.path", &self.repo.path)?; + if let Some(url) = &self.runtime.url { + validate_url(url)?; + } + if let Some(icon) = &self.icon { + validate_state_path("icon.source", &icon.source)?; + } + if let Some(path) = &self.runtime.pid_file { + validate_state_path("runtime.pid-file", path)?; + } + if let Some(path) = &self.runtime.log_file { + validate_state_path("runtime.log-file", path)?; + } + if self.runtime.command.first().is_some_and(String::is_empty) { + anyhow::bail!("runtime.command[0] must name an executable"); + } + for (index, item) in self.runtime.command.iter().enumerate() { + validate_no_controls(&format!("runtime.command[{index}]"), item)?; + } + for (index, item) in self.runtime.startup_command_search.iter().enumerate() { + if item.is_empty() { + anyhow::bail!("runtime.startup-command-search[{index}] must not be empty"); + } + validate_no_controls(&format!("runtime.startup-command-search[{index}]"), item)?; + } match self.runtime.kind { RuntimeKind::ServerUrl => { if self.runtime.url.is_none() && self.runtime.port.is_none() { @@ -166,7 +281,7 @@ impl LauncherConfig { if self.runtime.command.is_empty() && self.runtime.startup_command_search.is_empty() { anyhow::bail!( - "runtime.kind = process requires runtime.command or runtime.startup-command-search" + "process runtime requires runtime.command or runtime.startup-command-search" ); } } @@ -209,6 +324,71 @@ mod tests { assert!(LauncherConfig::parse(txt).is_err()); } + #[test] + fn rejects_path_traversal_project_names() { + let txt = r#" + [project] + name = "../victim" + display = "X" + [repo] + path = "/tmp/x" + [runtime] + kind = "process" + command = ["x"] + "#; + let err = LauncherConfig::parse(txt).unwrap_err().to_string(); + assert!(err.contains("project.name")); + } + + #[test] + fn rejects_control_characters_in_desktop_metadata() { + let txt = r#" + [project] + name = "x" + display = "X\nExec=sh" + [repo] + path = "/tmp/x" + [runtime] + kind = "process" + command = ["x"] + "#; + assert!(LauncherConfig::parse(txt).is_err()); + } + + #[test] + fn rejects_http_urls_without_a_host_or_with_credentials() { + for url in ["https:///path", "http://?query=1", "https://user@example.test/"] { + let txt = format!( + r#" + [project] + name = "x" + display = "X" + [repo] + path = "/tmp/x" + [runtime] + kind = "remote" + url = {url:?} + "# + ); + assert!(LauncherConfig::parse(&txt).is_err(), "accepted {url}"); + } + } + + #[test] + fn rejects_non_http_runtime_urls() { + let txt = r#" + [project] + name = "x" + display = "X" + [repo] + path = "/tmp/x" + [runtime] + kind = "remote" + url = "javascript:alert(1)" + "#; + assert!(LauncherConfig::parse(txt).is_err()); + } + #[test] fn process_kind_requires_command() { let txt = r#" diff --git a/crates/launcher-common/src/discovery.rs b/crates/launcher-common/src/discovery.rs index b8dc4e7..2f279e3 100644 --- a/crates/launcher-common/src/discovery.rs +++ b/crates/launcher-common/src/discovery.rs @@ -23,7 +23,7 @@ pub const ESTATE_ROOT: &str = "/var/mnt/eclipse/repos"; pub const LIVE_EXT: &str = ".launcher.a2ml"; /// Suffix that marks a test fixture / worked example. Must **not** be -/// picked up by estate walks. See `examples/README.md`. +/// picked up by estate walks. See `examples/README.adoc`. pub const FIXTURE_EXT: &str = ".launcher.fixture.a2ml"; /// Walk `root` and return every live launcher config, sorted. diff --git a/crates/launcher-common/src/fs_utils.rs b/crates/launcher-common/src/fs_utils.rs new file mode 100644 index 0000000..04ea09d --- /dev/null +++ b/crates/launcher-common/src/fs_utils.rs @@ -0,0 +1,160 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell +//! Small filesystem helpers shared by the CLI and provisioning backends. + +use anyhow::{Context, Result}; +use std::fs::{self, OpenOptions}; +use std::io::Write; +use std::path::Path; +use std::sync::atomic::{AtomicU64, Ordering}; + +static TEMP_SEQUENCE: AtomicU64 = AtomicU64::new(0); + +/// Replace a file atomically without following a pre-existing symlink at the +/// destination. `mode` is applied on Unix before the temporary file is +/// published. The private temporary directory is created beside the target so +/// the final rename stays on one filesystem. +pub fn write_atomic(path: &Path, contents: &[u8], mode: u32) -> Result<()> { + write_atomic_impl(path, contents, Some(mode)) +} + +/// Atomically replace a file without changing its permissions. For a new file, +/// the operating system's normal creation mode and process umask apply. +pub fn write_atomic_unmodified(path: &Path, contents: &[u8]) -> Result<()> { + write_atomic_impl(path, contents, None) +} + +fn write_atomic_impl(path: &Path, contents: &[u8], mode: Option) -> Result<()> { + #[cfg(not(unix))] + let _ = mode; + let parent = path + .parent() + .filter(|p| !p.as_os_str().is_empty()) + .unwrap_or(Path::new(".")); + let name = path + .file_name() + .context("atomic-write destination must have a file name")?; + + for _ in 0..100 { + let sequence = TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed); + let mut temp_name = name.to_os_string(); + temp_name.push(format!(".{}.{}.tmpdir", std::process::id(), sequence)); + let temp_dir = parent.join(temp_name); + + let mut builder = fs::DirBuilder::new(); + #[cfg(unix)] + { + use std::os::unix::fs::DirBuilderExt; + // The directory stays private even when the process umask is 000. + builder.mode(0o700); + } + match builder.create(&temp_dir) { + Ok(()) => { + let temp = temp_dir.join("contents"); + let result = (|| -> Result<()> { + let mut options = OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(if mode.is_some() { 0o600 } else { 0o666 }); + } + let mut file = options.open(&temp).with_context(|| { + format!("creating temporary file in {}", temp_dir.display()) + })?; + file.write_all(contents) + .with_context(|| format!("writing temporary file {}", temp.display()))?; + file.sync_all() + .with_context(|| format!("syncing temporary file {}", temp.display()))?; + #[cfg(unix)] + if let Some(mode) = mode { + use std::os::unix::fs::PermissionsExt; + file.set_permissions(fs::Permissions::from_mode(mode)) + .with_context(|| format!("setting permissions on {}", temp.display()))?; + } + drop(file); + fs::rename(&temp, path).with_context(|| { + format!("replacing {} with {}", path.display(), temp.display()) + })?; + Ok(()) + })(); + let cleanup = fs::remove_dir_all(&temp_dir).with_context(|| { + format!("removing temporary directory {}", temp_dir.display()) + }); + result?; + cleanup?; + return Ok(()); + } + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(e) => { + return Err(e).with_context(|| { + format!("creating temporary directory in {}", parent.display()) + }); + } + } + } + + anyhow::bail!("could not allocate a unique temporary directory beside {}", path.display()) +} + +/// Read the current mode when possible, otherwise use `fallback`. +pub fn existing_mode_or(path: &Path, fallback: u32) -> u32 { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::metadata(path) + .map(|metadata| metadata.permissions().mode() & 0o777) + .unwrap_or(fallback) + } + #[cfg(not(unix))] + { + let _ = path; + fallback + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::{SystemTime, UNIX_EPOCH}; + + #[test] + fn atomic_write_replaces_contents_and_applies_mode() { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let dir = std::env::temp_dir().join(format!("launch-scaffolder-fs-{unique}")); + fs::create_dir(&dir).unwrap(); + let target = dir.join("output.txt"); + fs::write(&target, b"old").unwrap(); + + write_atomic(&target, b"new", 0o640).unwrap(); + assert_eq!(fs::read(&target).unwrap(), b"new"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!(fs::metadata(&target).unwrap().permissions().mode() & 0o777, 0o640); + } + assert_eq!(fs::read_dir(&dir).unwrap().count(), 1, "temporary file was left behind"); + fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn atomic_write_unmodified_respects_normal_non_executable_creation_mode() { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let dir = std::env::temp_dir().join(format!("launch-scaffolder-fs-umask-{unique}")); + fs::create_dir(&dir).unwrap(); + let target = dir.join("output.txt"); + write_atomic_unmodified(&target, b"new").unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!(fs::metadata(&target).unwrap().permissions().mode() & 0o111, 0); + } + fs::remove_dir_all(dir).unwrap(); + } +} diff --git a/crates/launcher-common/src/integration.rs b/crates/launcher-common/src/integration.rs index 9802782..4b2218b 100644 --- a/crates/launcher-common/src/integration.rs +++ b/crates/launcher-common/src/integration.rs @@ -23,6 +23,7 @@ use crate::Result; use crate::config::{LauncherConfig, RuntimeKind}; +use crate::fs_utils::write_atomic; use anyhow::Context; use std::fs; use std::path::{Path, PathBuf}; @@ -48,6 +49,7 @@ pub struct InstallPaths { pub desktop_file_target: PathBuf, pub desktop_shortcut_target: PathBuf, pub icon_target: PathBuf, + pub icon_marker_target: PathBuf, pub launcher_target: PathBuf, } @@ -63,6 +65,7 @@ impl InstallPaths { desktop_file_target: apps_dir.join(format!("{app_name}.desktop")), desktop_shortcut_target: desktop_shortcut_dir.join(format!("{app_name}.desktop")), icon_target: icon_dir.join(format!("{app_name}.png")), + icon_marker_target: icon_dir.join(format!("{app_name}.png.launch-scaffolder-managed")), launcher_target: bin_dir.join(format!("{app_name}-launcher")), apps_dir, icon_dir, @@ -72,11 +75,12 @@ impl InstallPaths { } /// All removal targets, in the order disinteg should visit them. - pub fn removal_targets(&self) -> [&Path; 4] { + pub fn removal_targets(&self) -> [&Path; 5] { [ &self.desktop_file_target, &self.desktop_shortcut_target, &self.icon_target, + &self.icon_marker_target, &self.launcher_target, ] } @@ -113,6 +117,7 @@ pub struct IntegReport { /// target — the pattern established by the reference stapeln launcher /// and preserved by the template. pub fn integ(config: &LauncherConfig, script_path: &Path, opts: &IntegOpts) -> Result { + config.validate()?; let platform = detect_platform(); if platform != "linux" { return Err(IntegError::UnsupportedPlatform(platform).into()); @@ -122,14 +127,21 @@ pub fn integ(config: &LauncherConfig, script_path: &Path, opts: &IntegOpts) -> R } let paths = InstallPaths::linux(&config.project.name)?; - let already = paths.desktop_file_target.exists() || paths.launcher_target.exists(); + let already = paths.removal_targets().iter().any(|p| path_exists(p)); + let owned = is_managed_install(&paths); + if already && !owned { + anyhow::bail!( + "refusing to overwrite unmarked integration files for `{}`; inspect or move them first", + config.project.name + ); + } let mut report = IntegReport { - already_present: already, + already_present: owned, ..Default::default() }; - if already && !opts.force { + if owned && !opts.force { report.skipped.push(format!( "already integrated: {}", paths.desktop_file_target.display() @@ -181,20 +193,10 @@ pub fn integ(config: &LauncherConfig, script_path: &Path, opts: &IntegOpts) -> R } // -- Copy script to launcher target ------------------------------------ - fs::copy(script_path, &paths.launcher_target).with_context(|| { - format!( - "copying {} to {}", - script_path.display(), - paths.launcher_target.display() - ) - })?; - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let mut perms = fs::metadata(&paths.launcher_target)?.permissions(); - perms.set_mode(0o755); - fs::set_permissions(&paths.launcher_target, perms)?; - } + let script_contents = fs::read(script_path) + .with_context(|| format!("reading launcher script {}", script_path.display()))?; + write_atomic(&paths.launcher_target, &script_contents, 0o755) + .with_context(|| format!("installing launcher at {}", paths.launcher_target.display()))?; report .actions .push(format!("+ launcher: {}", paths.launcher_target.display())); @@ -202,12 +204,17 @@ pub fn integ(config: &LauncherConfig, script_path: &Path, opts: &IntegOpts) -> R // -- Copy icon if present ---------------------------------------------- let icon_name = if let Some(icon_source) = icon_source_abs(config) { if icon_source.exists() { - fs::copy(&icon_source, &paths.icon_target).with_context(|| { - format!( - "copying icon {} to {}", - icon_source.display(), - paths.icon_target.display() - ) + let icon_contents = fs::read(&icon_source) + .with_context(|| format!("reading icon {}", icon_source.display()))?; + write_atomic(&paths.icon_target, &icon_contents, 0o644) + .with_context(|| format!("installing icon at {}", paths.icon_target.display()))?; + write_atomic( + &paths.icon_marker_target, + b"launch-scaffolder managed icon\n", + 0o644, + ) + .with_context(|| { + format!("marking managed icon at {}", paths.icon_marker_target.display()) })?; report .actions @@ -226,15 +233,8 @@ pub fn integ(config: &LauncherConfig, script_path: &Path, opts: &IntegOpts) -> R // -- Write .desktop files ---------------------------------------------- let desktop_body = render_desktop_file(config, &paths, &icon_name); for target in [&paths.desktop_file_target, &paths.desktop_shortcut_target] { - fs::write(target, &desktop_body) + write_atomic(target, desktop_body.as_bytes(), 0o644) .with_context(|| format!("writing {}", target.display()))?; - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let mut perms = fs::metadata(target)?.permissions(); - perms.set_mode(0o444); - fs::set_permissions(target, perms)?; - } report .actions .push(format!("+ desktop: {}", target.display())); @@ -273,12 +273,20 @@ pub fn integ(config: &LauncherConfig, script_path: &Path, opts: &IntegOpts) -> R /// Remove an integration. Idempotent: reports what was actually removed /// and exits cleanly if nothing was present. pub fn disinteg(config: &LauncherConfig, opts: &DisintegOpts) -> Result { + config.validate()?; let platform = detect_platform(); if platform != "linux" { return Err(IntegError::UnsupportedPlatform(platform).into()); } let paths = InstallPaths::linux(&config.project.name)?; let mut report = IntegReport::default(); + if paths.removal_targets().iter().any(|p| path_exists(p)) && !is_managed_install(&paths) { + report.skipped.push(format!( + "refusing to remove unmarked integration files for `{}`", + config.project.name + )); + return Ok(report); + } for target in paths.removal_targets() { if target.exists() || target.is_symlink() { @@ -337,15 +345,17 @@ fn render_desktop_file(config: &LauncherConfig, paths: &InstallPaths, icon_name: RuntimeKind::Process => "--start", RuntimeKind::ServerUrl | RuntimeKind::Remote => "--auto", }; - let launcher = paths.launcher_target.display(); + let launcher = paths.launcher_target.display().to_string(); + let launcher_exec = desktop_exec_arg(&launcher); format!( "[Desktop Entry]\n\ + # X-Launch-Scaffolder=launch-scaffolder\n\ Type=Application\n\ Version=1.0\n\ Name={name}\n\ GenericName={generic}\n\ Comment={comment}\n\ - Exec={launcher} {default_mode}\n\ + Exec={launcher_exec} {default_mode}\n\ Icon={icon}\n\ Terminal=false\n\ Categories={categories}\n\ @@ -355,22 +365,96 @@ fn render_desktop_file(config: &LauncherConfig, paths: &InstallPaths, icon_name: \n\ [Desktop Action stop]\n\ Name=Stop\n\ - Exec={launcher} --stop\n\ + Exec={launcher_exec} --stop\n\ \n\ [Desktop Action status]\n\ Name=Status\n\ - Exec={launcher} --status\n", - name = config.project.display, - generic = generic, - comment = comment, - launcher = launcher, + Exec={launcher_exec} --status\n", + name = desktop_string_value(&config.project.display), + generic = desktop_string_value(generic), + comment = desktop_string_value(comment), + launcher_exec = launcher_exec, default_mode = default_mode, - icon = icon_name, - categories = categories, - app = config.project.name, + icon = desktop_string_value(icon_name), + categories = desktop_string_value(&categories), + app = desktop_string_value(&config.project.name), ) } +/// Whether a path exists without following a symlinks-to-missing target. +fn path_exists(path: &Path) -> bool { + fs::symlink_metadata(path).is_ok() +} + +/// Require a marker on an installed script or desktop entry before replacing +/// or removing predictable per-app destinations. This prevents a config from +/// silently clobbering an unrelated same-named file in the user's home. +fn is_managed_install(paths: &InstallPaths) -> bool { + let mut found_marker = false; + for (path, marker) in [ + (&paths.launcher_target, "# GENERATED by launch-scaffolder from "), + (&paths.desktop_file_target, "# X-Launch-Scaffolder=launch-scaffolder"), + (&paths.desktop_shortcut_target, "# X-Launch-Scaffolder=launch-scaffolder"), + ] { + if !path_exists(path) { + continue; + } + let marked = fs::symlink_metadata(path).is_ok_and(|m| m.file_type().is_file()) + && fs::read_to_string(path).is_ok_and(|text| { + text.lines().any(|line| { + if marker.starts_with("# GENERATED") { + line.starts_with(marker) + } else { + line == marker + } + }) + }); + if !marked { + return false; + } + found_marker = true; + } + if path_exists(&paths.icon_target) { + let icon_is_marked = fs::read_to_string(&paths.icon_marker_target) + .is_ok_and(|text| text == "launch-scaffolder managed icon\n"); + if !icon_is_marked { + return false; + } + found_marker = true; + } + if path_exists(&paths.icon_marker_target) { + let marker_is_valid = fs::read_to_string(&paths.icon_marker_target) + .is_ok_and(|text| text == "launch-scaffolder managed icon\n"); + if !marker_is_valid { + return false; + } + found_marker = true; + } + found_marker +} + +/// Escape a Desktop Entry string value (not an Exec argument). +fn desktop_string_value(value: &str) -> String { + value + .replace('\\', "\\\\") + .replace('\n', "\\n") + .replace('\r', "\\r") + .replace('\t', "\\t") +} + +/// Quote one argument in the freedesktop Exec grammar. Spaces are contained +/// by double quotes; the characters interpreted inside such quotes are +/// backslash-escaped. +fn desktop_exec_arg(value: &str) -> String { + let escaped = value + .replace('\\', "\\\\") + .replace('"', "\\"") + .replace('`', "\\`") + .replace('$', "\\$") + .replace('%', "%%"); + format!("\"{escaped}\"") +} + /// Resolve the absolute icon-source path if the config supplied one. fn icon_source_abs(config: &LauncherConfig) -> Option { let raw = config.icon.as_ref()?.source.as_str(); @@ -417,6 +501,9 @@ fn run_best_effort(cmd: &str, args: &[&str], report: &mut IntegReport) { mod tests { use super::*; use crate::config::{LauncherConfig, Project, Repo, Runtime, RuntimeKind}; + use std::sync::atomic::{AtomicU64, Ordering}; + + static TEST_TEMP_COUNTER: AtomicU64 = AtomicU64::new(0); fn sample_config() -> LauncherConfig { LauncherConfig { @@ -449,6 +536,66 @@ mod tests { } } + #[test] + fn every_existing_text_target_needs_its_own_management_marker() { + let dir = std::env::temp_dir().join(format!( + "launch-scaffolder-ownership-test-{}-{}", + std::process::id(), + TEST_TEMP_COUNTER.fetch_add(1, Ordering::Relaxed) + )); + fs::create_dir_all(&dir).unwrap(); + let paths = InstallPaths { + apps_dir: dir.clone(), + icon_dir: dir.clone(), + bin_dir: dir.clone(), + desktop_shortcut_dir: dir.clone(), + desktop_file_target: dir.join("app.desktop"), + desktop_shortcut_target: dir.join("shortcut.desktop"), + icon_target: dir.join("app.png"), + icon_marker_target: dir.join("app.png.launch-scaffolder-managed"), + launcher_target: dir.join("app-launcher"), + }; + fs::write(&paths.launcher_target, "# GENERATED by launch-scaffolder from test\n").unwrap(); + fs::write( + &paths.desktop_file_target, + concat!( + "[Desktop Entry]\nName=unrelated\n", + "# X-Launch-Scaffolder=launch-scaffolder (copied text)\n" + ), + ) + .unwrap(); + assert!(!is_managed_install(&paths)); + + fs::write( + &paths.desktop_file_target, + "# X-Launch-Scaffolder=launch-scaffolder\n[Desktop Entry]\n", + ) + .unwrap(); + assert!(is_managed_install(&paths)); + + fs::write(&paths.icon_target, b"not a real png").unwrap(); + assert!(!is_managed_install(&paths)); + fs::write(&paths.icon_marker_target, "launch-scaffolder managed icon\n").unwrap(); + assert!(is_managed_install(&paths)); + fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn desktop_values_are_escaped_and_the_entry_is_marked_managed() { + let mut cfg = sample_config(); + cfg.project.display = "A\\B".into(); + let paths = InstallPaths::linux("foo").unwrap(); + let body = render_desktop_file(&cfg, &paths, "foo"); + assert!(body.contains("# X-Launch-Scaffolder=launch-scaffolder")); + assert!(body.contains("Name=A\\\\B")); + assert!(desktop_exec_arg("/tmp/a b\"c$").contains("\\\"")); + assert_eq!(desktop_exec_arg("100%"), "\"100%%\""); + assert_eq!( + desktop_exec_arg("/tmp/a b\"c$`%"), + "\"/tmp/a b\\\"c\\$\\`%%\"" + ); + } + #[test] fn desktop_file_contains_required_keys() { let cfg = sample_config(); @@ -467,7 +614,7 @@ mod tests { fn removal_targets_are_stable_order() { let paths = InstallPaths::linux("foo").expect("home should resolve"); let targets = paths.removal_targets(); - assert_eq!(targets.len(), 4); + assert_eq!(targets.len(), 5); } #[test] diff --git a/crates/launcher-common/src/lib.rs b/crates/launcher-common/src/lib.rs index a1fd339..618f2dc 100644 --- a/crates/launcher-common/src/lib.rs +++ b/crates/launcher-common/src/lib.rs @@ -2,27 +2,29 @@ // Copyright (c) Jonathan D.A. Jewell //! launch-scaffolder shared library. //! -//! This crate is the heart of the launch-scaffolder tool. It contains: +//! This crate contains the implemented shared logic for the CLI: //! -//! - [`deed`] — parse the estate's `.deed` format (DEED v1.0.0 grammar). -//! - [`standard`] — parse the `launcher-standard_praxis.deed` spec file. -//! - [`config`] — parse per-app `.launcher.a2ml` config files. -//! - [`template`] — render a launcher shell script from a standard + config. -//! - [`platform`] — cross-platform file path, permission, and dispatch helpers. -//! - [`integrity`] — SHA-256 integrity manifest generation (SPARK-verifiable -//! in a future phase via Zig FFI to an Ada/SPARK module). -//! - [`exceptions`] — merge logic for standard + config + per-app `[exceptions]` -//! overrides. +//! - [`deed`] and [`standard`] — parse DEED and resolve/validate the launcher standard. +//! - [`config`] — parse and validate per-app TOML descriptors with legacy `.a2ml` names. +//! - [`template`] — render a Bash launcher with safely quoted config values. +//! - [`discovery`] — find live estate descriptors while excluding fixtures. +//! - [`integration`] — atomically install/remove Linux desktop entries and launcher files. +//! - [`fs_utils`] — same-filesystem atomic file replacement. +//! - [`metadata_block`] — read current DEED metadata and legacy A2ML metadata. +//! +//! [`platform`], [`integrity`], and [`exceptions`] remain placeholders, are not +//! called by the CLI, and must not be advertised as implemented APIs. Native +//! macOS and Windows integration are also outstanding. //! //! The `launch-scaffolder` binary crate in this workspace is a thin CLI over -//! these modules; all meaningful logic lives here so future surfaces (PanLL -//! panel, library consumers, test harnesses) can reuse it without depending -//! on the `clap` or subcommand infrastructure. +//! these modules; future library consumers can reuse the implemented logic +//! without depending on the `clap` or subcommand infrastructure. pub mod config; pub mod deed; pub mod discovery; pub mod exceptions; +pub mod fs_utils; pub mod integration; pub mod integrity; pub mod metadata_block; diff --git a/crates/launcher-common/src/metadata_block.rs b/crates/launcher-common/src/metadata_block.rs index 3010fef..bb33d1f 100644 --- a/crates/launcher-common/src/metadata_block.rs +++ b/crates/launcher-common/src/metadata_block.rs @@ -2,7 +2,8 @@ // Copyright (c) Jonathan D.A. Jewell //! Parser, renderer, and in-place rewriter for the //! `# @a2ml-metadata begin ... # @a2ml-metadata end` block embedded at -//! the top of every generated launcher script. +//! generated launcher scripts: current `@launcher-deed` blocks and the legacy +//! `@a2ml-metadata` dialect. //! //! Example input (from a real generated launcher): //! @@ -625,6 +626,12 @@ fn unquote_owned(s: &str) -> Option { /// read-only DEED dialect, the key is absent or names a list, or the parsed /// scalar cannot be located safely for replacement. pub fn rewrite_scalar(text: &str, key: &str, new_value: &str) -> Result { + if new_value + .chars() + .any(|c| c.is_control() || matches!(c, '"' | '\\')) + { + bail!("metadata scalar values must not contain quotes, backslashes, or control characters"); + } let block = parse_from_text(text)? .context("no launcher metadata block (@launcher-deed or @a2ml-metadata) found in input")?; @@ -820,6 +827,14 @@ echo "not the block" assert!(parse_from_text("no block here\n").unwrap().is_none()); } + #[test] + fn rewrite_scalar_rejects_values_that_break_quoted_metadata_literals() { + for value in [r#"" injected"#, r"back\slash", "line\nbreak"] { + let err = rewrite_scalar(SAMPLE, "version", value).unwrap_err(); + assert!(err.to_string().contains("must not contain")); + } + } + /// Name both dialects when there is no block at all. /// /// `config set` calls `rewrite_scalar` directly — it does no parse of its diff --git a/crates/launcher-common/src/standard.rs b/crates/launcher-common/src/standard.rs index 0fdfe46..f986fa3 100644 --- a/crates/launcher-common/src/standard.rs +++ b/crates/launcher-common/src/standard.rs @@ -73,7 +73,7 @@ impl LauncherStandard { if looks_like_the_old_toml_format(text) { "this looks like the retired TOML/A2ML launcher standard. The launcher \ standard is now a praxis DEED (`launcher-standard_praxis.deed`, owner \ - ruling D73-C); see hyperpolymath/standards#960" + ruling D73-C); see hyperpolymath/standards#837" } else { "standard is not a valid deed" } @@ -108,6 +108,17 @@ impl LauncherStandard { Ok(Self { doc, spec_version }) } + /// Return the highest-priority on-disk standard source, if any. An + /// explicit path wins; otherwise the baked standard's own search ladder + /// is consulted. `None` means callers should use [`BAKED_STANDARD`]. + pub fn source_path(flag: Option<&Path>) -> Option { + if let Some(path) = flag { + return Some(path.to_path_buf()); + } + let env = |key: &str| std::env::var(key).ok(); + Self::search_ladder(&env).into_iter().find(|path| path.exists()) + } + /// Resolve a standard using the documented precedence: /// /// 1. An explicit file path (typically from `--standard ` or diff --git a/crates/launcher-common/src/template.rs b/crates/launcher-common/src/template.rs index 2b61994..1e05288 100644 --- a/crates/launcher-common/src/template.rs +++ b/crates/launcher-common/src/template.rs @@ -32,9 +32,8 @@ pub const LAUNCHER_TEMPLATE: &str = include_str!("../../../templates/launcher.sh /// rather than in the standard's `(required-modes)` clause: that clause says /// what a compliant launcher MUST accept, and this says what this one DOES /// accept. They are not the same, and conflating them would let the block -/// claim a mode the script does not implement (the standard also requires -/// `--version`, which this script does not yet have — a live finding, not -/// something to paper over by copying the standard's list). +/// claim a mode the script does not implement. The explicit list below is +/// asserted against the generated main switch so the two cannot drift. /// /// [`tests::the_declared_modes_are_the_arms_of_the_main_switch`] asserts this /// list and the template's `case "$MODE"` arms agree in both directions, so @@ -49,6 +48,7 @@ pub const LAUNCHER_MODES: &[&str] = &[ "--integ", "--disinteg", "--help", + "--version", ]; /// Render a list of strings as the inside of a DEED list: `"a" "b" "c"`. @@ -70,6 +70,7 @@ pub fn render( _standard: &LauncherStandard, config_path: Option<&Path>, ) -> Result { + config.validate()?; let mut tera = Tera::default(); tera.add_raw_template("launcher.sh", LAUNCHER_TEMPLATE) .context("registering launcher template with Tera")?; @@ -77,6 +78,7 @@ pub fn render( // applies at exactly the emission sites — the embedded deed block — // and every other interpolation in the script keeps its raw value. tera.register_filter("deedstr", deedstr_filter); + tera.register_filter("shquote", shquote_filter); let mut ctx = TeraContext::new(); @@ -108,10 +110,16 @@ pub fn render( s }; ctx.insert("app_categories", &categories_joined); - ctx.insert( - "app_version", - config.project.version.as_deref().unwrap_or("1.0.0"), - ); + let app_version = config.project.version.as_deref().unwrap_or("1.0.0"); + ctx.insert("app_version", app_version); + let build_sha_short = std::env::var("LAUNCH_SCAFFOLDER_BUILD_SHA") + .ok() + .filter(|sha| { + (7..=40).contains(&sha.len()) && sha.bytes().all(|b| b.is_ascii_hexdigit()) + }) + .map(|sha| sha[..7].to_ascii_lowercase()) + .unwrap_or_else(|| "unknown".to_string()); + ctx.insert("build_sha_short", &build_sha_short); ctx.insert( "app_license", config.project.license.as_deref().unwrap_or("MPL-2.0"), @@ -186,20 +194,40 @@ pub fn render( // Resolving them at mint time instead would bake one machine's paths into // a script that may run on another, so the expansion is left to the shell // and the directory is created by the script before first write. - let pid_file = config.runtime.pid_file.clone().unwrap_or_else(|| { - format!( - "${{XDG_RUNTIME_DIR:-${{XDG_STATE_HOME:-$HOME/.local/state}}}}/{}-server.pid", - config.project.name - ) - }); - let log_file = config.runtime.log_file.clone().unwrap_or_else(|| { - format!( - "${{XDG_STATE_HOME:-$HOME/.local/state}}/{}-server.log", - config.project.name - ) - }); - ctx.insert("pid_file", &pid_file); - ctx.insert("log_file", &log_file); + let (pid_file, pid_file_is_default) = match &config.runtime.pid_file { + Some(path) => (path.clone(), false), + None => ( + format!( + "${{XDG_RUNTIME_DIR:-${{XDG_STATE_HOME:-$HOME/.local/state}}}}/launch-scaffolder/{}/server.pid", + config.project.name + ), + true, + ), + }; + let (log_file, log_file_is_default) = match &config.runtime.log_file { + Some(path) => (path.clone(), false), + None => ( + format!( + "${{XDG_STATE_HOME:-$HOME/.local/state}}/launch-scaffolder/{}/server.log", + config.project.name + ), + true, + ), + }; + let pid_file_shell = if pid_file_is_default { + format!("\"{pid_file}\"") + } else { + shell_path_quote(&pid_file) + }; + let log_file_shell = if log_file_is_default { + format!("\"{log_file}\"") + } else { + shell_path_quote(&log_file) + }; + ctx.insert("pid_file_shell", &pid_file_shell); + ctx.insert("log_file_shell", &log_file_shell); + ctx.insert("pid_file_is_default", &pid_file_is_default); + ctx.insert("log_file_is_default", &log_file_is_default); ctx.insert("wait_seconds", &config.runtime.wait_for_url_timeout_seconds); // Explicit command vector vs search list. @@ -218,7 +246,7 @@ pub fn render( .as_ref() .map(|i| i.source.replace("{repo-dir}", &config.repo.path)) .unwrap_or_default(); - ctx.insert("icon_source", &icon_source); + ctx.insert("icon_source_shell", &shell_path_quote(&icon_source)); // --- metadata ----------------------------------------------------- ctx.insert("spec_version", &_standard.spec_version); @@ -310,6 +338,34 @@ fn deed_escape(s: &str) -> std::result::Result { Ok(out) } +/// Quote a string as one POSIX-shell word. The generated program is Bash, +/// whose single-quoted strings preserve every character except a literal +/// single quote; that character is emitted using the standard close/escape/ +/// reopen sequence. Config values must never be interpolated as shell code. +fn shell_quote(value: &str) -> String { + format!("'{}'", value.replace('\'', r"'\''")) +} + +fn shell_path_quote(path: &str) -> String { + if let Some(rest) = path.strip_prefix("~/") { + format!("\"${{HOME}}\"/{}", shell_quote(rest)) + } else if path == "~" { + "\"${HOME}\"".to_string() + } else { + shell_quote(path) + } +} + +fn shquote_filter( + value: &tera::Value, + _args: &std::collections::HashMap, +) -> tera::Result { + let s = value + .as_str() + .ok_or_else(|| tera::Error::msg(format!("`shquote` takes a string, got `{value}`")))?; + Ok(tera::Value::from(shell_quote(s))) +} + /// Tera filter wrapping [`deed_escape`], registered as `deedstr`. /// /// Tera autoescaping is HTML-shaped and does not fire for a `.sh` template @@ -401,6 +457,34 @@ mod tests { /// A freshly minted launcher carries a block the Phase-1 reader accepts, /// and accepts *as a deed* rather than by falling back to the legacy arm. /// + #[test] + fn icon_home_paths_are_expanded_without_losing_shell_quoting() { + let std_ = LauncherStandard::baked().expect("baked standard should parse"); + let mut cfg = stapeln_config(); + cfg.icon = Some(crate::config::Icon { + source: "~/icons/app image.png".into(), + }); + let script = render(&cfg, &std_, None).expect("renders"); + assert!(script.contains("ICON_SOURCE=\"${HOME}\"/'icons/app image.png'")); + } + + #[test] + fn shell_metacharacters_in_config_are_data_not_code() { + let mut config = sample_config(); + config.project.display = r#"x"; touch /tmp/launch-scaffolder-pwned; #"#.into(); + config.repo.path = r#"/tmp/a"$(touch /tmp/launch-scaffolder-pwned)"#.into(); + config.runtime.command = vec![ + "program with spaces".into(), + "$(touch /tmp/launch-scaffolder-pwned)".into(), + "it's fine".into(), + ]; + let rendered = render(&config, &LauncherStandard::baked().unwrap(), None).unwrap(); + assert!(rendered.contains(r#"APP_DISPLAY='x"; touch /tmp/launch-scaffolder-pwned; #'"#)); + assert!(rendered.contains(r#"REPO_DIR='/tmp/a"$(touch /tmp/launch-scaffolder-pwned)'"#)); + assert!(rendered.contains(r#"START_COMMAND=('program with spaces' '$(touch /tmp/launch-scaffolder-pwned)' 'it'\''s fine')"#)); + assert!(!rendered.contains(r#"APP_DISPLAY="x"; touch"#)); + } + /// Asserting `is_deed()` is what separates this from "some block parsed": /// the reader still understands both dialects, so a template that had /// silently kept emitting the legacy form would pass a parse-only test. @@ -684,9 +768,9 @@ mod tests { /// They are shell expressions rather than paths: the launcher runs on the /// user's machine, which is not necessarily the machine it was minted on. const DEFAULT_PID_LINE: &str = - "PID_FILE=\"${XDG_RUNTIME_DIR:-${XDG_STATE_HOME:-$HOME/.local/state}}/stapeln-server.pid\""; + "PID_FILE=\"${XDG_RUNTIME_DIR:-${XDG_STATE_HOME:-$HOME/.local/state}}/launch-scaffolder/stapeln/server.pid\""; const DEFAULT_LOG_LINE: &str = - "LOG_FILE=\"${XDG_STATE_HOME:-$HOME/.local/state}/stapeln-server.log\""; + "LOG_FILE=\"${XDG_STATE_HOME:-$HOME/.local/state}/launch-scaffolder/stapeln/server.log\""; /// A config that sets neither `pid-file` nor `log-file` mints a launcher /// whose state lands under a per-user directory — never in `/tmp`. @@ -725,8 +809,8 @@ mod tests { /// An explicit `pid-file` / `log-file` in the config still wins, unchanged /// (#48 AC2). /// - /// Including the `~/` spelling, which is expanded by `integration.rs` - /// rather than by the renderer. + /// Including the `~/` spelling, which must be expanded safely by the + /// generated shell at runtime. #[test] fn explicit_pid_and_log_paths_still_win_unchanged() { let std_ = LauncherStandard::baked().expect("baked standard should parse"); @@ -737,12 +821,12 @@ mod tests { let script = render(&cfg, &std_, None).expect("renders"); assert!( - script.contains("PID_FILE=\"/var/run/stapeln.pid\""), - "an explicit pid-file must be emitted verbatim" + script.contains("PID_FILE='/var/run/stapeln.pid'"), + "an explicit pid-file must be shell-quoted" ); assert!( - script.contains("LOG_FILE=\"~/logs/stapeln.log\""), - "an explicit log-file must be emitted verbatim, `~` included" + script.contains("LOG_FILE=\"${HOME}\"/'logs/stapeln.log'"), + "an explicit leading `~/` must expand safely at runtime" ); assert!( !script.contains("XDG_RUNTIME_DIR") && !script.contains("XDG_STATE_HOME"), @@ -756,25 +840,27 @@ mod tests { ); } - /// The generated launcher creates its state directories 0700 before writing + /// The generated launcher creates private per-app state directories before writing /// (#48 AC3). /// - /// Two assertions, because either alone is vacuous: `mkdir -p` without a - /// mode creates the directory with the caller's umask (commonly 0755), and - /// `mkdir -p -m` applies the mode only to the deepest directory it creates - /// — so the mode is stated separately, and the test looks for both halves. + /// Creation and mode are separate assertions. The private per-app leaf is + /// chmodded, while a configured external parent is never chmodded. #[test] fn the_launcher_creates_its_state_directories_0700_before_writing() { let script = render_stapeln(); assert!( - script.contains("chmod 0700 \"$pid_dir\" \"$log_dir\""), - "the launcher must set 0700 on the directories it is about to write into" + script.contains("chmod 0700 \"$pid_dir\""), + "the launcher must set 0700 on its default per-app state directory" ); assert!( script.contains("mkdir -p \"$pid_dir\" \"$log_dir\""), "the launcher must create the directories it is about to write into" ); + assert!( + script.contains("chmod 0700 \"$log_dir\""), + "the launcher must separately set 0700 on its default log directory" + ); } /// `ensure_state_dirs` runs BEFORE the first write, not after. @@ -850,6 +936,29 @@ mod tests { /// Vacuity guard: the arms are read from the template, so if the main /// switch were ever renamed or removed the extraction returns nothing and /// the test fails instead of passing on an empty list. + #[test] + fn shell_integration_validates_each_managed_target_independently() { + let script = render_stapeln(); + assert!(script.contains("local marker_targets=(\"$LAUNCHER_TARGET\" \"$DESKTOP_FILE_TARGET\" \"$DESKTOP_SHORTCUT_TARGET\")")); + assert!(script.contains("grep -Fxq '# X-Launch-Scaffolder=launch-scaffolder'")); + assert!(script.contains("ICON_MARKER_TARGET=\"$ICON_TARGET.launch-scaffolder-managed\"")); + assert!(script.contains("grep -Fxq 'launch-scaffolder managed icon'")); + assert!(script.contains("printf -v quoted_launcher '%q' \"$LAUNCHER_TARGET\"")); + assert!(script.contains("desktop_exec_arg()")); + assert!(script.contains("${value//%/%%}")); + assert!(script.contains("\"$ICON_MARKER_TARGET\"")); + } + + #[test] + fn version_and_browser_aliases_are_implemented() { + let script = render_stapeln(); + assert!(script.contains("APP_VERSION='0.1.0'")); + assert!(script.contains("printf '%s %s (%s) [%s]\\n'")); + assert!(script.contains("--browser|--web)")); + assert!(script.contains("start_server && open_browser")); + assert!(LAUNCHER_MODES.contains(&"--version")); + } + #[test] fn the_declared_modes_are_the_arms_of_the_main_switch() { let arms = main_switch_arms(); diff --git a/crates/launcher-common/tests/fixtures/metadata_block/README.adoc b/crates/launcher-common/tests/fixtures/metadata_block/README.adoc index 6d1c07c..56cceea 100644 --- a/crates/launcher-common/tests/fixtures/metadata_block/README.adoc +++ b/crates/launcher-common/tests/fixtures/metadata_block/README.adoc @@ -1,3 +1,5 @@ +// SPDX-License-Identifier: CC-BY-SA-4.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell = `+tests/fixtures/metadata_block/+` — committed launcher artefacts Three kinds of artefact live in this repository, and the difference between diff --git a/crates/launcher/Cargo.toml b/crates/launcher/Cargo.toml index 115ac9a..97fb1a2 100644 --- a/crates/launcher/Cargo.toml +++ b/crates/launcher/Cargo.toml @@ -10,7 +10,7 @@ license.workspace = true repository.workspace = true homepage.workspace = true readme.workspace = true -description = "Cross-platform launcher mint/provision/config/realign tool. Generates compliant desktop launchers from a declarative DEED standard." +description = "Portable launcher minter and configurator with Linux desktop integration." keywords.workspace = true categories.workspace = true diff --git a/crates/launcher/src/cmd_config.rs b/crates/launcher/src/cmd_config.rs index c507e66..f5493bf 100644 --- a/crates/launcher/src/cmd_config.rs +++ b/crates/launcher/src/cmd_config.rs @@ -18,7 +18,10 @@ use anyhow::{Context, Result}; use clap::{Args as ClapArgs, Subcommand}; -use launch_scaffolder_common::metadata_block; +use launch_scaffolder_common::{ + fs_utils::{existing_mode_or, write_atomic}, + metadata_block, +}; use std::path::{Path, PathBuf}; #[derive(Debug, ClapArgs)] @@ -90,7 +93,9 @@ fn cmd_set(script: &Path, key: &str, value: &str) -> Result<()> { let text = std::fs::read_to_string(script).with_context(|| format!("reading {}", script.display()))?; let rewritten = metadata_block::rewrite_scalar(&text, key, value)?; - std::fs::write(script, &rewritten).with_context(|| format!("writing {}", script.display()))?; + let mode = existing_mode_or(script, 0o644); + write_atomic(script, rewritten.as_bytes(), mode) + .with_context(|| format!("writing {}", script.display()))?; println!("✓ {}: {} = \"{}\"", script.display(), key, value); eprintln!( "⚠ realign will overwrite this change. Update the source .launcher.a2ml \ diff --git a/crates/launcher/src/cmd_mint.rs b/crates/launcher/src/cmd_mint.rs index fef3897..13d4148 100644 --- a/crates/launcher/src/cmd_mint.rs +++ b/crates/launcher/src/cmd_mint.rs @@ -5,7 +5,12 @@ use anyhow::{Context, Result}; use clap::Args as ClapArgs; -use launch_scaffolder_common::{config::LauncherConfig, standard::LauncherStandard, template}; +use launch_scaffolder_common::{ + config::LauncherConfig, + fs_utils::{existing_mode_or, write_atomic, write_atomic_unmodified}, + standard::LauncherStandard, + template, +}; use std::path::{Path, PathBuf}; #[derive(Debug, ClapArgs)] @@ -45,16 +50,17 @@ pub fn run(args: Args, standard_path: Option<&Path>) -> Result<()> { parent.join(format!("{}-launcher.sh", config.project.name)) }); - std::fs::write(&out, &script).with_context(|| format!("writing {}", out.display()))?; - - if !args.no_chmod { - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let mut perms = std::fs::metadata(&out)?.permissions(); - perms.set_mode(0o755); - std::fs::set_permissions(&out, perms)?; - } + if args.no_chmod && !out.exists() { + write_atomic_unmodified(&out, script.as_bytes()) + .with_context(|| format!("writing {}", out.display()))?; + } else { + let mode = if args.no_chmod { + existing_mode_or(&out, 0o644) + } else { + 0o755 + }; + write_atomic(&out, script.as_bytes(), mode) + .with_context(|| format!("writing {}", out.display()))?; } tracing::info!("minted {} → {}", config.project.name, out.display()); diff --git a/crates/launcher/src/cmd_realign.rs b/crates/launcher/src/cmd_realign.rs index 605f191..5d2d31c 100644 --- a/crates/launcher/src/cmd_realign.rs +++ b/crates/launcher/src/cmd_realign.rs @@ -27,12 +27,16 @@ //! are treated as test fixtures and skipped; only `.launcher.a2ml` //! (without `.fixture.`) is considered a live config. This is the //! project-wide convention for distinguishing fixture inputs from -//! estate-owned configs — see `examples/README.md`. +//! estate-owned configs — see `examples/README.adoc`. use anyhow::{Context, Result}; use clap::Args as ClapArgs; use launch_scaffolder_common::{ - config::LauncherConfig, discovery, standard::LauncherStandard, template, + config::LauncherConfig, + discovery, + fs_utils::{existing_mode_or, write_atomic, write_atomic_unmodified}, + standard::LauncherStandard, + template, }; use std::path::{Path, PathBuf}; @@ -169,16 +173,17 @@ fn realign_one( return Ok(outcome); } - std::fs::write(&out, &script).with_context(|| format!("writing {}", out.display()))?; - - if !no_chmod { - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let mut perms = std::fs::metadata(&out)?.permissions(); - perms.set_mode(0o755); - std::fs::set_permissions(&out, perms)?; - } + if no_chmod && !out.exists() { + write_atomic_unmodified(&out, script.as_bytes()) + .with_context(|| format!("writing {}", out.display()))?; + } else { + let mode = if no_chmod { + existing_mode_or(&out, 0o644) + } else { + 0o755 + }; + write_atomic(&out, script.as_bytes(), mode) + .with_context(|| format!("writing {}", out.display()))?; } tracing::info!("realigned {} → {}", config.project.name, out.display()); diff --git a/crates/launcher/src/cmd_standard.rs b/crates/launcher/src/cmd_standard.rs index 740195d..63c915d 100644 --- a/crates/launcher/src/cmd_standard.rs +++ b/crates/launcher/src/cmd_standard.rs @@ -1,20 +1,90 @@ // SPDX-License-Identifier: MPL-2.0 // Copyright (c) Jonathan D.A. Jewell -//! `standard` subcommand — scaffold stub. Full implementation lands in a -//! follow-up session once the workspace builds and the common crate has -//! its standard/config parsers. +//! `standard` subcommand — inspect and validate the launcher standard. -use anyhow::Result; -use clap::Args as ClapArgs; +use anyhow::{Context, Result}; +use clap::{Args as ClapArgs, Subcommand}; +use launch_scaffolder_common::standard::{BAKED_STANDARD, LauncherStandard}; use std::path::Path; #[derive(Debug, ClapArgs)] pub struct Args { - // Subcommand-specific flags land here in follow-up. + #[command(subcommand)] + action: Action, } -pub fn run(_args: Args, _standard: Option<&Path>) -> Result<()> { - tracing::warn!("subcommand `standard` is a scaffold stub — not yet implemented"); - println!("launch-scaffolder standard: not yet implemented (scaffold stub)"); +#[derive(Debug, Subcommand)] +enum Action { + /// Print the resolved launcher standard (the baked copy when no external + /// standard is available). + Show, + /// Parse and validate the selected launcher standard. + Validate, +} + +pub fn run(args: Args, standard_path: Option<&Path>) -> Result<()> { + match args.action { + Action::Show => show(standard_path), + Action::Validate => validate(standard_path), + } +} + +fn show(standard_path: Option<&Path>) -> Result<()> { + let text = match LauncherStandard::source_path(standard_path) { + Some(path) => std::fs::read_to_string(&path) + .with_context(|| format!("reading standard {}", path.display()))?, + None => { + // Preserve the usual fallback diagnostic before selecting the + // embedded text. `show` parses the exact bytes it will print below. + LauncherStandard::resolve(standard_path)?; + BAKED_STANDARD.to_string() + } + }; + let standard = LauncherStandard::parse(&text).context("validating selected launcher standard")?; + print!("{text}"); + tracing::debug!("showed launcher standard version {}", standard.spec_version); + Ok(()) +} + +fn validate(standard_path: Option<&Path>) -> Result<()> { + let standard = LauncherStandard::resolve(standard_path)?; + let required = standard.metadata_required_fields()?; + let platforms = standard.platforms()?; + let covered = standard.lifecycle_phases_covered()?; + let deferred = standard.lifecycle_phases_deferred()?; + + let modes = standard + .doc + .clause("required-modes") + .context("standard is missing the (required-modes) clause")?; + for key in ["runtime", "integration", "meta"] { + let field = modes + .field(key) + .with_context(|| format!("(required-modes) is missing :{key}"))?; + let entries = field + .str_list(); + if entries.is_empty() { + anyhow::bail!("(required-modes :{key}) must be a non-empty string list"); + } + } + + println!( + "✓ launcher standard {} — {} metadata fields, {} platforms, {} phases covered, {} deferred", + standard.spec_version, + required.len(), + platforms.len(), + covered.len(), + deferred.len(), + ); Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_baked_standard_passes_semantic_validation() { + validate(None).expect("the committed standard should validate"); + } +} diff --git a/crates/launcher/src/main.rs b/crates/launcher/src/main.rs index 4dfb32f..841fd2f 100644 --- a/crates/launcher/src/main.rs +++ b/crates/launcher/src/main.rs @@ -1,6 +1,6 @@ // SPDX-License-Identifier: MPL-2.0 // Copyright (c) Jonathan D.A. Jewell -//! launch-scaffolder — cross-platform launcher minter, provisioner, configurator. +//! launch-scaffolder — portable launcher minter and Linux provisioner/configurator. //! //! One binary, five subcommands: //! @@ -23,7 +23,7 @@ mod cmd_provision; mod cmd_realign; mod cmd_standard; -/// launch-scaffolder — build and maintain cross-platform launchers from A2ML specs. +/// launch-scaffolder — build and maintain launchers from per-app specs. #[derive(Debug, Parser)] #[command( name = "launch-scaffolder", @@ -96,3 +96,17 @@ fn main() -> Result<()> { Command::Standard(args) => cmd_standard::run(args, cli.standard.as_deref()), } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn standard_show_and_validate_are_registered() { + for action in ["show", "validate"] { + let cli = Cli::try_parse_from(["launch-scaffolder", "standard", action]) + .expect("standard action must be available from the CLI"); + assert!(matches!(cli.command, Command::Standard(_))); + } + } +} diff --git a/docs/branch-protection-remediation-2026-04-10.adoc b/docs/branch-protection-remediation-2026-04-10.adoc index f69b701..8a9cb49 100644 --- a/docs/branch-protection-remediation-2026-04-10.adoc +++ b/docs/branch-protection-remediation-2026-04-10.adoc @@ -1,3 +1,5 @@ +// SPDX-License-Identifier: CC-BY-SA-4.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell == Estate-wide branch-protection remediation — 2026-04-10 === What triggered this diff --git a/docs/compliance-audit-2026-04-10.adoc b/docs/compliance-audit-2026-04-10.adoc index e6c1870..6019d37 100644 --- a/docs/compliance-audit-2026-04-10.adoc +++ b/docs/compliance-audit-2026-04-10.adoc @@ -1,3 +1,5 @@ +// SPDX-License-Identifier: CC-BY-SA-4.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell == Launcher Compliance Audit — 2026-04-10 ____ @@ -13,10 +15,10 @@ cross-reference this one by its frozen claims. * `+README.adoc+` — live subcommand surface and architecture * `+.machine_readable/6a2/STATE.a2ml+` — current milestone + completion percentage -* `+docs/launcher-exceptions-2026-04-10.md+` — reconciliation against +* `+docs/launcher-exceptions-2026-04-10.adoc+` — reconciliation against this audit, including one correction (opsm runtime-shape classification; see its "`Discrepancy 1`" section) -* `+docs/branch-protection-remediation-2026-04-10.md+` — estate-wide +* `+docs/branch-protection-remediation-2026-04-10.adoc+` — estate-wide ruleset remediation that followed the scaffolder work Of the 11 launchers audited here, 6 have since been migrated to @@ -25,7 +27,7 @@ project-wharf — plus stapeln, which the audit did not cover because stapeln’s launcher lived under `+stapeln/scripts/+` not `+.desktop-tools/+`). The remaining 5 are the declared exceptions (hypatia, invariant-path, opsm, ambientops, idaptik) documented with -migration triggers in `+docs/launcher-exceptions-2026-04-10.md+`. +migration triggers in `+docs/launcher-exceptions-2026-04-10.adoc+`. ____ Read-only audit of the 11 hand-written launchers in diff --git a/docs/launcher-exceptions-2026-04-10.adoc b/docs/launcher-exceptions-2026-04-10.adoc index d446e30..d46d94e 100644 --- a/docs/launcher-exceptions-2026-04-10.adoc +++ b/docs/launcher-exceptions-2026-04-10.adoc @@ -1,3 +1,5 @@ +// SPDX-License-Identifier: CC-BY-SA-4.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell == Launcher Scaffolder Exceptions — 2026-04-10 Five launchers in `+/var/mnt/eclipse/repos/.desktop-tools/+` are @@ -7,7 +9,7 @@ Each row records what would have to change in `+launch-scaffolder+` before the launcher could be moved into scaffolder management. Companion document: -link:compliance-audit-2026-04-10.md[`+compliance-audit-2026-04-10.md+`]. +link:compliance-audit-2026-04-10.adoc[`+compliance-audit-2026-04-10.adoc+`]. See link:#cross-reference-against-the-compliance-audit[§Cross-reference against the compliance audit] at the end for the reconciliation notes — discrepancies are flagged, not silently patched. diff --git a/docs/ruleset-audit-2026-04-10/README.adoc b/docs/ruleset-audit-2026-04-10/README.adoc index 3556061..99e9e49 100644 --- a/docs/ruleset-audit-2026-04-10/README.adoc +++ b/docs/ruleset-audit-2026-04-10/README.adoc @@ -1,8 +1,10 @@ +// SPDX-License-Identifier: CC-BY-SA-4.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell == `+docs/ruleset-audit-2026-04-10/+` Raw data and scripts from the estate-wide branch-protection ruleset audit conducted on 2026-04-10. The human-readable post-mortem lives at -link:../branch-protection-remediation-2026-04-10.md[`+../branch-protection-remediation-2026-04-10.md+`] +link:../branch-protection-remediation-2026-04-10.adoc[`+../branch-protection-remediation-2026-04-10.adoc+`] — *read that first*. === Contents diff --git a/docs/ruleset-audit-2026-04-10/TRANSFER-VERIFICATION-CHECKLIST-2026-04-22.adoc b/docs/ruleset-audit-2026-04-10/TRANSFER-VERIFICATION-CHECKLIST-2026-04-22.adoc index c13cbb2..681ca7c 100644 --- a/docs/ruleset-audit-2026-04-10/TRANSFER-VERIFICATION-CHECKLIST-2026-04-22.adoc +++ b/docs/ruleset-audit-2026-04-10/TRANSFER-VERIFICATION-CHECKLIST-2026-04-22.adoc @@ -1,3 +1,5 @@ +// SPDX-License-Identifier: CC-BY-SA-4.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell == Transfer Verification Checklist — 2026-04-22 Scope: verify whether the following legacy repos were transferred from diff --git a/docs/tech-debt-2026-05-26.adoc b/docs/tech-debt-2026-05-26.adoc index b35ee25..a32ddc9 100644 --- a/docs/tech-debt-2026-05-26.adoc +++ b/docs/tech-debt-2026-05-26.adoc @@ -1,3 +1,5 @@ +// SPDX-License-Identifier: CC-BY-SA-4.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell == Tech-Debt Audit — launch-scaffolder — 2026-05-26 *Source:* estate-wide automated scan 2026-05-26. *Companion:* diff --git a/examples/README.adoc b/examples/README.adoc index 15ab451..3a39e82 100644 --- a/examples/README.adoc +++ b/examples/README.adoc @@ -1,3 +1,5 @@ +// SPDX-License-Identifier: CC-BY-SA-4.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell == `+examples/+` — fixture inputs for `+launch-scaffolder+` Every file in this directory is a *test fixture*, not a live per-app diff --git a/examples/stapeln.launcher.fixture.a2ml b/examples/stapeln.launcher.fixture.a2ml index 42e0284..5762cad 100644 --- a/examples/stapeln.launcher.fixture.a2ml +++ b/examples/stapeln.launcher.fixture.a2ml @@ -4,7 +4,7 @@ # examples/stapeln.launcher.fixture.a2ml — worked example per-app config. # FIXTURE ONLY — the `.fixture.` infix marks this as a test input so # `launch-scaffolder realign` estate walks do not pick it up alongside -# the live `stapeln/stapeln.launcher.a2ml`. See examples/README.md. +# the live `stapeln/stapeln.launcher.a2ml`. See examples/README.adoc. # # This is the input to `launch-scaffolder mint examples/stapeln.launcher.fixture.a2ml`. # The output is a compliant stapeln-launcher.sh (equivalent to the current @@ -33,8 +33,7 @@ startup-command-search = [ "{repo-dir}/scripts/run.sh", "{repo-dir}/dev.sh", ] -pid-file = "/tmp/stapeln-server.pid" -log-file = "/tmp/stapeln-server.log" +# Leave PID/log paths unset to use the secure per-app XDG defaults. wait-for-url-timeout-seconds = 15 [icon] diff --git a/manifest.scm b/manifest.scm index dd942c7..e4b4fea 100644 --- a/manifest.scm +++ b/manifest.scm @@ -10,6 +10,9 @@ "git" "just" "nickel" + "rust" + "rust-cargo" + "shellcheck" "curl" "bash" "coreutils" diff --git a/mise.toml b/mise.toml index 6dd983f..9072977 100644 --- a/mise.toml +++ b/mise.toml @@ -1,57 +1,18 @@ -[tools] -# Language runtimes -node = "latest" -python = "latest" -rust = "latest" -go = "latest" -zig = "latest" -java = "latest" -bun = "latest" -denojs = "latest" - -# Package managers -npm = "latest" -yarn = "latest" -pnpm = "latest" -pip = "latest" -cargo = "latest" -go-task = "latest" - -# Formatting & Linting -gofmt = "latest" -black = "latest" -isort = "latest" -ruff = "latest" -prettier = "latest" -shfmt = "latest" -stylua = "latest" +# SPDX-License-Identifier: MPL-2.0 +# Minimal development toolchain for the Rust/SPARK CLI repository. +# Keep this project free of unrelated JavaScript/Node/Deno runtimes per the +# estate language policy. -# Build tools -cmake = "latest" -make = "latest" -ninja = "latest" - -# Shell tools -git = "latest" -gnu-sed = "latest" -gnu-tar = "latest" -gnu-grep = "latest" - -# Testing -vitest = "latest" -pytest = "latest" -jest = "latest" +[tools] +rust = "1.85.0" +just = "latest" +shellcheck = "latest" [env] -# Common environment variables -NODE_ENV = "development" -PYTHONDONTWRITEBYTECODE = "1" -PYTHONUNBUFFERED = "1" +RUST_BACKTRACE = "1" -# Task runner alias [alias] -task = "go-task" -build = "cargo build --release || npm run build || go build" -test = "cargo test || npm test || go test ./..." -lint = "ruff check . || prettier --check . || black --check ." -fmt = "ruff format . || prettier --write . || black ." +build = "cargo build --locked --workspace" +test = "cargo test --locked --workspace" +lint = "cargo clippy --locked --workspace --all-targets -- -D warnings" +fmt = "cargo fmt --all" diff --git a/templates/launcher.sh.tera b/templates/launcher.sh.tera index ba5d5b1..3a0489c 100644 --- a/templates/launcher.sh.tera +++ b/templates/launcher.sh.tera @@ -1,4 +1,5 @@ #!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 {# NOTE: the shebang MUST be the literal first line of this template. It previously sat below this comment block, and every launcher the generator emitted began with a blank line -- shellcheck SC2148, and a @@ -72,25 +73,27 @@ set -euo pipefail # CONFIGURATION # ---------------------------------------------------------------------------- -APP_NAME="{{ app_name }}" -APP_DISPLAY="{{ app_display }}" -APP_DESC="{{ app_desc }}" -APP_CATEGORIES="{{ app_categories }}" -APP_GENERIC_NAME="{{ generic_name }}" -RUNTIME_KIND="{{ runtime_kind }}" +APP_NAME={{ app_name | shquote }} +APP_DISPLAY={{ app_display | shquote }} +APP_DESC={{ app_desc | shquote }} +APP_CATEGORIES={{ app_categories | shquote }} +APP_GENERIC_NAME={{ generic_name | shquote }} +APP_VERSION={{ app_version | shquote }} +BUILD_SHA_SHORT={{ build_sha_short | shquote }} +RUNTIME_KIND={{ runtime_kind | shquote }} -REPO_DIR="{{ repo_dir }}" -ICON_SOURCE="{{ icon_source }}" +REPO_DIR={{ repo_dir | shquote }} +ICON_SOURCE={{ icon_source_shell | safe }} # Absolute path back to the per-app `.launcher.a2ml` config that # produced this script. Consumed by the --integ / --disinteg arms when # the `launch-scaffolder` binary is on $PATH, so they can delegate to # the Rust implementation instead of running the shell fallback. -CONFIG_FILE="{{ config_file }}" +CONFIG_FILE={{ config_file | shquote }} {% if runtime_kind == "server-url" -%} {% if has_url -%} -URL="{{ url }}" +URL={{ url | shquote }} {%- else -%} # No explicit [runtime].url in the config, so the URL is composed from the port # at run time. APP_PORT is emitted ONLY here, where something reads it: the port @@ -102,38 +105,64 @@ URL="http://localhost:${APP_PORT}" {%- endif %} WAIT_SECONDS="{{ wait_seconds }}" {%- elif runtime_kind == "remote" -%} -URL="{{ url }}" +URL={{ url | shquote }} {%- else -%} URL="" {%- endif %} -PID_FILE="{{ pid_file }}" -LOG_FILE="{{ log_file }}" +PID_FILE={{ pid_file_shell | safe }} +LOG_FILE={{ log_file_shell | safe }} -# Both defaults live under a per-user XDG directory. Create them 0700 before +# Both defaults live in per-app directories under per-user XDG state. Create +# the default leaves as 0700 before # the first write: a predictable path inside a world-writable directory (the # old /tmp default) let any local user pre-create or symlink the pid file and # steer what this script later killed or removed (Hypatia 82/83, #48). -# `mkdir -p -m` cannot do the job: with -p the mode applies only to the -# deepest directory created, so the chmod is stated separately and applies -# whether or not the directory was just made. +# Default locations use unique per-app directories before chmod. Explicit +# paths are never chmodded: a path such as /tmp must never have its parent +# permissions changed. Every resolved parent is checked before PID/log I/O. ensure_state_dirs() { local pid_dir log_dir pid_dir="$(dirname "$PID_FILE")" log_dir="$(dirname "$LOG_FILE")" mkdir -p "$pid_dir" "$log_dir" - chmod 0700 "$pid_dir" "$log_dir" +{% if pid_file_is_default -%} + chmod 0700 "$pid_dir" +{%- endif %} +{% if log_file_is_default -%} + chmod 0700 "$log_dir" +{%- endif %} + check_private_state_dir "$pid_dir" || return 1 + check_private_state_dir "$log_dir" || return 1 +} + +# Refuse shared or group-writable state locations. This also prevents a +# custom /tmp path from causing chmod on /tmp or exposing a predictable PID +# file to other local users. +check_private_state_dir() { + local dir="$1" mode digits numeric + [[ -O "$dir" ]] || { err "State directory is not owned by this user: $dir"; return 1; } + mode="$(stat -c '%a' "$dir" 2>/dev/null || stat -f '%Lp' "$dir" 2>/dev/null)" || { + err "Cannot inspect state-directory permissions: $dir"; return 1; + } + digits="${mode: -3}" + [[ "$digits" =~ ^[0-7]{3}$ ]] || { err "Cannot inspect state-directory permissions: $dir"; return 1; } + numeric=$((8#$digits)) + if (( numeric & 022 )); then + err "State directory is group/world-writable; choose a private location: $dir" + return 1 + fi } {% if explicit_command | length > 0 -%} # Explicit argv from [runtime].command -START_COMMAND=({% for arg in explicit_command %}{{ arg | safe }} {% endfor %}) +START_COMMAND=({% for arg in explicit_command %}{{ arg | shquote }}{% if not loop.last %} {% endif %}{% endfor %}) {%- elif startup_search | length > 0 -%} # Search list from [runtime].startup-command-search — first executable wins. START_COMMAND="" for candidate in \ {%- for cmd in startup_search %} - "{{ cmd }}" \ + {{ cmd | shquote }} \ {%- endfor %} ; do if [ -x "$candidate" ]; then @@ -201,6 +230,7 @@ case "$PLATFORM" in DESKTOP_FILE_TARGET="$APPS_DIR/${APP_NAME}.desktop" DESKTOP_SHORTCUT_TARGET="$DESKTOP_SHORTCUT_DIR/${APP_NAME}.desktop" ICON_TARGET="$ICON_DIR/${APP_NAME}.png" + ICON_MARKER_TARGET="$ICON_TARGET.launch-scaffolder-managed" LAUNCHER_TARGET="$BIN_DIR/${APP_NAME}-launcher" ;; macos) @@ -210,6 +240,7 @@ case "$PLATFORM" in DESKTOP_FILE_TARGET="$APPS_DIR/${APP_DISPLAY}.app" DESKTOP_SHORTCUT_TARGET="$DESKTOP_SHORTCUT_DIR/${APP_DISPLAY}.command" ICON_TARGET="$APPS_DIR/${APP_DISPLAY}.app/Contents/Resources/icon.png" + ICON_MARKER_TARGET="$ICON_TARGET.launch-scaffolder-managed" LAUNCHER_TARGET="$BIN_DIR/${APP_NAME}-launcher" ;; windows) @@ -220,11 +251,12 @@ case "$PLATFORM" in DESKTOP_FILE_TARGET="$START_MENU_DIR/${APP_DISPLAY}.lnk" DESKTOP_SHORTCUT_TARGET="$DESKTOP_SHORTCUT_DIR/${APP_DISPLAY}.lnk" ICON_TARGET="$BIN_DIR/${APP_NAME}.ico" + ICON_MARKER_TARGET="$ICON_TARGET.launch-scaffolder-managed" LAUNCHER_TARGET="$BIN_DIR/${APP_NAME}-launcher.sh" ;; *) APPS_DIR=""; DESKTOP_SHORTCUT_DIR=""; BIN_DIR="$HOME/.local/bin" - DESKTOP_FILE_TARGET=""; DESKTOP_SHORTCUT_TARGET=""; ICON_TARGET="" + DESKTOP_FILE_TARGET=""; DESKTOP_SHORTCUT_TARGET=""; ICON_TARGET=""; ICON_MARKER_TARGET="" LAUNCHER_TARGET="$BIN_DIR/${APP_NAME}-launcher" ;; esac @@ -233,12 +265,34 @@ esac # PROCESS MANAGEMENT # ---------------------------------------------------------------------------- +pid_directory_is_safe() { + local pid_dir + pid_dir="$(dirname "$PID_FILE")" + [ -d "$pid_dir" ] || return 0 + check_private_state_dir "$pid_dir" +} + +read_pid() { + local pid + IFS= read -r pid < "$PID_FILE" || return 1 + if [[ ! "$pid" =~ ^[0-9]{1,10}$ ]] || (( 10#$pid < 2 )); then + err "Invalid PID value in $PID_FILE" + return 1 + fi + printf '%s' "$pid" +} + is_running() { - [ -f "$PID_FILE" ] && kill -0 "$(cat "$PID_FILE")" 2>/dev/null + [ -f "$PID_FILE" ] || return 1 + pid_directory_is_safe || return 1 + local pid + pid="$(read_pid)" || return 1 + kill -0 "$pid" 2>/dev/null } clear_stale_pid() { - if [ -f "$PID_FILE" ] && ! kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then + if [ -f "$PID_FILE" ] && ! is_running; then + warn "Removing stale or invalid PID file" rm -f "$PID_FILE" fi } @@ -266,7 +320,7 @@ start_server() { return 0 {%- else -%} if is_running; then - log "Already running (PID $(cat "$PID_FILE"))" + log "Already running (PID $(read_pid))" return 0 fi @@ -289,7 +343,9 @@ start_server() { echo $! > "$PID_FILE" sleep 0.2 - if ! kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then + local started_pid + started_pid="$(read_pid)" || { rm -f "$PID_FILE"; return 1; } + if ! kill -0 "$started_pid" 2>/dev/null; then gui_error "Process exited immediately" "Check $LOG_FILE" rm -f "$PID_FILE" return 1 @@ -307,9 +363,9 @@ Check $LOG_FILE — and try: curl -v $URL" fi return 1 fi - log "Server started (PID $(cat "$PID_FILE")) — $URL" + log "Server started (PID $(read_pid)) — $URL" {%- else -%} - log "Started (PID $(cat "$PID_FILE"))" + log "Started (PID $(read_pid))" {%- endif %} return 0 {%- endif %} @@ -325,7 +381,10 @@ stop_server() { return 0 fi log "Stopping $APP_DISPLAY..." - kill "$(cat "$PID_FILE")" 2>/dev/null || true + pid_directory_is_safe || return 1 + local pid + pid="$(read_pid)" || return 1 + kill "$pid" 2>/dev/null || true rm -f "$PID_FILE" log "Stopped" {%- endif %} @@ -362,12 +421,96 @@ open_browser() { # SYSTEM INTEGRATION — --integ / --disinteg # ---------------------------------------------------------------------------- +path_exists() { + [ -e "$1" ] || [ -L "$1" ] +} + already_integrated() { - [ -f "$DESKTOP_FILE_TARGET" ] || [ -f "$LAUNCHER_TARGET" ] + path_exists "$DESKTOP_FILE_TARGET" || path_exists "$DESKTOP_SHORTCUT_TARGET" || \ + path_exists "$ICON_TARGET" || path_exists "$ICON_MARKER_TARGET" || path_exists "$LAUNCHER_TARGET" +} + +is_managed_install() { + local found_marker="false" target + local marker_targets=("$LAUNCHER_TARGET" "$DESKTOP_FILE_TARGET" "$DESKTOP_SHORTCUT_TARGET") + for target in "${marker_targets[@]}"; do + if ! path_exists "$target"; then + continue + fi + if [ "$target" = "$LAUNCHER_TARGET" ]; then + if [ ! -f "$target" ] || ! grep -Eq '^# GENERATED by launch-scaffolder from .+' "$target" 2>/dev/null; then + return 1 + fi + else + if [ ! -f "$target" ] || ! grep -Fxq '# X-Launch-Scaffolder=launch-scaffolder' "$target" 2>/dev/null; then + return 1 + fi + fi + found_marker="true" + done + if path_exists "$ICON_TARGET"; then + if [ ! -f "$ICON_MARKER_TARGET" ] || ! grep -Fxq 'launch-scaffolder managed icon' "$ICON_MARKER_TARGET" 2>/dev/null; then + return 1 + fi + found_marker="true" + fi + if path_exists "$ICON_MARKER_TARGET"; then + if [ ! -f "$ICON_MARKER_TARGET" ] || ! grep -Fxq 'launch-scaffolder managed icon' "$ICON_MARKER_TARGET" 2>/dev/null; then + return 1 + fi + found_marker="true" + fi + [ "$found_marker" = "true" ] +} + +atomic_write_icon_marker() { + local temp + if ! temp="$(mktemp "${ICON_MARKER_TARGET}.tmp.XXXXXX")"; then + err "cannot create temporary icon ownership marker" + return 1 + fi + if ! printf '%s\n' 'launch-scaffolder managed icon' >"$temp" || \ + ! chmod 0644 "$temp" || ! mv -f "$temp" "$ICON_MARKER_TARGET"; then + rm -f "$temp" + err "cannot atomically write icon ownership marker" + return 1 + fi +} + +atomic_copy() { + local source="$1" target="$2" mode="$3" temp + if ! temp="$(mktemp "${target}.tmp.XXXXXX")"; then + err "cannot create temporary file beside $target" + return 1 + fi + if ! cp "$source" "$temp" || ! chmod "$mode" "$temp" || ! mv -f "$temp" "$target"; then + rm -f "$temp" + err "cannot atomically install $target" + return 1 + fi +} + +desktop_escape() { + local value="$1" + value="${value//\\/\\\\}" + value="${value//$'\n'/\\n}" + value="${value//$'\r'/\\r}" + value="${value//$'\t'/\\t}" + printf '%s' "$value" +} + +desktop_exec_arg() { + local value="$1" + value="${value//\\/\\\\}" + value="${value//\"/\\\"}" + value="${value//\`/\\\`}" + value="${value//\$/\\\$}" + value="${value//%/%%}" + printf '"%s"\n' "$value" } write_linux_desktop_file() { - local target="$1" + local target="$1" temp local icon_name if [ -f "$ICON_TARGET" ]; then icon_name="$APP_NAME" @@ -378,31 +521,38 @@ write_linux_desktop_file() { # keepopen.sh implements the standard fallback ladder: GUI → TUI → # bash-at-repo-root. See launcher-standard.adoc §Fallback Ladder. local keepopen="/var/mnt/eclipse/repos/.desktop-tools/keepopen.sh" - local gui_cmd tui_cmd + local gui_cmd tui_cmd quoted_launcher quoted_log + printf -v quoted_launcher '%q' "$LAUNCHER_TARGET" + printf -v quoted_log '%q' "$LOG_FILE" {% if runtime_kind == "server-url" -%} # server-url: GUI = start server + open browser + tail log (so terminal # stays open); TUI = start-only + follow log; Shell = repo root. - gui_cmd="$LAUNCHER_TARGET --auto && tail -f $LOG_FILE" - tui_cmd="$LAUNCHER_TARGET --start && tail -f $LOG_FILE" + gui_cmd="$quoted_launcher --auto && tail -f $quoted_log" + tui_cmd="$quoted_launcher --start && tail -f $quoted_log" {%- elif runtime_kind == "remote" -%} # remote: GUI = open remote URL; TUI = print status; Shell = repo root. - gui_cmd="$LAUNCHER_TARGET --browser" - tui_cmd="$LAUNCHER_TARGET --status" + gui_cmd="$quoted_launcher --browser" + tui_cmd="$quoted_launcher --status" {%- else -%} # process: GUI = start then tail log so terminal stays open; # TUI = just tail the existing log; Shell = repo root. - gui_cmd="$LAUNCHER_TARGET --start && tail -f $LOG_FILE" - tui_cmd="tail -n 200 -f $LOG_FILE" + gui_cmd="$quoted_launcher --start && tail -f $quoted_log" + tui_cmd="tail -n 200 -f $quoted_log" {%- endif %} - cat > "$target" < "$temp" < Date: Sat, 26 Sep 2026 20:02:35 +0000 Subject: [PATCH 2/8] Fix Rust string escaping and formatting Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- crates/launcher-common/src/integration.rs | 6 +++--- crates/launcher/src/cmd_standard.rs | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/crates/launcher-common/src/integration.rs b/crates/launcher-common/src/integration.rs index 4b2218b..e09d12e 100644 --- a/crates/launcher-common/src/integration.rs +++ b/crates/launcher-common/src/integration.rs @@ -448,9 +448,9 @@ fn desktop_string_value(value: &str) -> String { fn desktop_exec_arg(value: &str) -> String { let escaped = value .replace('\\', "\\\\") - .replace('"', "\\"") - .replace('`', "\\`") - .replace('$', "\\$") + .replace('"', r#"\""#) + .replace('`', r#"\`"#) + .replace('$', r#"\$"#) .replace('%', "%%"); format!("\"{escaped}\"") } diff --git a/crates/launcher/src/cmd_standard.rs b/crates/launcher/src/cmd_standard.rs index 63c915d..6a908ab 100644 --- a/crates/launcher/src/cmd_standard.rs +++ b/crates/launcher/src/cmd_standard.rs @@ -40,7 +40,8 @@ fn show(standard_path: Option<&Path>) -> Result<()> { BAKED_STANDARD.to_string() } }; - let standard = LauncherStandard::parse(&text).context("validating selected launcher standard")?; + let standard = + LauncherStandard::parse(&text).context("validating selected launcher standard")?; print!("{text}"); tracing::debug!("showed launcher standard version {}", standard.spec_version); Ok(()) @@ -61,8 +62,7 @@ fn validate(standard_path: Option<&Path>) -> Result<()> { let field = modes .field(key) .with_context(|| format!("(required-modes) is missing :{key}"))?; - let entries = field - .str_list(); + let entries = field.str_list(); if entries.is_empty() { anyhow::bail!("(required-modes :{key}) must be a non-empty string list"); } From dcaf3eb2dfbf03020e9234c5bcbcfa138e9f28cc Mon Sep 17 00:00:00 2001 From: hyperpolymath <6759885+hyperpolymath@users.noreply.github.com> Date: Sun, 27 Sep 2026 01:40:40 +0000 Subject: [PATCH 3/8] Fix generated output regressions and CI diagnostics Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- .github/workflows/launcher-artefacts.yml | 4 +++- crates/launcher-common/src/config.rs | 8 +++++++- crates/launcher-common/src/standard.rs | 2 +- crates/launcher-common/src/template.rs | 2 +- 4 files changed, 12 insertions(+), 4 deletions(-) diff --git a/.github/workflows/launcher-artefacts.yml b/.github/workflows/launcher-artefacts.yml index 6396f8f..ffd6ec4 100644 --- a/.github/workflows/launcher-artefacts.yml +++ b/.github/workflows/launcher-artefacts.yml @@ -111,7 +111,9 @@ jobs: if [ -s "$RUNNER_TEMP/fmt.diff" ]; then echo "rustfmt wants changes ($(wc -l < "$RUNNER_TEMP/fmt.diff") line(s)):" cat "$RUNNER_TEMP/fmt.diff" - annotate "cargo fmt --check drift" "$RUNNER_TEMP/fmt.diff" 80 + # Fit the complete current workspace diff into the GitHub annotation + # so format failures remain actionable even when log archives are unavailable. + annotate "cargo fmt --check drift" "$RUNNER_TEMP/fmt.diff" 240 else echo "✓ cargo fmt --all -- --check is clean" fi diff --git a/crates/launcher-common/src/config.rs b/crates/launcher-common/src/config.rs index d10b088..11738b4 100644 --- a/crates/launcher-common/src/config.rs +++ b/crates/launcher-common/src/config.rs @@ -149,7 +149,13 @@ fn validate_display_value(field: &str, value: &str) -> Result<()> { if value.trim().is_empty() { anyhow::bail!("{field} must not be empty"); } - validate_no_controls(field, value) + // A horizontal tab is representable in the generated DEED and desktop + // metadata (both escape it). Reject every other control character so + // config values cannot inject lines or terminal control sequences. + if value.chars().any(|ch| ch.is_control() && ch != '\t') { + anyhow::bail!("{field} must not contain control characters other than tab"); + } + Ok(()) } fn validate_no_controls(field: &str, value: &str) -> Result<()> { diff --git a/crates/launcher-common/src/standard.rs b/crates/launcher-common/src/standard.rs index f986fa3..1af90f2 100644 --- a/crates/launcher-common/src/standard.rs +++ b/crates/launcher-common/src/standard.rs @@ -73,7 +73,7 @@ impl LauncherStandard { if looks_like_the_old_toml_format(text) { "this looks like the retired TOML/A2ML launcher standard. The launcher \ standard is now a praxis DEED (`launcher-standard_praxis.deed`, owner \ - ruling D73-C); see hyperpolymath/standards#837" + ruling D73-C); see hyperpolymath/standards#960" } else { "standard is not a valid deed" } diff --git a/crates/launcher-common/src/template.rs b/crates/launcher-common/src/template.rs index 1e05288..d3393ff 100644 --- a/crates/launcher-common/src/template.rs +++ b/crates/launcher-common/src/template.rs @@ -748,7 +748,7 @@ mod tests { !script.contains("APP_PORT="), "a launcher with an explicit [runtime].url must not also state the port" ); - assert!(script.contains("URL=\"http://localhost:4010\"")); + assert!(script.contains("URL='http://localhost:4010'")); } // --------------------------------------------------------------- From e2122752fc8c51a4bad0c1710ddf00a585bb248c Mon Sep 17 00:00:00 2001 From: hyperpolymath <6759885+hyperpolymath@users.noreply.github.com> Date: Sun, 27 Sep 2026 01:43:05 +0000 Subject: [PATCH 4/8] Make CI format and fixture drift diagnostics retrievable Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- .github/workflows/launcher-artefacts.yml | 39 ++++++++++++++++++++++-- 1 file changed, 36 insertions(+), 3 deletions(-) diff --git a/.github/workflows/launcher-artefacts.yml b/.github/workflows/launcher-artefacts.yml index ffd6ec4..859c5f7 100644 --- a/.github/workflows/launcher-artefacts.yml +++ b/.github/workflows/launcher-artefacts.yml @@ -111,9 +111,12 @@ jobs: if [ -s "$RUNNER_TEMP/fmt.diff" ]; then echo "rustfmt wants changes ($(wc -l < "$RUNNER_TEMP/fmt.diff") line(s)):" cat "$RUNNER_TEMP/fmt.diff" - # Fit the complete current workspace diff into the GitHub annotation - # so format failures remain actionable even when log archives are unavailable. - annotate "cargo fmt --check drift" "$RUNNER_TEMP/fmt.diff" 240 + # GitHub truncates large annotations. Split the diff so every + # rustfmt hunk remains actionable even when log archives are unavailable. + split -l 35 "$RUNNER_TEMP/fmt.diff" "$RUNNER_TEMP/fmt-part-" + for part in "$RUNNER_TEMP"/fmt-part-*; do + annotate "cargo fmt --check drift ${part##*-}" "$part" 35 + done else echo "✓ cargo fmt --all -- --check is clean" fi @@ -131,6 +134,36 @@ jobs: fi exit "$STATUS" + - name: Capture the fresh mint when the committed fixture drifts + shell: bash + run: | + set +e + set -uo pipefail + OUT="$RUNNER_TEMP/recovery" + mkdir -p "$OUT" + ./target/debug/launch-scaffolder mint "$FIXTURE_CONFIG" --stdout \ + | sed -E 's|^CONFIG_FILE=.*|CONFIG_FILE=""|' > "$OUT/current.sh" + STATUS="${PIPESTATUS[0]}" + if [ "$STATUS" -ne 0 ]; then + echo "::error title=mint failed::launch-scaffolder mint exited $STATUS" + exit "$STATUS" + fi + if ! cmp -s "$FIXTURE_SCRIPT" "$OUT/current.sh"; then + # Chunk the exact generated bytes into small API-visible notices so + # a stale fixture can be re-minted without relying on log downloads. + python3 - "$OUT/current.sh" <<'PYTHON' + import base64 + import pathlib + import sys + payload = base64.b64encode(pathlib.Path(sys.argv[1]).read_bytes()).decode() + size = 3000 + parts = [payload[i:i + size] for i in range(0, len(payload), size)] + for index, part in enumerate(parts, 1): + print(f"::notice title=minted-fixture-{index:02d}-of-{len(parts):02d}::{part}") + PYTHON + fi + exit 0 + - name: Run the suite and count what actually ran shell: bash run: | From 1014ae1a19685e3e58223a18bf566564fdd63759 Mon Sep 17 00:00:00 2001 From: hyperpolymath <6759885+hyperpolymath@users.noreply.github.com> Date: Sun, 27 Sep 2026 01:45:12 +0000 Subject: [PATCH 5/8] Apply rustfmt fixes from CI Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- .github/workflows/launcher-artefacts.yml | 2 +- crates/launcher-common/src/config.rs | 15 +++++++---- crates/launcher-common/src/fs_utils.rs | 25 ++++++++++++++---- crates/launcher-common/src/integration.rs | 32 ++++++++++++++++++----- crates/launcher-common/src/standard.rs | 4 ++- crates/launcher-common/src/template.rs | 7 ++--- 6 files changed, 62 insertions(+), 23 deletions(-) diff --git a/.github/workflows/launcher-artefacts.yml b/.github/workflows/launcher-artefacts.yml index 859c5f7..2784418 100644 --- a/.github/workflows/launcher-artefacts.yml +++ b/.github/workflows/launcher-artefacts.yml @@ -156,7 +156,7 @@ jobs: import pathlib import sys payload = base64.b64encode(pathlib.Path(sys.argv[1]).read_bytes()).decode() - size = 3000 + size = 7000 parts = [payload[i:i + size] for i in range(0, len(payload), size)] for index, part in enumerate(parts, 1): print(f"::notice title=minted-fixture-{index:02d}-of-{len(parts):02d}::{part}") diff --git a/crates/launcher-common/src/config.rs b/crates/launcher-common/src/config.rs index 11738b4..24dc087 100644 --- a/crates/launcher-common/src/config.rs +++ b/crates/launcher-common/src/config.rs @@ -172,11 +172,12 @@ fn validate_url(url: &str) -> Result<()> { .or_else(|| url.strip_prefix("https://")) .map(|rest| rest.split(['/', '?', '#']).next().unwrap_or_default()); if url.chars().any(char::is_whitespace) - || authority.is_none_or(|host| { - host.is_empty() || host.starts_with(':') || host.contains('@') - }) + || authority + .is_none_or(|host| host.is_empty() || host.starts_with(':') || host.contains('@')) { - anyhow::bail!("runtime.url must be an absolute HTTP(S) URL with a host, no credentials, and no whitespace"); + anyhow::bail!( + "runtime.url must be an absolute HTTP(S) URL with a host, no credentials, and no whitespace" + ); } Ok(()) } @@ -363,7 +364,11 @@ mod tests { #[test] fn rejects_http_urls_without_a_host_or_with_credentials() { - for url in ["https:///path", "http://?query=1", "https://user@example.test/"] { + for url in [ + "https:///path", + "http://?query=1", + "https://user@example.test/", + ] { let txt = format!( r#" [project] diff --git a/crates/launcher-common/src/fs_utils.rs b/crates/launcher-common/src/fs_utils.rs index 04ea09d..6155610 100644 --- a/crates/launcher-common/src/fs_utils.rs +++ b/crates/launcher-common/src/fs_utils.rs @@ -70,7 +70,9 @@ fn write_atomic_impl(path: &Path, contents: &[u8], mode: Option) -> Result< if let Some(mode) = mode { use std::os::unix::fs::PermissionsExt; file.set_permissions(fs::Permissions::from_mode(mode)) - .with_context(|| format!("setting permissions on {}", temp.display()))?; + .with_context(|| { + format!("setting permissions on {}", temp.display()) + })?; } drop(file); fs::rename(&temp, path).with_context(|| { @@ -94,7 +96,10 @@ fn write_atomic_impl(path: &Path, contents: &[u8], mode: Option) -> Result< } } - anyhow::bail!("could not allocate a unique temporary directory beside {}", path.display()) + anyhow::bail!( + "could not allocate a unique temporary directory beside {}", + path.display() + ) } /// Read the current mode when possible, otherwise use `fallback`. @@ -134,9 +139,16 @@ mod tests { #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; - assert_eq!(fs::metadata(&target).unwrap().permissions().mode() & 0o777, 0o640); + assert_eq!( + fs::metadata(&target).unwrap().permissions().mode() & 0o777, + 0o640 + ); } - assert_eq!(fs::read_dir(&dir).unwrap().count(), 1, "temporary file was left behind"); + assert_eq!( + fs::read_dir(&dir).unwrap().count(), + 1, + "temporary file was left behind" + ); fs::remove_dir_all(dir).unwrap(); } @@ -153,7 +165,10 @@ mod tests { #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; - assert_eq!(fs::metadata(&target).unwrap().permissions().mode() & 0o111, 0); + assert_eq!( + fs::metadata(&target).unwrap().permissions().mode() & 0o111, + 0 + ); } fs::remove_dir_all(dir).unwrap(); } diff --git a/crates/launcher-common/src/integration.rs b/crates/launcher-common/src/integration.rs index e09d12e..89e8638 100644 --- a/crates/launcher-common/src/integration.rs +++ b/crates/launcher-common/src/integration.rs @@ -214,7 +214,10 @@ pub fn integ(config: &LauncherConfig, script_path: &Path, opts: &IntegOpts) -> R 0o644, ) .with_context(|| { - format!("marking managed icon at {}", paths.icon_marker_target.display()) + format!( + "marking managed icon at {}", + paths.icon_marker_target.display() + ) })?; report .actions @@ -392,9 +395,18 @@ fn path_exists(path: &Path) -> bool { fn is_managed_install(paths: &InstallPaths) -> bool { let mut found_marker = false; for (path, marker) in [ - (&paths.launcher_target, "# GENERATED by launch-scaffolder from "), - (&paths.desktop_file_target, "# X-Launch-Scaffolder=launch-scaffolder"), - (&paths.desktop_shortcut_target, "# X-Launch-Scaffolder=launch-scaffolder"), + ( + &paths.launcher_target, + "# GENERATED by launch-scaffolder from ", + ), + ( + &paths.desktop_file_target, + "# X-Launch-Scaffolder=launch-scaffolder", + ), + ( + &paths.desktop_shortcut_target, + "# X-Launch-Scaffolder=launch-scaffolder", + ), ] { if !path_exists(path) { continue; @@ -555,7 +567,11 @@ mod tests { icon_marker_target: dir.join("app.png.launch-scaffolder-managed"), launcher_target: dir.join("app-launcher"), }; - fs::write(&paths.launcher_target, "# GENERATED by launch-scaffolder from test\n").unwrap(); + fs::write( + &paths.launcher_target, + "# GENERATED by launch-scaffolder from test\n", + ) + .unwrap(); fs::write( &paths.desktop_file_target, concat!( @@ -575,7 +591,11 @@ mod tests { fs::write(&paths.icon_target, b"not a real png").unwrap(); assert!(!is_managed_install(&paths)); - fs::write(&paths.icon_marker_target, "launch-scaffolder managed icon\n").unwrap(); + fs::write( + &paths.icon_marker_target, + "launch-scaffolder managed icon\n", + ) + .unwrap(); assert!(is_managed_install(&paths)); fs::remove_dir_all(dir).unwrap(); } diff --git a/crates/launcher-common/src/standard.rs b/crates/launcher-common/src/standard.rs index 1af90f2..fdbb4c0 100644 --- a/crates/launcher-common/src/standard.rs +++ b/crates/launcher-common/src/standard.rs @@ -116,7 +116,9 @@ impl LauncherStandard { return Some(path.to_path_buf()); } let env = |key: &str| std::env::var(key).ok(); - Self::search_ladder(&env).into_iter().find(|path| path.exists()) + Self::search_ladder(&env) + .into_iter() + .find(|path| path.exists()) } /// Resolve a standard using the documented precedence: diff --git a/crates/launcher-common/src/template.rs b/crates/launcher-common/src/template.rs index d3393ff..63b59a4 100644 --- a/crates/launcher-common/src/template.rs +++ b/crates/launcher-common/src/template.rs @@ -114,9 +114,7 @@ pub fn render( ctx.insert("app_version", app_version); let build_sha_short = std::env::var("LAUNCH_SCAFFOLDER_BUILD_SHA") .ok() - .filter(|sha| { - (7..=40).contains(&sha.len()) && sha.bytes().all(|b| b.is_ascii_hexdigit()) - }) + .filter(|sha| (7..=40).contains(&sha.len()) && sha.bytes().all(|b| b.is_ascii_hexdigit())) .map(|sha| sha[..7].to_ascii_lowercase()) .unwrap_or_else(|| "unknown".to_string()); ctx.insert("build_sha_short", &build_sha_short); @@ -767,8 +765,7 @@ mod tests { /// /// They are shell expressions rather than paths: the launcher runs on the /// user's machine, which is not necessarily the machine it was minted on. - const DEFAULT_PID_LINE: &str = - "PID_FILE=\"${XDG_RUNTIME_DIR:-${XDG_STATE_HOME:-$HOME/.local/state}}/launch-scaffolder/stapeln/server.pid\""; + const DEFAULT_PID_LINE: &str = "PID_FILE=\"${XDG_RUNTIME_DIR:-${XDG_STATE_HOME:-$HOME/.local/state}}/launch-scaffolder/stapeln/server.pid\""; const DEFAULT_LOG_LINE: &str = "LOG_FILE=\"${XDG_STATE_HOME:-$HOME/.local/state}/launch-scaffolder/stapeln/server.log\""; From 737ea81325b2c13f09611bbaed3866a6b9e4855b Mon Sep 17 00:00:00 2001 From: hyperpolymath <6759885+hyperpolymath@users.noreply.github.com> Date: Sun, 27 Sep 2026 01:47:31 +0000 Subject: [PATCH 6/8] Split CI fixture recovery annotations Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- .github/workflows/launcher-artefacts.yml | 57 ++++++++++++++++++++---- 1 file changed, 49 insertions(+), 8 deletions(-) diff --git a/.github/workflows/launcher-artefacts.yml b/.github/workflows/launcher-artefacts.yml index 2784418..b7a43f5 100644 --- a/.github/workflows/launcher-artefacts.yml +++ b/.github/workflows/launcher-artefacts.yml @@ -135,6 +135,7 @@ jobs: exit "$STATUS" - name: Capture the fresh mint when the committed fixture drifts + id: fixture-recovery shell: bash run: | set +e @@ -148,22 +149,62 @@ jobs: echo "::error title=mint failed::launch-scaffolder mint exited $STATUS" exit "$STATUS" fi - if ! cmp -s "$FIXTURE_SCRIPT" "$OUT/current.sh"; then - # Chunk the exact generated bytes into small API-visible notices so - # a stale fixture can be re-minted without relying on log downloads. - python3 - "$OUT/current.sh" <<'PYTHON' + if cmp -s "$FIXTURE_SCRIPT" "$OUT/current.sh"; then + echo "drift=false" >> "$GITHUB_OUTPUT" + else + echo "drift=true" >> "$GITHUB_OUTPUT" + # Prepare small chunks: GitHub truncates each annotation message. + python3 - "$OUT/current.sh" "$OUT/chunks.txt" <<'PYTHON' import base64 import pathlib import sys payload = base64.b64encode(pathlib.Path(sys.argv[1]).read_bytes()).decode() - size = 7000 - parts = [payload[i:i + size] for i in range(0, len(payload), size)] - for index, part in enumerate(parts, 1): - print(f"::notice title=minted-fixture-{index:02d}-of-{len(parts):02d}::{part}") + size = 2500 + chunks = [payload[i:i + size] for i in range(0, len(payload), size)] + pathlib.Path(sys.argv[2]).write_text("\n".join(chunks) + "\n") PYTHON fi exit 0 + - name: Publish fresh-mint payload (parts 1–8) + if: steps.fixture-recovery.outputs.drift == 'true' + shell: bash + run: | + python3 - "$RUNNER_TEMP/recovery/chunks.txt" 0 8 <<'PYTHON' + import pathlib + import sys + chunks = pathlib.Path(sys.argv[1]).read_text().splitlines() + start, stop = int(sys.argv[2]), int(sys.argv[3]) + for index, chunk in enumerate(chunks[start:stop], start + 1): + print(f"::notice title=minted-fixture-{index:02d}-of-{len(chunks):02d}::{chunk}") + PYTHON + + - name: Publish fresh-mint payload (parts 9–16) + if: steps.fixture-recovery.outputs.drift == 'true' + shell: bash + run: | + python3 - "$RUNNER_TEMP/recovery/chunks.txt" 8 16 <<'PYTHON' + import pathlib + import sys + chunks = pathlib.Path(sys.argv[1]).read_text().splitlines() + start, stop = int(sys.argv[2]), int(sys.argv[3]) + for index, chunk in enumerate(chunks[start:stop], start + 1): + print(f"::notice title=minted-fixture-{index:02d}-of-{len(chunks):02d}::{chunk}") + PYTHON + + - name: Publish fresh-mint payload (parts 17–24) + if: steps.fixture-recovery.outputs.drift == 'true' + shell: bash + run: | + python3 - "$RUNNER_TEMP/recovery/chunks.txt" 16 24 <<'PYTHON' + import pathlib + import sys + chunks = pathlib.Path(sys.argv[1]).read_text().splitlines() + start, stop = int(sys.argv[2]), int(sys.argv[3]) + for index, chunk in enumerate(chunks[start:stop], start + 1): + print(f"::notice title=minted-fixture-{index:02d}-of-{len(chunks):02d}::{chunk}") + PYTHON + - name: Run the suite and count what actually ran shell: bash run: | From e8b22489844b37a7ccb21d60ece0af59b9405f29 Mon Sep 17 00:00:00 2001 From: hyperpolymath <6759885+hyperpolymath@users.noreply.github.com> Date: Sun, 27 Sep 2026 01:48:34 +0000 Subject: [PATCH 7/8] Refresh currency-locked minted launcher fixture Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- .github/workflows/launcher-artefacts.yml | 71 ----- ...minted-2026-09-23_stapeln-launcher-deed.sh | 273 +++++++++++++++--- 2 files changed, 226 insertions(+), 118 deletions(-) diff --git a/.github/workflows/launcher-artefacts.yml b/.github/workflows/launcher-artefacts.yml index b7a43f5..427b531 100644 --- a/.github/workflows/launcher-artefacts.yml +++ b/.github/workflows/launcher-artefacts.yml @@ -134,77 +134,6 @@ jobs: fi exit "$STATUS" - - name: Capture the fresh mint when the committed fixture drifts - id: fixture-recovery - shell: bash - run: | - set +e - set -uo pipefail - OUT="$RUNNER_TEMP/recovery" - mkdir -p "$OUT" - ./target/debug/launch-scaffolder mint "$FIXTURE_CONFIG" --stdout \ - | sed -E 's|^CONFIG_FILE=.*|CONFIG_FILE=""|' > "$OUT/current.sh" - STATUS="${PIPESTATUS[0]}" - if [ "$STATUS" -ne 0 ]; then - echo "::error title=mint failed::launch-scaffolder mint exited $STATUS" - exit "$STATUS" - fi - if cmp -s "$FIXTURE_SCRIPT" "$OUT/current.sh"; then - echo "drift=false" >> "$GITHUB_OUTPUT" - else - echo "drift=true" >> "$GITHUB_OUTPUT" - # Prepare small chunks: GitHub truncates each annotation message. - python3 - "$OUT/current.sh" "$OUT/chunks.txt" <<'PYTHON' - import base64 - import pathlib - import sys - payload = base64.b64encode(pathlib.Path(sys.argv[1]).read_bytes()).decode() - size = 2500 - chunks = [payload[i:i + size] for i in range(0, len(payload), size)] - pathlib.Path(sys.argv[2]).write_text("\n".join(chunks) + "\n") - PYTHON - fi - exit 0 - - - name: Publish fresh-mint payload (parts 1–8) - if: steps.fixture-recovery.outputs.drift == 'true' - shell: bash - run: | - python3 - "$RUNNER_TEMP/recovery/chunks.txt" 0 8 <<'PYTHON' - import pathlib - import sys - chunks = pathlib.Path(sys.argv[1]).read_text().splitlines() - start, stop = int(sys.argv[2]), int(sys.argv[3]) - for index, chunk in enumerate(chunks[start:stop], start + 1): - print(f"::notice title=minted-fixture-{index:02d}-of-{len(chunks):02d}::{chunk}") - PYTHON - - - name: Publish fresh-mint payload (parts 9–16) - if: steps.fixture-recovery.outputs.drift == 'true' - shell: bash - run: | - python3 - "$RUNNER_TEMP/recovery/chunks.txt" 8 16 <<'PYTHON' - import pathlib - import sys - chunks = pathlib.Path(sys.argv[1]).read_text().splitlines() - start, stop = int(sys.argv[2]), int(sys.argv[3]) - for index, chunk in enumerate(chunks[start:stop], start + 1): - print(f"::notice title=minted-fixture-{index:02d}-of-{len(chunks):02d}::{chunk}") - PYTHON - - - name: Publish fresh-mint payload (parts 17–24) - if: steps.fixture-recovery.outputs.drift == 'true' - shell: bash - run: | - python3 - "$RUNNER_TEMP/recovery/chunks.txt" 16 24 <<'PYTHON' - import pathlib - import sys - chunks = pathlib.Path(sys.argv[1]).read_text().splitlines() - start, stop = int(sys.argv[2]), int(sys.argv[3]) - for index, chunk in enumerate(chunks[start:stop], start + 1): - print(f"::notice title=minted-fixture-{index:02d}-of-{len(chunks):02d}::{chunk}") - PYTHON - - name: Run the suite and count what actually ran shell: bash run: | diff --git a/crates/launcher-common/tests/fixtures/metadata_block/minted-2026-09-23_stapeln-launcher-deed.sh b/crates/launcher-common/tests/fixtures/metadata_block/minted-2026-09-23_stapeln-launcher-deed.sh index b2ccff6..ddb1cec 100644 --- a/crates/launcher-common/tests/fixtures/metadata_block/minted-2026-09-23_stapeln-launcher-deed.sh +++ b/crates/launcher-common/tests/fixtures/metadata_block/minted-2026-09-23_stapeln-launcher-deed.sh @@ -1,5 +1,6 @@ #!/usr/bin/env bash # SPDX-License-Identifier: MPL-2.0 +# SPDX-License-Identifier: MPL-2.0 # Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) # # @launcher-deed begin @@ -16,7 +17,7 @@ # :standards ("launcher-standard.adoc" # "LM-LA-LIFECYCLE-STANDARD.adoc" # "cross-platform-system-integration-modes")) -# (modes :accepted ("--start" "--stop" "--status" "--browser" "--web" "--auto" "--integ" "--disinteg" "--help")) +# (modes :accepted ("--start" "--stop" "--status" "--browser" "--web" "--auto" "--integ" "--disinteg" "--help" "--version")) # (platforms :supported ("linux" "macos" "windows")) # (lifecycle-phases :covered ("start" "stop" "status" "integ" "disinteg") # :deferred ("install" "uninstall" "update" "backup" "restore" "migrate"))) @@ -37,15 +38,17 @@ set -euo pipefail # CONFIGURATION # ---------------------------------------------------------------------------- -APP_NAME="stapeln" -APP_DISPLAY="Stapeln" -APP_DESC="Stapeln" -APP_CATEGORIES="Utility;" -APP_GENERIC_NAME="Stapeln" -RUNTIME_KIND="server-url" +APP_NAME='stapeln' +APP_DISPLAY='Stapeln' +APP_DESC='Stapeln' +APP_CATEGORIES='Utility;' +APP_GENERIC_NAME='Stapeln' +APP_VERSION='0.1.0' +BUILD_SHA_SHORT='unknown' +RUNTIME_KIND='server-url' -REPO_DIR="/srv/stapeln" -ICON_SOURCE="" +REPO_DIR='/srv/stapeln' +ICON_SOURCE='' # Absolute path back to the per-app `.launcher.a2ml` config that # produced this script. Consumed by the --integ / --disinteg arms when @@ -53,25 +56,47 @@ ICON_SOURCE="" # the Rust implementation instead of running the shell fallback. CONFIG_FILE="" -URL="http://localhost:4010" +URL='http://localhost:4010' WAIT_SECONDS="15" -PID_FILE="${XDG_RUNTIME_DIR:-${XDG_STATE_HOME:-$HOME/.local/state}}/stapeln-server.pid" -LOG_FILE="${XDG_STATE_HOME:-$HOME/.local/state}/stapeln-server.log" +PID_FILE="${XDG_RUNTIME_DIR:-${XDG_STATE_HOME:-$HOME/.local/state}}/launch-scaffolder/stapeln/server.pid" +LOG_FILE="${XDG_STATE_HOME:-$HOME/.local/state}/launch-scaffolder/stapeln/server.log" -# Both defaults live under a per-user XDG directory. Create them 0700 before +# Both defaults live in per-app directories under per-user XDG state. Create +# the default leaves as 0700 before # the first write: a predictable path inside a world-writable directory (the # old /tmp default) let any local user pre-create or symlink the pid file and # steer what this script later killed or removed (Hypatia 82/83, #48). -# `mkdir -p -m` cannot do the job: with -p the mode applies only to the -# deepest directory created, so the chmod is stated separately and applies -# whether or not the directory was just made. +# Default locations use unique per-app directories before chmod. Explicit +# paths are never chmodded: a path such as /tmp must never have its parent +# permissions changed. Every resolved parent is checked before PID/log I/O. ensure_state_dirs() { local pid_dir log_dir pid_dir="$(dirname "$PID_FILE")" log_dir="$(dirname "$LOG_FILE")" mkdir -p "$pid_dir" "$log_dir" - chmod 0700 "$pid_dir" "$log_dir" +chmod 0700 "$pid_dir" +chmod 0700 "$log_dir" + check_private_state_dir "$pid_dir" || return 1 + check_private_state_dir "$log_dir" || return 1 +} + +# Refuse shared or group-writable state locations. This also prevents a +# custom /tmp path from causing chmod on /tmp or exposing a predictable PID +# file to other local users. +check_private_state_dir() { + local dir="$1" mode digits numeric + [[ -O "$dir" ]] || { err "State directory is not owned by this user: $dir"; return 1; } + mode="$(stat -c '%a' "$dir" 2>/dev/null || stat -f '%Lp' "$dir" 2>/dev/null)" || { + err "Cannot inspect state-directory permissions: $dir"; return 1; + } + digits="${mode: -3}" + [[ "$digits" =~ ^[0-7]{3}$ ]] || { err "Cannot inspect state-directory permissions: $dir"; return 1; } + numeric=$((8#$digits)) + if (( numeric & 022 )); then + err "State directory is group/world-writable; choose a private location: $dir" + return 1 + fi } START_COMMAND="" @@ -132,6 +157,7 @@ case "$PLATFORM" in DESKTOP_FILE_TARGET="$APPS_DIR/${APP_NAME}.desktop" DESKTOP_SHORTCUT_TARGET="$DESKTOP_SHORTCUT_DIR/${APP_NAME}.desktop" ICON_TARGET="$ICON_DIR/${APP_NAME}.png" + ICON_MARKER_TARGET="$ICON_TARGET.launch-scaffolder-managed" LAUNCHER_TARGET="$BIN_DIR/${APP_NAME}-launcher" ;; macos) @@ -141,6 +167,7 @@ case "$PLATFORM" in DESKTOP_FILE_TARGET="$APPS_DIR/${APP_DISPLAY}.app" DESKTOP_SHORTCUT_TARGET="$DESKTOP_SHORTCUT_DIR/${APP_DISPLAY}.command" ICON_TARGET="$APPS_DIR/${APP_DISPLAY}.app/Contents/Resources/icon.png" + ICON_MARKER_TARGET="$ICON_TARGET.launch-scaffolder-managed" LAUNCHER_TARGET="$BIN_DIR/${APP_NAME}-launcher" ;; windows) @@ -151,11 +178,12 @@ case "$PLATFORM" in DESKTOP_FILE_TARGET="$START_MENU_DIR/${APP_DISPLAY}.lnk" DESKTOP_SHORTCUT_TARGET="$DESKTOP_SHORTCUT_DIR/${APP_DISPLAY}.lnk" ICON_TARGET="$BIN_DIR/${APP_NAME}.ico" + ICON_MARKER_TARGET="$ICON_TARGET.launch-scaffolder-managed" LAUNCHER_TARGET="$BIN_DIR/${APP_NAME}-launcher.sh" ;; *) APPS_DIR=""; DESKTOP_SHORTCUT_DIR=""; BIN_DIR="$HOME/.local/bin" - DESKTOP_FILE_TARGET=""; DESKTOP_SHORTCUT_TARGET=""; ICON_TARGET="" + DESKTOP_FILE_TARGET=""; DESKTOP_SHORTCUT_TARGET=""; ICON_TARGET=""; ICON_MARKER_TARGET="" LAUNCHER_TARGET="$BIN_DIR/${APP_NAME}-launcher" ;; esac @@ -164,12 +192,34 @@ esac # PROCESS MANAGEMENT # ---------------------------------------------------------------------------- +pid_directory_is_safe() { + local pid_dir + pid_dir="$(dirname "$PID_FILE")" + [ -d "$pid_dir" ] || return 0 + check_private_state_dir "$pid_dir" +} + +read_pid() { + local pid + IFS= read -r pid < "$PID_FILE" || return 1 + if [[ ! "$pid" =~ ^[0-9]{1,10}$ ]] || (( 10#$pid < 2 )); then + err "Invalid PID value in $PID_FILE" + return 1 + fi + printf '%s' "$pid" +} + is_running() { - [ -f "$PID_FILE" ] && kill -0 "$(cat "$PID_FILE")" 2>/dev/null + [ -f "$PID_FILE" ] || return 1 + pid_directory_is_safe || return 1 + local pid + pid="$(read_pid)" || return 1 + kill -0 "$pid" 2>/dev/null } clear_stale_pid() { - if [ -f "$PID_FILE" ] && ! kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then + if [ -f "$PID_FILE" ] && ! is_running; then + warn "Removing stale or invalid PID file" rm -f "$PID_FILE" fi } @@ -191,7 +241,7 @@ start_server() { ensure_state_dirs clear_stale_pid if is_running; then - log "Already running (PID $(cat "$PID_FILE"))" + log "Already running (PID $(read_pid))" return 0 fi @@ -210,7 +260,9 @@ nohup "$START_COMMAND" >"$LOG_FILE" 2>&1 & echo $! > "$PID_FILE" sleep 0.2 - if ! kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then + local started_pid + started_pid="$(read_pid)" || { rm -f "$PID_FILE"; return 1; } + if ! kill -0 "$started_pid" 2>/dev/null; then gui_error "Process exited immediately" "Check $LOG_FILE" rm -f "$PID_FILE" return 1 @@ -227,7 +279,7 @@ Check $LOG_FILE — and try: curl -v $URL" fi return 1 fi - log "Server started (PID $(cat "$PID_FILE")) — $URL" + log "Server started (PID $(read_pid)) — $URL" return 0 } @@ -237,7 +289,10 @@ if ! is_running; then return 0 fi log "Stopping $APP_DISPLAY..." - kill "$(cat "$PID_FILE")" 2>/dev/null || true + pid_directory_is_safe || return 1 + local pid + pid="$(read_pid)" || return 1 + kill "$pid" 2>/dev/null || true rm -f "$PID_FILE" log "Stopped" } @@ -271,12 +326,96 @@ if ! is_running; then # SYSTEM INTEGRATION — --integ / --disinteg # ---------------------------------------------------------------------------- +path_exists() { + [ -e "$1" ] || [ -L "$1" ] +} + already_integrated() { - [ -f "$DESKTOP_FILE_TARGET" ] || [ -f "$LAUNCHER_TARGET" ] + path_exists "$DESKTOP_FILE_TARGET" || path_exists "$DESKTOP_SHORTCUT_TARGET" || \ + path_exists "$ICON_TARGET" || path_exists "$ICON_MARKER_TARGET" || path_exists "$LAUNCHER_TARGET" +} + +is_managed_install() { + local found_marker="false" target + local marker_targets=("$LAUNCHER_TARGET" "$DESKTOP_FILE_TARGET" "$DESKTOP_SHORTCUT_TARGET") + for target in "${marker_targets[@]}"; do + if ! path_exists "$target"; then + continue + fi + if [ "$target" = "$LAUNCHER_TARGET" ]; then + if [ ! -f "$target" ] || ! grep -Eq '^# GENERATED by launch-scaffolder from .+' "$target" 2>/dev/null; then + return 1 + fi + else + if [ ! -f "$target" ] || ! grep -Fxq '# X-Launch-Scaffolder=launch-scaffolder' "$target" 2>/dev/null; then + return 1 + fi + fi + found_marker="true" + done + if path_exists "$ICON_TARGET"; then + if [ ! -f "$ICON_MARKER_TARGET" ] || ! grep -Fxq 'launch-scaffolder managed icon' "$ICON_MARKER_TARGET" 2>/dev/null; then + return 1 + fi + found_marker="true" + fi + if path_exists "$ICON_MARKER_TARGET"; then + if [ ! -f "$ICON_MARKER_TARGET" ] || ! grep -Fxq 'launch-scaffolder managed icon' "$ICON_MARKER_TARGET" 2>/dev/null; then + return 1 + fi + found_marker="true" + fi + [ "$found_marker" = "true" ] +} + +atomic_write_icon_marker() { + local temp + if ! temp="$(mktemp "${ICON_MARKER_TARGET}.tmp.XXXXXX")"; then + err "cannot create temporary icon ownership marker" + return 1 + fi + if ! printf '%s\n' 'launch-scaffolder managed icon' >"$temp" || \ + ! chmod 0644 "$temp" || ! mv -f "$temp" "$ICON_MARKER_TARGET"; then + rm -f "$temp" + err "cannot atomically write icon ownership marker" + return 1 + fi +} + +atomic_copy() { + local source="$1" target="$2" mode="$3" temp + if ! temp="$(mktemp "${target}.tmp.XXXXXX")"; then + err "cannot create temporary file beside $target" + return 1 + fi + if ! cp "$source" "$temp" || ! chmod "$mode" "$temp" || ! mv -f "$temp" "$target"; then + rm -f "$temp" + err "cannot atomically install $target" + return 1 + fi +} + +desktop_escape() { + local value="$1" + value="${value//\\/\\\\}" + value="${value//$'\n'/\\n}" + value="${value//$'\r'/\\r}" + value="${value//$'\t'/\\t}" + printf '%s' "$value" +} + +desktop_exec_arg() { + local value="$1" + value="${value//\\/\\\\}" + value="${value//\"/\\\"}" + value="${value//\`/\\\`}" + value="${value//\$/\\\$}" + value="${value//%/%%}" + printf '"%s"\n' "$value" } write_linux_desktop_file() { - local target="$1" + local target="$1" temp local icon_name if [ -f "$ICON_TARGET" ]; then icon_name="$APP_NAME" @@ -287,20 +426,27 @@ write_linux_desktop_file() { # keepopen.sh implements the standard fallback ladder: GUI → TUI → # bash-at-repo-root. See launcher-standard.adoc §Fallback Ladder. local keepopen="/var/mnt/eclipse/repos/.desktop-tools/keepopen.sh" - local gui_cmd tui_cmd + local gui_cmd tui_cmd quoted_launcher quoted_log + printf -v quoted_launcher '%q' "$LAUNCHER_TARGET" + printf -v quoted_log '%q' "$LOG_FILE" # server-url: GUI = start server + open browser + tail log (so terminal # stays open); TUI = start-only + follow log; Shell = repo root. - gui_cmd="$LAUNCHER_TARGET --auto && tail -f $LOG_FILE" - tui_cmd="$LAUNCHER_TARGET --start && tail -f $LOG_FILE" + gui_cmd="$quoted_launcher --auto && tail -f $quoted_log" + tui_cmd="$quoted_launcher --start && tail -f $quoted_log" - cat > "$target" < "$temp" < Date: Sun, 27 Sep 2026 01:49:47 +0000 Subject: [PATCH 8/8] Normalize minted config placeholder to shell quotes Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- .github/workflows/launcher-artefacts.yml | 2 +- .../launcher-common/tests/fixtures/metadata_block/README.adoc | 2 +- .../metadata_block/minted-2026-09-23_stapeln-launcher-deed.sh | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/launcher-artefacts.yml b/.github/workflows/launcher-artefacts.yml index 427b531..d59f513 100644 --- a/.github/workflows/launcher-artefacts.yml +++ b/.github/workflows/launcher-artefacts.yml @@ -196,7 +196,7 @@ jobs: # absolute path of the config that produced the script, which is # checkout-location-specific by design and not a template property. ./target/debug/launch-scaffolder mint "$FIXTURE_CONFIG" --stdout \ - | sed -E 's|^CONFIG_FILE=.*|CONFIG_FILE=""|' \ + | sed -E "s|^CONFIG_FILE=.*|CONFIG_FILE=''|" \ > "$OUT/stapeln-launcher.sh" STATUS="${PIPESTATUS[0]}" if [ "$STATUS" -ne 0 ]; then diff --git a/crates/launcher-common/tests/fixtures/metadata_block/README.adoc b/crates/launcher-common/tests/fixtures/metadata_block/README.adoc index 56cceea..9af1661 100644 --- a/crates/launcher-common/tests/fixtures/metadata_block/README.adoc +++ b/crates/launcher-common/tests/fixtures/metadata_block/README.adoc @@ -70,7 +70,7 @@ cargo build ./target/debug/launch-scaffolder mint \ crates/launcher-common/tests/fixtures/config/stapeln.launcher.fixture.a2ml \ --stdout \ - | sed -E 's|^CONFIG_FILE=.*|CONFIG_FILE=""|' \ + | sed -E "s|^CONFIG_FILE=.*|CONFIG_FILE=''|" \ > crates/launcher-common/tests/fixtures/metadata_block/minted-2026-09-23_stapeln-launcher-deed.sh ---- diff --git a/crates/launcher-common/tests/fixtures/metadata_block/minted-2026-09-23_stapeln-launcher-deed.sh b/crates/launcher-common/tests/fixtures/metadata_block/minted-2026-09-23_stapeln-launcher-deed.sh index ddb1cec..017d215 100644 --- a/crates/launcher-common/tests/fixtures/metadata_block/minted-2026-09-23_stapeln-launcher-deed.sh +++ b/crates/launcher-common/tests/fixtures/metadata_block/minted-2026-09-23_stapeln-launcher-deed.sh @@ -54,7 +54,7 @@ ICON_SOURCE='' # produced this script. Consumed by the --integ / --disinteg arms when # the `launch-scaffolder` binary is on $PATH, so they can delegate to # the Rust implementation instead of running the shell fallback. -CONFIG_FILE="" +CONFIG_FILE='' URL='http://localhost:4010' WAIT_SECONDS="15"