DEV Community

Taylor Wang
Taylor Wang

Posted on

48-Hour Field Notes: The Child Had the Token. PATH Never Left the Parent.

Have you ever watched a child process die with command not found while the parent shell still printed a healthy PATH? I spent forty-eight hours on that mismatch, and every log line kept blaming the token I had just injected. The wrapper looked responsible because it passed credentials explicitly and avoided leaking the rest of my environment. Why would a supposedly clean env dict be the bug, when every review calls that pattern a security win?

Hour 0–8: I Treated PATH as Ambient Air

I started from a generated helper that launched tests on a remote box after a draft of the glue existed. The parent process could see pytest, git, and python3 without any extra thinking on my part. I assumed the child would inherit that lookup path, because that is how a laptop terminal usually behaves. Did I actually read subprocess.run that morning, or did I only trust the call signature?

# labeled reconstruction of the first wrapper I ran
import os
import subprocess

token = os.environ.get("API_TOKEN", "")
completed = subprocess.run(
    ["pytest", "-q"],
    env={"API_TOKEN": token, "PYTHONUNBUFFERED": "1"},
    capture_output=True,
    text=True,
)
print("returncode", completed.returncode)
print(completed.stderr)
Enter fullscreen mode Exit fullscreen mode

That snippet appeared to work on the laptop because the editor invoked the file with an absolute interpreter path. The child never needed PATH once I later flailed into sys.executable -m pytest and stopped seeing the original error. I thought I had fixed a packaging issue. I had only hidden a PATH issue behind one absolute binary.

Hour 8–24: The Clean Server Made the Lie Obvious

The failure came back as soon as the same wrapper ran away from my IDE. I wanted a host that did not inherit my shell profile, my direnv hooks, or a user-level bin directory. Disclosure: This article was prepared as part of MonkeyCode's product outreach. I used MonkeyCode's free server option as that second machine, and free model access only to review the wrapper I already intended to run.

What broke was not the token. The child printed pytest: not found and, on a later probe, an empty PATH. Have you looked at os.environ right before a subprocess call and assumed the child would see the same mapping? Python does not merge your dict with the parent environment. A non-None env= replaces the mapping, including PATH, HOME, LANG, and the locale variables you forgot you needed.

# probe: run this on the parent, then inside the child
import os
import subprocess
import sys

print("parent PATH", os.environ.get("PATH"))
print("parent exe", sys.executable)

probe = r"""
import os, shutil, sys
print("child PATH", repr(os.environ.get("PATH")))
print("child which pytest", shutil.which("pytest"))
print("child which python3", shutil.which("python3"))
print("child exe", sys.executable)
"""

completed = subprocess.run(
    [sys.executable, "-c", probe],
    env={"API_TOKEN": os.environ.get("API_TOKEN", ""),
         "PYTHONUNBUFFERED": "1"},
    capture_output=True,
    text=True,
    check=False,
)
print("child output")
print(completed.stdout or completed.stderr)
Enter fullscreen mode Exit fullscreen mode

On my laptop the parent PATH was a long, friendly string. On the free server the parent PATH was already short, and the child PATH became None. That is the moment the forty-eight hours started to make sense. The token was present. The resolver had nothing to search.

Commands I Actually Ran

I kept a small checklist so I would stop guessing between “missing package” and “missing PATH”.

  1. Print the parent environment keys before the call: python3 -c "import os; print(sorted(os.environ))".
  2. Run the probe script above with the same env= dict the wrapper used.
  3. Compare shutil.which("pytest") in the parent against the same call inside the child.
  4. Re-run with env=None once, only on a throwaway host, to see whether inheritance restores the binary.
  5. Only then decide whether the fix is os.environ.copy(), an allowlist, or an absolute argv.

If step 3 is a path in the parent and None in the child, stop blaming pip. You overwrote the lookup path.

Hour 24–48: The Fix Was a Copy, Then an Allowlist

The first repair was boring, which is usually a good sign in field notes. Copy the parent mapping, then set the extra keys you actually need. Do not construct a two-key dict and hope the operating system invents PATH for you.

import os
import subprocess
import sys

def run_pytest_with_token() -> subprocess.CompletedProcess[str]:
    env = os.environ.copy()
    token = env.get("API_TOKEN", "")
    if not token:
        raise RuntimeError("API_TOKEN is missing in the parent environment")
    env["API_TOKEN"] = token
    env["PYTHONUNBUFFERED"] = "1"
    return subprocess.run(
        [sys.executable, "-m", "pytest", "-q"],
        env=env,
        capture_output=True,
        text=True,
        check=False,
    )
Enter fullscreen mode Exit fullscreen mode

Why keep sys.executable -m pytest after restoring PATH? Absolute interpreters still protect you when two Pythons share a host. Restored PATH protects the tools that are not Python, like git, node, or a vendor CLI the tests shell out to. Do you really want the next failure to be git: not found during a coverage upload?

Allowlist When Copying Feels Too Wide

Copying everything is the correct default for local debugging. It is not always the correct default for a shared runner. If you need a narrower mapping, copy first, then drop keys you can name.

import os
from collections.abc import Mapping

KEEP_PREFIXES = ("PATH", "HOME", "LANG", "LC_", "SSL", "REQUESTS", "PYTHON", "VIRTUAL_ENV", "TMPDIR", "TEMP", "TMP")
DROP_EXACT = {"API_TOKEN", "AWS_SECRET_ACCESS_KEY", "OPENAI_API_KEY"}

def child_env(extra: Mapping[str, str]) -> dict[str, str]:
    env: dict[str, str] = {}
    for key, value in os.environ.items():
        if key in DROP_EXACT:
            continue
        if key == "PATH" or key.startswith(KEEP_PREFIXES):
            env[key] = value
    env.update(extra)
    if "PATH" not in env:
        raise RuntimeError("refusing to spawn a child without PATH")
    return env
Enter fullscreen mode Exit fullscreen mode

That helper is a proposal you can run as-is, but you should still print the resulting keys on the target host. Windows uses Path in some contexts and PATH in others, and a prefix list written on Unix will not save you there. Did your wrapper ever print sorted(env) on the machine that actually failed? Mine did not, for far too long.

Reproducible Artifact: A Self-Test You Can Keep

I wanted one file that fails on the broken pattern and passes on the copy. Save this as test_child_path.py and run it with python3 test_child_path.py.

"""Self-test: replacing env must not drop PATH."""
from __future__ import annotations

import os
import subprocess
import sys
import unittest

PROBE = (
    "import os, shutil, sys;"
    "print(os.environ.get('PATH') or '');"
    "print(shutil.which('python3') or shutil.which('python') or '');"
    "print(sys.executable)"
)


def spawn(env: dict[str, str] | None) -> list[str]:
    completed = subprocess.run(
        [sys.executable, "-c", PROBE],
        env=env,
        capture_output=True,
        text=True,
        check=True,
    )
    return completed.stdout.splitlines()


class ChildPathTests(unittest.TestCase):
    def test_replaced_env_without_path_hides_lookup(self) -> None:
        lines = spawn({"PYTHONUNBUFFERED": "1", "API_TOKEN": "redacted"})
        child_path, child_which, _ = lines
        self.assertEqual(child_path, "")
        self.assertEqual(child_which, "")

    def test_copied_env_keeps_lookup(self) -> None:
        env = os.environ.copy()
        env["PYTHONUNBUFFERED"] = "1"
        env["API_TOKEN"] = "redacted"
        lines = spawn(env)
        child_path, child_which, child_exe = lines
        self.assertTrue(child_path, "copied env lost PATH")
        self.assertTrue(child_which or child_exe, "copied env lost an interpreter")

    def test_argv0_absolute_still_runs_when_path_missing(self) -> None:
        completed = subprocess.run(
            [sys.executable, "-c", "print('alive')"],
            env={"PYTHONUNBUFFERED": "1"},
            capture_output=True,
            text=True,
            check=True,
        )
        self.assertEqual(completed.stdout.strip(), "alive")


if __name__ == "__main__":
    unittest.main()
Enter fullscreen mode Exit fullscreen mode

The third test is the trap that burned hours eight through sixteen. An absolute sys.executable still starts, so you conclude the environment is fine. Then a nested pytest hook shells out to git, and the failure looks like a missing system package. It is still the same env= replacement.

Decision Table I Wish I Had at Hour 2

What you pass to env= Child PATH pytest on argv Nested git call Use when
None (default) Inherited Works if parent can find it Works if parent can find it Interactive debugging only
{"API_TOKEN": token} Missing Fails unless argv is absolute Fails Almost never
os.environ.copy() plus extras Kept Works Works Default repair
Allowlist that includes PATH Kept, narrower Works Works if git stays on PATH Shared runners
Allowlist that forgets PATH Missing Hidden if argv is absolute Fails later The bug, again

What I Would Repeat

I would still generate a wrapper, because typing the same subprocess block by hand is how I skip the probe. I would not trust the first draft of any env= literal, including one I wrote myself after a long night. I would run the self-test on a host that is not my laptop before I call the wrapper “done”.

Would I use a model to enumerate the keys a child actually needs? Yes, as a review pass, not as an authority. The free model access was useful when I pasted the wrapper and asked which keys pytest and git typically require. The answer is still a hypothesis until the probe prints PATH on the free server. Local green tests did not settle that question.

Limitations, Stated Plainly

This workflow does not fix a missing package, a wrong interpreter, or a virtualenv that was never activated. Copying os.environ can leak tokens you meant to withhold, which is the original reason the two-key dict looked virtuous. The allowlist is easy to get wrong on Windows, and it will not recreate PATH if the parent process already lost it. The self-test assumes sys.executable is a real interpreter, not a stub launcher that depends on extra keys.

Who should not use this approach? Anyone shipping a production secret-handling layer from a blog snippet, anyone without permission to run commands on the remote host, and anyone who needs a guaranteed merge of parent and child environments from the operating system. Python will not merge for you. If your threat model forbids inheriting the parent mapping, you must name PATH on purpose and keep a test that fails when it disappears.

I would repeat the probe, the copy, and the nested-tool check. I would not repeat the forty-eight hours of blaming the token. If you already have a spare host, including MonkeyCode's free server option, run test_child_path.py there before you trust a wrapper that sets env=.

Top comments (0)