Skip to content

[P3] engine_preflight silently mutates .env — make the write announced, opt-in, and non-destructive #14

@anombyte93

Description

@anombyte93

Problem

Running engine_preflight (the mcp__atlas-engine__engine_preflight MCP tool / engine-preflight CLI subcommand) silently appends keys to the project's .env file. A "preflight"/"doctor"/"check" step is expected to be read-only — it should inspect state, not mutate user files without warning. Today the only way to stop the .env write is the all-or-nothing --no-configure flag, which also disables the wanted .taskmaster/config.json provider setup. The write should be (a) off by default during preflight, (b) separately opt-in from the rest of provider configuration, and (c) loudly announced whenever it does happen.

Current Behavior

engine_preflight is dispatched via cmd_engine_preflightrun_engine_preflight(configure=...) in prd_taskmaster/batch.py. When configure=True (the default; only flipped off by --no-configure) AND a .taskmaster/ project already exists, run_engine_preflight calls run_configure_providers() (batch.py:85-89).

Inside run_configure_providers (prd_taskmaster/providers.py:151-254), when the research role resolves to the local Perplexity-free proxy (_is_local_perplexity_free(...) true — provider in {openai-compatible, perplexity} plus a 127.0.0.1:8765 / localhost:8765 / perplexity-api-free baseURL), it appends two keys to .env via _ensure_env_entry(Path(".env"), ...):

  • OPENAI_COMPATIBLE_API_KEY="local-perplexity-api-free" (providers.py:223-224)
  • PERPLEXITY_API_BASE_URL="<local proxy url>" (providers.py:225-226)

and mirrors both into .env.example if that file exists (providers.py:227-241).

The low-level primitive _ensure_env_entry (prd_taskmaster/lib.py:275-284) opens the file in append mode and writes KEY="value". It is already non-clobbering — guarded by _env_file_has_key (lib.py:268-272), it returns False/no-op if the key already exists (lib.py:277), and it appends a trailing newline safely (lib.py:280-283). So "never overwrite an existing value" is already satisfied.

The genuine defects:

  1. ON by default — the write fires during a normal engine_preflight with no opt-in.
  2. Not separately opt-in — only --no-configure (cli.py:143) disables it, and that also suppresses the wanted config.json writes.
  3. Silent / unannounced — the only signal is a ".env:OPENAI_COMPATIBLE_API_KEY" / ".env:PERPLEXITY_API_BASE_URL" string buried in the changed list inside the returned providers_configured dict. The human-readable summary (batch.py:94-115) never mentions that .env was touched. (Note: the docstring at batch.py:75 even claims the call is "Read-only on a bare directory" — but it is not read-only once a project exists.)

The values written are non-secret (a dummy local-perplexity-api-free key and a localhost URL), and the trigger is narrow, but the mutation being on-by-default and invisible is the issue.

Expected Behavior

engine_preflight should be read-only with respect to the user's .env. It should still detect and report that the research role resolves to the local Perplexity-free proxy, and surface the would-be .env change as a pending/planned action in the human-readable summary, naming the exact keys and the command to run to apply it. The actual .env write should only happen via an explicit, opt-in step (a dedicated flag or the standalone configure-providers subcommand), and when it does run it must print an explicit "Wrote .env: KEY1, KEY2" line. The non-clobbering behavior (_ensure_env_entry skips existing keys) must be preserved.

Files to Touch

  • /home/anombyte/.claude/skills/prd-taskmaster/prd_taskmaster/providers.pyrun_configure_providers (L151-254). Add an apply_env: bool = True parameter. Guard the .env/.env.example write block (L222-241) on apply_env; when apply_env is False, push a ".env:PENDING:OPENAI_COMPATIBLE_API_KEY" / ".env:PENDING:PERPLEXITY_API_BASE_URL" marker into changed instead of writing. PRIMARY change site.
  • /home/anombyte/.claude/skills/prd-taskmaster/prd_taskmaster/batch.pyrun_engine_preflight (L72-126). Call run_configure_providers(apply_env=False) so preflight is read-only on .env. In the summary builder (L94-115), detect .env:PENDING:* markers in providers_configured["changed"] and append a human line naming the keys + the apply command. Also add an env_writes / env_pending field to the returned dict. Thread any new opt-in flag from cmd_engine_preflight (L129-133).
  • /home/anombyte/.claude/skills/prd-taskmaster/prd_taskmaster/cli.py — engine-preflight subparser (L142-143). If using the dedicated-flag approach, register --write-env (or --no-write-env) next to --no-configure and thread args.write_env through cmd_engine_preflight. The standalone configure-providers subcommand (DISPATCH ~L302-309, cmd_configure_providers at providers.py:257) keeps apply_env=True so it performs the real write.
  • /home/anombyte/.claude/skills/prd-taskmaster/prd_taskmaster/lib.py_ensure_env_entry (L275-284), _env_file_has_key (L268-272), _is_local_perplexity_free (L320-334). No change required unless you choose to add logging at this chokepoint; already non-clobbering. Reference only.
  • /home/anombyte/Shade_Gen/Projects/prd-taskmaster-plugin/mcp-server/pipeline.pypreflight (L179-234). NOT the code to change; the stale atlas-go plugin's read-only preflight, included only to disambiguate (it never touches .env). Flag if the team wants to retire this duplicate.

Researched Fix Approaches

1. [Recommended] — Make engine_preflight read-only; move the .env write into the explicit configure step (with announce) (confidence: 86%)

  • Library/Config: stdlib argparse (cli.py); no new dependency. Verified task-master-ai convention: it reads .env only and never auto-writes it.
  • Pattern: plan/preview vs apply separation (Terraform planapply, Ansible --check, clig.dev). A preflight/doctor/check command must be read-only; surface the .env change as a planned action, write it only via the existing standalone configure-providers subcommand.
  • Why: Restores parity with the tool being wrapped — task-master-ai only reads .env, so the wrapper's silent append is behavior the upstream tool does not have. The escape hatch already exists (configure-providers is a separate subcommand), giving the write a natural opt-in home preflight need not trigger.
  • Risk: Behavioral change — callers/MCP hosts that relied on preflight auto-writing .env (so the local-perplexity-free path "just works" after one preflight) now need an explicit apply step. Mitigate by making the preflight summary print the exact command to run. Note: the config.json write at providers.py:244 is also a mutation during preflight, but it is lower-stakes (tool-owned dir, not user .env) and out of scope here.
  • Implementation hint:
# providers.py — run_configure_providers signature + guard
def run_configure_providers(economy: str | None = None, apply_env: bool = True) -> dict:
    ...
    if _is_local_perplexity_free(models.get("research", {})):
        if apply_env:
            if _ensure_env_entry(Path(".env"), "OPENAI_COMPATIBLE_API_KEY", "local-perplexity-api-free"):
                changed.append(".env:OPENAI_COMPATIBLE_API_KEY")
            if _ensure_env_entry(Path(".env"), "PERPLEXITY_API_BASE_URL", local_proxy_url):
                changed.append(".env:PERPLEXITY_API_BASE_URL")
            # ... .env.example mirror unchanged ...
        else:
            changed.append(".env:PENDING:OPENAI_COMPATIBLE_API_KEY")
            changed.append(".env:PENDING:PERPLEXITY_API_BASE_URL")

# batch.py — run_engine_preflight
providers_configured = run_configure_providers(apply_env=False)
...
pending = [m for m in (providers_configured or {}).get("changed", []) if m.startswith(".env:PENDING:")]
if pending:
    keys = ", ".join(m.split(":", 2)[2] for m in pending)
    summary.append(f"Pending .env: {keys} (run `configure-providers` to write)")
# cmd_configure_providers keeps apply_env=True (default) so the standalone command really writes.

2. [Alternative] — Dedicated, independent --write-env / --no-write-env opt-in flag (default OFF), always announce (confidence: 80%)

  • Library/Config: stdlib argparse only. Flag naming verified against clig.dev (--dry-run/-n, --no-<x> negation, explicit --write/--apply for mutations).
  • Pattern: explicit opt-in side-effect flag, orthogonal to the existing --no-configure. Register --write-env (action="store_true", default False) on the engine-preflight subparser; thread args.write_envcmd_engine_preflightrun_engine_preflight(write_env=...)run_configure_providers(apply_env=write_env). Print "Wrote .env: KEY1, KEY2" whenever a write occurs.
  • Why: Directly fixes all three named defects: ON-by-default → OFF, all-or-nothing → its own flag orthogonal to --no-configure (you can still get config.json without the .env append), silent → always announced. Lowest blast radius — config.json behavior untouched.
  • Risk: A purist reading of "preflight must be read-only" dislikes that preflight technically CAN still mutate (when --write-env is passed). Requires the separate MCP host (which shells script.py engine-preflight) to learn the new flag for the write to ever fire from MCP — confirm the host can thread flags, or the write becomes unreachable from MCP.
  • Implementation hint:
# cli.py
p.add_argument("--write-env", action="store_true",
    help="Opt in to appending OPENAI_COMPATIBLE_API_KEY / PERPLEXITY_API_BASE_URL to .env (local Perplexity-free proxy only)")
# cmd_engine_preflight
run_engine_preflight(configure=not args.no_configure, write_env=getattr(args, "write_env", False))
# batch.py summary, after a real write:
summary.append(f"Wrote .env: {', '.join(written_keys)}")

3. [Fallback] — Announce-only: keep auto-write but surface every .env mutation in the summary + a structured env_writes field (confidence: 58%)

  • Library/Config: stdlib only; relies on the existing changed markers at providers.py:224/226/234/241.
  • Pattern: non-destructive-by-virtue-of-already-idempotent + loud logging. No default change, no new flag — batch.py reads providers_configured["changed"], extracts .env:* markers, adds an explicit summary line and an env_writes top-level field.
  • Why: Smallest, lowest-risk diff resolving the single most-cited defect (silent/unannounced). The write is already non-clobbering and idempotent and the values are non-secret, so "never clobber" already holds — only the announcement is missing.
  • Risk: Does NOT satisfy the opt-in requirement; the write stays ON by default during a preflight, which violates the read-only convention and upstream parity. Likely rejected if the intent is "preflight must not write user files at all." Treat as a stopgap or complement to Approach 1/2.
  • Implementation hint:
# batch.py — run_engine_preflight, after computing providers_configured
env_writes = [m for m in (providers_configured or {}).get("changed", []) if m.startswith(".env")]
if env_writes:
    summary.append("Modified " + ", ".join(env_writes))
# add 'env_writes': env_writes to the returned dict

Reference

The wrapped tool task-master-ai (claude-task-master by eyaltoledano) is the decisive reference: it reads API keys EXCLUSIVELY from .env (CLI) or the MCP env block and — verified against its docs and .env.example on main — NEVER auto-writes the user's .env. Its setup/models flow creates only .taskmaster/config.json; keys/baseURLs are documented for the user to add themselves. So Atlas's engine_preflight is MORE intrusive than the tool it wraps. Aligning the wrapper (preflight = read-only; .env writes only via an explicit, announced step) restores parity. Broader CLI convention (clig.dev, Terraform planapply, Ansible --check, AWS --dry-run): doctor/preflight/check commands are read-only, mutations need a distinct apply/--write step or confirmation, and every file write must be announced. NOTE: upstream task-master-ai does NOT document OPENAI_COMPATIBLE_API_KEY (the key the Atlas wrapper writes) — verify what task-master's openai-compatible provider actually reads before relying on that key name.

Sources: task-master-ai .env.example and configuration-advanced docs; clig.dev; Terraform plan/apply; "In Praise of Dry Run" (henrikwarne.com).

Acceptance Criteria

  • Running engine_preflight (default flags) on a project where .taskmaster/config.json resolves research to the local Perplexity-free proxy does NOT append OPENAI_COMPATIBLE_API_KEY or PERPLEXITY_API_BASE_URL to .env — verify .env byte-for-byte unchanged before/after (git diff --exit-code .env).
  • In that same default-flags run, the human-readable summary (and/or a structured env_pending/env_writes field in the returned dict) explicitly names the two keys and tells the operator how to apply them (e.g. references configure-providers or --write-env).
  • The opt-in path (standalone configure-providers, OR engine-preflight --write-env, per chosen approach) DOES write both keys to .env and prints an explicit "Wrote .env: ..." style line naming the keys.
  • Re-running the opt-in write a second time is a no-op (idempotent): _ensure_env_entry still returns False for existing keys, .env is unchanged, and no spurious "Wrote .env" line is emitted.
  • A pre-existing OPENAI_COMPATIBLE_API_KEY or PERPLEXITY_API_BASE_URL with a different value in .env is NEVER overwritten by either path (non-clobber preserved).
  • engine_preflight --no-configure still suppresses ALL provider configuration including the config.json writes (existing behavior unchanged), and the new .env opt-in is orthogonal to it (you can get config.json configuration without the .env write).
  • When apply_env=False/preview, .taskmaster/config.json is still written/updated as before — only the .env mutation is deferred.
  • parse_prd succeeds and writes tasks to a fresh tag when invoked from inside a Claude Code / MCP session after running the read-only engine_preflight followed by the explicit configure step (end-to-end the research path still works once .env is applied).
  • The misleading docstring at batch.py:75 ("Read-only on a bare directory") is updated to accurately reflect that preflight no longer mutates .env.

Complexity: S

Trust Level: HINT (not specification)

The researched approaches above are starting points. Before implementing:

  1. Verify the library/config exists as stated (e.g. task-master models, read .taskmaster/config.json; confirm whether the MCP host that exposes mcp__atlas-engine__engine_preflight can pass CLI flags before choosing Approach 2).
  2. Check that imports/keys match reality (especially whether task-master's openai-compatible provider reads OPENAI_COMPATIBLE_API_KEY).
  3. Try the recommended approach — if it works in 1-2 attempts, use it.
  4. If it fails, do NOT keep retrying — research why, explore alternatives.
  5. The acceptance criteria are the real spec, not the approach.

Metadata

Metadata

Assignees

No one assigned

    Labels

    configProvider / config handlingdxDeveloper experienceenhancementNew feature or requestpreflightPreflight / setup validation

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions