Skip to content

Fix three fabricated-result bugs, close the RCE, and overhaul the documentation - #139

Merged
jeremymanning merged 68 commits into
masterfrom
fix/remaining-issues-sweep
Aug 19, 2026
Merged

Fix three fabricated-result bugs, close the RCE, and overhaul the documentation#139
jeremymanning merged 68 commits into
masterfrom
fix/remaining-issues-sweep

Conversation

@jeremymanning

Copy link
Copy Markdown
Member

What this is

A sweep of every open issue, plus a documentation overhaul, plus the defects
that turned up while doing both. 52 commits.

The headline is not the issue count. It is that three separate code paths
were returning fabricated answers instead of running your function
, all
reachable by default, and none of them detectable by the test suite as it
stood.

The fabricated-result bugs

Where What you got instead of your answer
create_simple_subprocess_fallback the literal string "Function execution completed"
_attempt_client_side_gpu_parallelization traces of random 100×100 matrices
detect_loops range fallback a tenth of the work

Each was silent — no error, no warning, a plausible-looking value.

1. The subprocess stub. When a function was classed "complex" and flattening
failed, _execute_single substituted a closure whose entire remote body was
result = "Function execution completed". Reproduced:

REAL ANSWER      : 5
complexity_score : 999 | is_complex: True
flatten success  : False
WHAT CLUSTRIX RUNS -> 'Function execution completed'

analyze_function_complexity returned 999 / is_complex=True whenever it could
not read the source — so flattening was attempted precisely when it could not
work, and any function with one nested helper qualified.

2. GPU parallelization. auto_gpu_parallel defaulted to True and triggered
on any host reporting ≥2 GPUs. The path never called the decorated function: it
ran a fixed torch.randn(100, 100) program per GPU and scraped the matrix trace
out of stdout.

3. Loop ranges. detect_loops fell back to range(10) when it could not
evaluate a range, so a loop over range(n) was chunked as ten iterations:

BEFORE:  detect_loops(range(n)) -> range(0, 10)
AFTER:   detect_loops(range(n)) -> None

It reached that value by calling eval() on text sliced out of your source,
under a comment admitting the approach was dangerous. Now a literal-only reader
that cannot execute anything.

All three are deleted rather than patched, along with the machinery that existed
only to serve them: function_flattening.py, dependency_resolution.py and
gpu_utils.py (2,859 lines). Nothing is lost — serialize_function already
round-trips every case flattening was meant to rescue, verified in a fresh
interpreter with the defining module off sys.path.

Security

Remote-to-local code execution. Results were HMAC-verified before
deserialization; error.pkl was not, and no error.pkl.hmac existed anywhere. A
hostile cluster only had to make the job fail. Demonstrated with a payload
whose __reduce__ called os.system; both read sites were wrapped in
except Exception: pass, so a failed exploit was silent. After:

--- unsigned payload, as a hostile cluster would write it ---
  REFUSED: PayloadAuthenticationError: ... produced a payload with no signature
--- signed with a guessed key ---
  REFUSED: PayloadAuthenticationError: ... failed its integrity check
--- empty key recorded on the caller (the old fail-open path) ---
  REFUSED: PayloadAuthenticationError: No result-signing key is recorded ...

exploit ran: False

Also: verification failed open when no key was recorded; cloud results were
never verified at all; SSH host keys were never checked (AutoAddPolicy at 12
sites); the job script had no shlex.quote anywhere, so remote_work_dir,
module loads and environment variables reached the shell unquoted — two of those
sites inside a python -c " string. Saved configs were world-readable with
credentials in plaintext, and ClusterConfig.__repr__ printed them into any
traceback.

Correctness

  • Environment replication silently dropped a third of your packages. Any
    freeze line containing @ was skipped; with uv on PATH every conda-built
    package renders as name @ file:///.... Measured: 563 installed, 376 returned.
    Now 559, with the six genuinely unreproducible ones (editable installs, git
    checkouts) refused at submit time by name rather than dropped.
  • Loop analysis reported unparallelizable loops as parallelizabletotal += i
    came back with zero dependencies. detect_loops_in_function never dedented its
    source, so every method or closure raised IndentationError into a swallowed
    exception and returned [].
  • Local auto-parallelization never parallelized: it injected a keyword the
    callee could not accept and swallowed the TypeError. The remote path had the
    same defect.
  • Kubernetes reported failed jobs as successful, and decoded results with
    ast.literal_eval on the pod log.
  • The by-value serialization walk missed instance attributes, builtin-container
    subclasses, namespace packages and functools.partial, and degraded silently
    to by-reference at its node cap.

The test suite

BEFORE:  83 failed, 1437 passed, 2 errors
AFTER:   1763 passed, 12 skipped, 0 failed

The last 13 failures were test pollution, not product bugs — every one passed
in isolation. Three nested leaks: reset_config restored 8 of a hundred-odd
fields; it restored fields but not the module binding; and a class-local
reset_config fixture shadowed the autouse one. After that file ran, the live
config still carried 20 drifted fields, and the widget reads the live config.

A safety hole worth naming: most files under tests/real_world/ carried no
real_world marker, so the command CLAUDE.md documented as safe —
pytest tests/ -m "not real_world" — executed them, making real SSH and cloud
calls. 26 tests were leaking. The marker is now applied by path.

Documentation

Rewritten, then reviewed adversarially five times until the reviews came back
clean. New: introduction.rst, quickstart.rst, execution_model.rst,
configuration.rst, limitations.rst, troubleshooting.rst.

BEFORE:  36 examples checked in 5 files, 60 sphinx warnings
AFTER:   168 examples checked in 23 files + docstrings, 0 warnings, 0 failures

The checker had a hand-written file list, so the pages written that day were not
checked at all. It now discovers everything Sphinx publishes. Making that work
required fixing three defects in the checker itself: an example calling
sys.exit() killed it at the third of twenty files and exited 0; an example
dialling a fictitious host hung it indefinitely (paramiko retries through
EINTR, so the in-process alarm was ignored — each file now runs in its own
subprocess); and it demanded every third-party import be installed, so an ML
example importing tensorflow counted as broken docs.

The reviews found what the tooling structurally cannot: the checker runs
blocks, it never compares a comment to what the code did.
# Loop gets parallelized automatically appeared in README, CLAUDE.md, three API pages and
four notebooks — false in every case, and every block passed. docs/notebooks/
turned out to be a stale duplicate tree that five "Open In Colab" badges pointed
into, so a reader clicking through landed in an un-overhauled copy.

What is still not verified

  • PBS and SGE — implemented, share SLURM's environment path, never run
    against a real scheduler.
  • Kubernetes — never run against a real cluster.
  • AWS, GCP, Azure, Lambda VM backends — no cloud job has been shown to run
    end to end.
  • PyPI serves 0.1.1, which predates all of the above fixes. README and the
    install page now say so and give the git install command.

Reproducing

pip install -e ".[dev]"
pytest tests/ -m "not real_world" --ignore=tests/real_world --ignore=tests/integration
python scripts/check_docs_examples.py
python -m sphinx -b html docs/source /tmp/docs

🤖 Generated with Claude Code

https://claude.ai/code/session_012gTBDPK16HUZ3kHQ2QyjuU

jeremymanning and others added 30 commits August 18, 2026 20:27
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012gTBDPK16HUZ3kHQ2QyjuU
The dependabot advisory GHSA-3936-cmfr-pm3m originally matched
pyproject.toml's black==25.1.0, which sat inside the vulnerable range
>=24.3.0,<26.3.1. That pin is already corrected to ==26.3.1.

Commit bf524a4 then added a black>=26.3.1 floor to docs/requirements.txt
on the assumption that black arrived there transitively through the
Jupyter stack. It does not: resolving that file from scratch installs
117 packages and black is not one of them. The floor added a dependency
rather than constraining one, and because '>=' leaves the version
unresolved the dependency graph recorded black with versionInfo: null --
which is why the alert stayed open against docs/requirements.txt after
the real cause was fixed.

Drop the line, and pin setup.py's dev extra to ==26.3.1 so no unbounded
black constraint is left anywhere for the graph to resolve as unknown.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012gTBDPK16HUZ3kHQ2QyjuU
cleanup_test_resources.py and destroy_cluster.py were deleted by b9c836f
("Issue #72: Delete obsolete development scripts") on the false claim that
they were migrated to scripts/aws/ -- that directory never existed. Recover
the real source from git history (b9c836f^) and restore it as
scripts/aws/cleanup_resources.py and scripts/aws/destroy_cluster.py, hardened
per issue #95:

- Default to dry run; require --execute to delete anything.
- Print every resource (type, id, region) before acting, in both modes.
- Only touch resources positively identified as Clustrix-managed, using the
  same clustrix:managed / clustrix:cluster tags and IAM role naming that
  clustrix.kubernetes.aws_provisioner.AWSEKSFromScratchProvisioner applies
  (the original script's VPC cleanup had no such check at all, and its IAM
  role name guesses never matched what the provisioner actually creates).
- Fail loudly with a clear message when AWS credentials are missing, instead
  of silently falling through to boto3's default credential chain.

Add tests/unit/test_aws_cleanup_scripts.py: verifies --help, the
missing-credentials failure path, and argparse defaults via real subprocess
calls and real argparse round trips (no AWS account, no mocked boto3), plus
static (ast-based) proof that every destructive boto3 call is reachable only
through execute_plan() and only when gated behind `if args.execute`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012gTBDPK16HUZ3kHQ2QyjuU
tests/real_world/conftest.py's pytest_collection_modifyitems hook added
skip markers for expensive/visual/dartmouth tests but never applied the
real_world marker itself. 6 of 81 files under tests/real_world/ carried
no @pytest.mark.real_world decorator, so the documented CI-safe command
`pytest tests/ -m "not real_world"` silently collected and ran them --
making real SSH connections and cloud API calls.

The hook now applies pytest.mark.real_world to every item whose path is
under tests/real_world/, closing the hole regardless of whether the file
itself declares the marker. The hook is scoped by path (not applied
unconditionally) because pytest_collection_modifyitems fires once per
session with the full item list, not just items from this directory --
an unscoped version would have marked the entire suite as real_world.

Adds tests/unit/test_billable_and_realworld_isolation.py: runs real
pytest in subprocesses (collect-only) to prove `-m "not real_world"`
now collects zero tests/real_world items, pins the 6 previously-leaking
files individually, guards against the marker leaking onto the rest of
the suite, and adversarially re-checks the tests/integration billable
guard (cwd change, -p no:cacheprovider, --co, direct import) -- no
bypass found; that guard's config.args-based design (not
invocation_params.args) is unchanged.

CLAUDE.md's documented commands (`pytest tests/ -m "not real_world"` and
`pytest tests/real_world/ -m real_world`) are both accurate after this
fix and need no wording change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012gTBDPK16HUZ3kHQ2QyjuU
…ld workflows

- tests.yml: default test job now runs `pytest tests/ -m "not real_world"
  --ignore=tests/real_world --ignore=tests/integration` instead of only
  tests/unit/ (~350 of ~1,532 non-billable tests). Belt-and-braces with the
  real_world marker another agent is auto-applying in
  tests/real_world/conftest.py. Also dropped a `|| true` on the
  integration-test job's pytest step (verified the 7-test selection it
  guards passes cleanly without it).
- fast_ci.yml: removed `continue-on-error: true` from the mypy step. Verified
  clean (`Success: no issues found in 69 source files`) once the dev extra's
  type stub packages (types-PyYAML/requests/paramiko, already declared in
  pyproject.toml) are actually installed -- no clustrix/ changes needed.
- Consolidated the two duplicate real-world-test workflows (hyphen vs
  underscore) into one canonical real-world-tests.yml, matching what
  docs/CREDENTIAL_SETUP.md already documented. Deleted real_world_tests.yml,
  whose jobs depended on fictional infrastructure (Kind clusters, a
  recovery_report.json/performance_results.json nothing produces).
- Replaced the three `if: false` gates with real ones: workflow_dispatch
  (manual) plus a weekly schedule, gated on secret presence via a
  check-secrets job (the `secrets` context is not permitted in job-level
  `if:` -- actionlint caught this). No push/pull_request trigger exists on
  this workflow at all, so a fork PR cannot invoke it under any condition.
  Also fixed a dead reference to a nonexistent scripts/test_real_world_credentials.py.
- Added hf-jobs-integration: a workflow_dispatch/schedule job gated on
  HF_TOKEN that submits a real function through clustrix's HF Jobs backend
  (contextlab namespace, cpu-basic flavor, per the verified #118 config) --
  the first integration-test substrate in this repo that can actually run
  without SSH/cloud credentials.
- Bumped actions/setup-python@v4->v5 and actions/cache@v3->v4 across touched
  files per actionlint (all other actionlint findings and secrets-in-if bugs
  in files I touched are resolved; actionlint exits 0).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012gTBDPK16HUZ3kHQ2QyjuU
.flake8 was sitting in the working tree with unresolved conflict markers
(<<<<<<< Updated upstream / >>>>>>> Stashed changes). flake8 cannot parse
that, so it silently fell back to its defaults -- 79-character lines and
none of the per-file-ignores -- and reported violations the project had
deliberately configured away. The committed version was correct; restore
it. .gitignore and .pre-commit-config.yaml were left in the same
unmerged state and matched HEAD exactly. The corruption came from the
pre-commit hook's stash/restore cycle running against a tree that other
work was modifying at the same time.

tests.yml's flake8 step passed --exit-zero, so the step could not fail
CI no matter what it found, and carried its own --extend-ignore list
that had drifted from .flake8. Drop both; .flake8 is the single source
of truth. Verified: flake8 clustrix/ tests/ scripts/ reports 0 findings
with the restored config.

Also extend both lint steps to cover scripts/, which is already clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012gTBDPK16HUZ3kHQ2QyjuU
executor_scheduler_status.py: the SLURM status check branched on
isinstance(ssh_client, Mock) to skip retry/sacct logic during unit tests.
Replaced with the real condition it was standing in for: whether there is a
live SSH connection at all (ssh_client is None). No currently-passing test
depended on the sniff -- the tests that exercised it were already failing
for unrelated reasons (connection_manager.execute_remote_command has moved
on from what they mock).

notebook_magic_mocks.py: renamed to notebook_magic_fallback.py and stripped
of the ~115 lines of fake ipywidgets classes (_MockDropdown, _MockButton,
etc.). EnhancedClusterConfigWidget.__init__ already refuses to construct
without real IPython+ipywidgets, so those classes' methods were provably
dead code -- nothing ever reached them. ipywidgets is an intentional
optional dependency (see pyproject.toml's `widgets` extra and the
GitHub-Actions-compat test suite that exercises import without it), so a
hard-require was not the right call; instead `widgets` is now a placeholder
that raises a clear ImportError on any attribute access instead of faking
the API. The magics/display/HTML shims that ARE genuinely exercised without
IPython installed (ClusterfyMagics's line magic, etc.) are kept as real,
honestly-degraded implementations.

Also fixed unresolved git-conflict markers left in .gitignore and
.pre-commit-config.yaml (discovered while resolving an unrelated stray
stash during this work); kept the deliberate current content over the
stale stashed alternative in both.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012gTBDPK16HUZ3kHQ2QyjuU
…ct unknown ones

Every paramiko.SSHClient() in the SSH-touching modules used AutoAddPolicy(),
which trusts any host key on first connection with zero verification --
every SSH connection clustrix made was MITM-able. Adds a single shared
helper, clustrix.ssh_security.configure_host_key_policy(), that loads
system + user known_hosts and defaults to rejecting unknown host keys with
an actionable error naming the host and the exact ssh-keyscan command to
fix it. The old insecure behavior is now an explicit, documented opt-in via
ClusterConfig.ssh_host_key_policy="auto_add". All 10 owned call sites
(ssh_utils.py x3, executor_connections.py, filesystem.py, validation.py x2,
cli_credentials.py, kubernetes/lambda_provisioner.py) now call the shared
helper instead of repeating the policy decision. The Lambda Cloud
provisioner, which connects to freshly-booted ephemeral instances with no
prior known_hosts entry, does an explicit ssh-keyscan (reusing
ssh_utils.add_host_key) before each connect attempt as a logged
trust-on-first-use step rather than blanket auto-trust.

Issue #111: Save config files at 0600 and omit secrets by default

ClusterConfig.save_to_file/save_config wrote via plain open(path, "w")
with no mode and no secret exclusion, so any password/token/API key on the
config landed in a 0644 file. Files are now created via os.open() with
mode 0o600 (plus an immediate fchmod so a pre-existing, more permissive
file is tightened before any content is written -- never after). Secret-
bearing fields are omitted by default, determined programmatically from
ClusterConfig's field names via the same regex approach already used in
scripts/verify_cluster_usecases.py (now imported from clustrix.config as
the single source of truth instead of being duplicated). Pass
include_secrets=True to write them anyway.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012gTBDPK16HUZ3kHQ2QyjuU
Two AutoAddPolicy call sites were left behind when the rest moved to
configure_host_key_policy. This closes the notebook widget's
_test_ssh_connectivity, which hands its configuration over as a plain
dict rather than a ClusterConfig -- so the shared helper now reads
either shape, keeping the policy decision in one place instead of
letting the widget invent its own.

Verified: dict {'ssh_host_key_policy':'auto_add'} -> AutoAddPolicy with
the warning; empty dict and None -> RejectUnknownHostKeyPolicy.

executor_cloud.py's site is still open; that file is being rewritten in
parallel and the fix goes in there.

Separately, raise the cloudpickle floor from 2.0.0 to 3.0.0. Under
cloudpickle 2.0.0 a by-value-registered local package that defines a
typing.NamedTuple cannot be loaded back when the module object itself
is in the function's globals -- i.e. the ordinary 'import mypkg;
mypkg.f(x)' idiom:

  cloudpickle 2.0.0
  via_from    dumps   953 bytes  LOAD: OK -> 2
  via_module  dumps  3844 bytes  LOAD: KeyError: '__module__'

  cloudpickle 3.1.1
  via_from    dumps  1079 bytes  LOAD: OK -> 2
  via_module  dumps  4120 bytes  LOAD: OK -> 2

The 'from mypkg import f' spelling happens to work under 2.x because
cloudpickle then embeds only the globals the function actually uses, so
the NamedTuple never enters the payload. That is why this went unnoticed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012gTBDPK16HUZ3kHQ2QyjuU
…tions

_execute_single substituted a different callable for the user's function
whenever analyze_function_complexity reported "complex", and that reporting
was backwards: the analyser's except branch returned complexity_score 999,
is_complex True whenever inspect.getsource failed. So for any REPL, notebook
or exec-created function, clustrix attempted a source rewrite that cannot
work, fell through to create_simple_subprocess_fallback, and ran a hardcoded
subprocess whose entire body was `result = "Function execution completed"`.
That string was returned to the caller as the job's answer, with no error.

- delete create_simple_subprocess_fallback outright; it never ran the user's
  function and cannot ever produce a correct answer
- _execute_single now serialises the function the caller wrote, always. No
  rewrite is substituted, because equivalence of a rewritten function cannot
  be verified without running the user's function. It is also unnecessary:
  serialize_function already pickles by value via dill(recurse=True) /
  cloudpickle, which round-trips nested functions, closures, module globals
  and source-less functions. decorator.py no longer imports the flattener at
  all, so the substitution cannot be reintroduced by accident.
- analyze_function_complexity reports source_available. On the failure branch
  the metrics are None and is_complex is False -- "I could not analyse this"
  is now distinguishable from "I analysed it and it is complex".
- auto_flatten_if_needed no longer reports success: True while handing back
  the original function. It returns explicit flattened/success/reason/strategy
  and skips entirely when there is no source to rewrite. It also no longer
  picks a hoisted helper as the main function: the namespace lookup was a
  substring match, and helpers are named {parent}_{nested}_hoisted.
- #89/#90 TODOs replaced with the reason they are not being implemented:
  flattening has no caller in the execution path, so completing the closure
  and global-variable plumbing would only make an unusable rewriter reachable.

New tests run for real -- no mocks. SubprocessJobRunner is a genuine
implementation of the executor contract that ships the real serialized payload
to a fresh interpreter and executes it. Against the pre-fix code these fail
with: "clustrix returned 'Function execution completed' but the function
computes 42".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012gTBDPK16HUZ3kHQ2QyjuU
The file told anyone reading it to add a cluster type to a ClusterType
enum in config.py. There is no ClusterType anywhere in the package --
cluster_type is a plain str. It located ClusterExecutor in
clustrix/executor.py, which is a 39-line re-export shim; the
implementation is spread across seven executor_*/hf_jobs modules, none
of which were mentioned. It claimed functions defined in a REPL 'cannot
be serialized', which is false and is the belief that produced the
fabricated-result bug: serialization works fine without source, only
the inspect.getsource-based features need it.

It also carried one half of a mocking policy that contradicted
.claude/CLAUDE.md's other half, so developers could cite either and
neither governed. Both are replaced by one stated policy: real
verification first, mocks only as a cost-control stand-in afterwards,
never as a fallback, never inside shipped code, and never a reason to
weaken a failing test.

Added: the two-venv execution model and why every handoff must stay
symmetric; HMAC verification of remote results; host-key verification
via ssh_security.configure_host_key_policy; the billable-test guard and
why it reads config.args; a backend table that says plainly which
backends are actually proven and which are not.

Every factual claim in the new text was checked against the code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012gTBDPK16HUZ3kHQ2QyjuU
Kubernetes (#119): the worker printed `CLUSTRIX_RESULT:{result}` -- the repr
of the result -- and the caller ran it through ast.literal_eval, returning
the repr string when that failed and the whole pod log when no marker was
found. It now writes a base64 pickle plus an HMAC over those bytes, keyed by
a per-job secret passed in CLUSTRIX_RESULT_KEY, and the caller verifies it
before deserializing. check_k8s_job_status no longer answers "completed"
whenever the API call raises: an outcome that cannot be read is an error.
The worker program is now a module-level function, so it can be run
directly and tested without a cluster.

cluster_type "local" (#120): ClusterExecutor had no branch for it and raised
"Unsupported cluster type: local", though the widget offers it. LocalJobManager
in local_executor.py wires it to the existing LocalExecutor.

PBS (#120): submit_pbs_job never set up a remote environment, so its script
activated a virtualenv nothing had created. SLURM and SSH each carried a copy
of the two-venv setup and SGE had only half of it; all four now share
_stage_job_directory and _setup_job_environment.

Placeholder hostnames (#119): azure/gcp/lambda returned cluster_host
"placeholder.<provider>.com" (or "") when they could not read an instance's
address, which surfaced later as an SSH failure against a domain that does
not exist. They now raise, naming the provider, the instance and what could
not be determined.

Provider interface (#119): only Lambda implements create_instance. Submitting
to another provider was accepted and the NotImplementedError then surfaced
inside a background thread. submit_cloud_job now checks the interface and the
authentication up front and refuses with a message naming both.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012gTBDPK16HUZ3kHQ2QyjuU
…opping it

Redacting secrets from saved configs swept environment_variables into
SECRET_FIELDS wholesale, which meant a save/reload cycle silently lost
OMP_NUM_THREADS along with AWS_SECRET_ACCESS_KEY. It also contradicted
the rationale stated directly above it -- derive the secret set from
field names, do not hand-list -- since it was a hand-added exception.

Each entry is now judged on its own key name, so ordinary settings
survive and credentials do not:

  environment_variables  saved   {'OMP_NUM_THREADS','MY_PIPELINE_STAGE',
                                  'AWS_SECRET_ACCESS_KEY','HF_TOKEN'}
                         loaded  {'OMP_NUM_THREADS','MY_PIPELINE_STAGE'}
  plaintext AWS secret on disk: False
  plaintext HF token on disk  : False
  file mode: 0o600

Two fields also matched the pattern without holding a secret:
use_env_password is a boolean flag, and password_env_var holds the NAME
of an environment variable rather than its value. Dropping them broke
the auth-fallback round trip while protecting nothing, so the derivation
now excludes 'use_*' and '*_env_var'.

Two tests asserted the old behaviour; their assertions are rewritten
rather than the code reverted, and two new tests cover the mapping.

Also renamed the credential-shaped test fixtures that made
check_for_secrets report 6 findings on the tree. They were real fixtures,
but the scanner was right to be suspicious of 'hunter2-super-secret' and
'sk-real-looking-secret-abcdef123456'. Renaming them to obviously-fake
values keeps the scanner strict rather than teaching it a suppression
marker that could later hide a real secret.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012gTBDPK16HUZ3kHQ2QyjuU
…atterns tutorials

#124: Fix MIGRATION.md's incorrect `from clustrix import ClusterConfig` (not
re-exported; must be `from clustrix.config import ClusterConfig`). Fix the
sphinx duplicate-object warnings (60 -> 4): filesystem.rst, file_packaging.rst,
and dependency_analysis.rst each had a blanket `automodule:: :members:` PLUS
per-member autoclass/autofunction directives for the same objects; since this
project's autodoc_default_options sets members=True globally, both fired.
Switched those three pages to `currentmodule` + the explicit-directives-only
pattern already used in cost_monitoring.rst. The remaining 4 warnings come
from a docstring in clustrix/notebook_magic_config.py (out of docs/ scope).

#70: Document Kubernetes auto-provisioning (previously undocumented) in
kubernetes_tutorial.rst: local kind-based provisioning (no cloud credentials
needed) and the five cloud providers, each explicitly labeled unverified per
README's existing wording. Documents a real gotcha found while verifying
against the source: `@cluster(provider=...)` does not select the Kubernetes
provisioner -- `configure(k8s_provider=...)` does, and it defaults to "aws".

#96: New docs/source/tutorials/usage_patterns.rst turning issue #96's
deleted-script snippets into verified, runnable patterns. Notes that
@cluster falls back to local execution with no cluster configured, which is
what makes the examples runnable without a real cluster.

#88: Verified clustrix.utils.serialize_function/deserialize_function
round-trip a function whose source is unavailable (prints 5). Narrowed
usage_patterns.rst's description of the REPL limitation accordingly: it's
the source-based features (loop parallelization, GPU-parallel detection,
dependency analysis) that need inspect.getsource(), not serialization
itself. README wording changes reported separately (README.md is out of
this agent's file ownership).

Added scripts/check_docs_examples.py: extracts every Python code block from
the touched docs, executes the ones that don't need external resources for
real (no mocks), and for blocks marked `# cluster-required` checks syntax
plus that every imported name actually exists via importlib/hasattr. Also
fixed two real bugs found while extending this script's coverage to the
pricing docs at the coordinator's request: PRICING_USER_GUIDE.md's
CustomPricingClient example was missing the required abstract method
_fetch_pricing_from_api (verified TypeError without it), and the documented
CostEstimate dataclass had invented fields (provider/hours/region) that
don't exist on the real one in clustrix/cost_monitoring.py.

Removed all documentation of clustrix.pricing_clients.performance_monitor,
.resilience, and .validation_alerts (deleted as orphaned code) from
PRICING_API_REFERENCE.md and PRICING_USER_GUIDE.md, replacing each with
either the real remaining API or an explicit removal note -- no invented
replacement APIs.

36/36 doc code blocks pass scripts/check_docs_examples.py (29 executed for
real, 7 statically verified as needing external resources).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012gTBDPK16HUZ3kHQ2QyjuU
…truth

Four files disagreed about what version this is: pyproject.toml and
setup.py said 0.1.1, clustrix/__init__.py and docs/source/conf.py said
0.1.0. All four now say 0.2.0, the release #127 is about.

The set of supported cluster types was written out separately in
clustrix/cli.py and the widget dropdown, and they had drifted: the CLI
offered slurm/pbs/sge/kubernetes/ssh/local and omitted 'huggingface'
entirely, so a backend that is verified working end to end could not be
selected from the command line. Both now read
config.SUPPORTED_CLUSTER_TYPES. Verified they agree:

  canonical   : ('local','ssh','slurm','pbs','sge','kubernetes','huggingface')
  CLI choices : ['local','ssh','slurm','pbs','sge','kubernetes','huggingface']
  widget      : ('local','ssh','slurm','pbs','sge','kubernetes','huggingface')

Export ClusterConfig from the package root. ClusterExecutor,
ClusterFilesystem and ProfileManager were all exported and it was not,
so the obvious import raised ImportError -- which MIGRATION.md had been
telling users to write.

README's REPL section claimed such functions 'cannot be serialized'.
They can: serialization works from the code object. What is actually
lost is the source-based features -- loop parallelization, GPU-parallel
detection, complexity analysis. Narrowed to say that, since the
overstatement is what justified the flattening detour that fabricated
results.

Last 4 sphinx warnings fixed: autodata pointed at the module that
re-exports DEFAULT_CONFIGS rather than the one that defines it, so
autodoc fell back to dict.__doc__, whose **kwargs and indented body are
not valid RST. Docs now build with zero warnings, down from 60.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012gTBDPK16HUZ3kHQ2QyjuU
D1  get_environment_requirements dropped every `name @ file:///...` line, which
    is how uv renders conda-built packages -- 187 of 563 packages here. It now
    reads installed metadata directly, so uv and pip can no longer produce two
    different answers (and two different _environment_key values) for the same
    machine. Requirements that genuinely cannot be reinstalled remotely
    (editable installs, VCS checkouts, bare egg-info source trees) are no
    longer silently dropped: they are reported, and a payload that reaches into
    one is refused at submit time naming the package.
D2  The walk no longer truncates at a node cap and submits anyway; it finishes,
    or raises WalkTooLargeError.
D3  Instance attributes (__dict__ and __slots__) are now walked.
D4  A project-local class subclassing dict/list/tuple is now followed by type,
    so it travels instead of arriving stripped of its methods.
D5  _is_local_module falls back to __path__, so PEP 420 namespace packages are
    recognised and their children enqueued.
D6  functools.partial and bound methods are followed to their targets.
D7  The unpicklable-object message names the object and where it actually lives
    (closure variable, module-level name, attribute) instead of asserting
    "module level" and listing every local module in the payload.
D8  A remote interpreter is accepted only when its minor version matches the
    local one; payload bytecode does not cross minor versions.
D9  Conda environments are stamped ready only after every install succeeded,
    the reuse check requires that stamp, and no install is wrapped in
    `|| echo 'Failed to install ...'` any more.

The mock-based environment tests that asserted clustrix shells out to pip are
replaced with real ones against the real environment -- faking freeze output is
exactly why the uv/pip divergence went unnoticed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012gTBDPK16HUZ3kHQ2QyjuU
…p the job script being a shell

V1 (critical) result.pkl was HMAC-verified before dill.loads and error.pkl was
   not, so "make the job fail" was a complete bypass: a hostile cluster exits
   non-zero, writes its own error.pkl, and its __reduce__ runs on the
   submitting machine. Both call sites wrapped the load in
   `except Exception: pass`, so a failed attempt was silent. error.pkl is now
   signed with the same per-job key by every worker path and verified on the
   same terms; the refusal is raised outside the try so it cannot be swallowed
   into the text-log fallback.
V2 Cloud results were deserialized with no key generated and no verification at
   all, under a comment claiming the worker wrote them with dill while the same
   file wrote them with pickle.dump. The cloud path now creates its work dir
   0700 with a 0600 key inside, reuses result_signing_lines()/verify_signed_payload(),
   and writes with dill as the caller has always claimed.
V3 Verification failed OPEN when no key was recorded: it warned and loaded
   anyway, so "no key" -- an adopted job id, a cleared table -- was as good as
   a valid signature. Missing key, empty key and untracked job are all refusals.
V4 Every config value reaching the generated job script was pasted in raw.
   Ordinary shell words (job dir, env-var values, pip specs, venv paths,
   interpreter) are shlex.quote()d; places that must stay unquoted (module load
   arguments, #SBATCH/#PBS/#$ directive bodies, export names) are validated
   against a strict allowlist and refused naming the config key.
   pre_execution_commands stay a fragment on purpose.
V5 The signing key stayed in the job's environment while the user's function
   ran, so any dependency could forge a validly tagged result. It is captured
   and popped before user code in every generated program, popped in the HF
   bootstrap before pip runs, and the HF log parser now selects the block that
   verifies rather than the first one printed.
V6 `dill or cloudpickle or pickle` silently fell back to stdlib pickle on dill
   bytes -- the exact failure #121 was filed about. The requirement is now
   stated and the job fails naming the missing package.

Also replaces the last AutoAddPolicy() in the package (executor_cloud) with
configure_host_key_policy().

Regression tests are real: a real directory as the remote host, real signing,
real tampering, and the generated worker programs really executed. 15 of the 21
new authentication tests and 8 of the updated ones fail against the previous
tree.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012gTBDPK16HUZ3kHQ2QyjuU
…essage

Reading installed metadata costs ~0.25s and a submission asks for it several
times, so it is cached keyed on sys.path -- the thing that decides which
distributions are visible, so any change that could change the answer changes
the key.

test_an_unembeddable_module_raises_here_not_there asserted the old text, which
blamed module level for a lock held in a CLOSURE. It now asserts the message
names the closure variable, the function, and the object type.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012gTBDPK16HUZ3kHQ2QyjuU
…c tests

Salvage review of PR #128 (branch epic/test-coverage-90-percent) found its
loop-analysis tests too weak (isinstance(x, list) only) and its "advanced"
suite dependent on API that doesn't exist on master (integrate_with_decorator,
enhanced_dependency_analysis, etc.) -- not ported. Comprehension
auto-parallelization (visit_ListComp/SetComp/DictComp/GeneratorExp, #132's
landmine) was likewise not brought across; a test now pins that down.

While writing real semantic tests against find_parallelizable_loops, found
and fixed four correctness bugs in clustrix/loop_analysis.py:

- DependencyAnalyzer.visit_AugAssign never counted `total` in `total += i`
  as a read (AugAssign targets are Store-only in the AST), so a plain
  reduction accumulator came back with zero dependencies and
  is_parallelizable=True.
- detect_loops_in_function() didn't dedent inspect.getsource() output, so
  any function defined inside a class/closure (one indentation level deep)
  raised IndentationError, silently swallowed, always returning [].
- detect_loops_in_function() called _analyze_for_loop/_analyze_while_loop
  directly via ast.walk() instead of detector.visit(tree), bypassing the
  current_level bookkeeping -- nested_level was -1 for every loop found via
  the public API, making find_parallelizable_loops's nesting-depth filter
  a no-op.
- SafeRangeEvaluator couldn't evaluate a literal negative number (-1 is
  UnaryOp(USub, Constant(1)), not Constant(-1)), so range(10, 0, -1) always
  fell back to range_info=None.

Also found clustrix/dependency_analysis.py's separate, exported
LoopAnalyzer._is_loop_parallelizable() (public via
clustrix.analyze_function_loops) only checked for break/continue/global
despite documenting a "no shared mutable state" criterion -- it approved
both the accumulator and shared-list-append patterns above. Fixed by
reusing loop_analysis.DependencyAnalyzer instead of a second, weaker
implementation.

Added tests/unit/test_loop_analysis_semantics.py: real functions, no mocks,
asserting on actual dependency/parallelizability values -- loop-carried
deps, shared-state mutation, break/continue/return/for-else, nesting
levels, enumerate/zip/dict.items() tuple-target blind spots, range()
variants, and the comprehension-non-detection regression guard.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012gTBDPK16HUZ3kHQ2QyjuU
…lel real

The two TODOs #89 and #90 ask to implement (global-variable extraction in
dependency_resolution.py, closure-variable arguments in function_flattening.py)
live inside code that has never produced a runnable output for any input tried.
The advanced flattener emits `import <name>` for hoisted helpers and builtins;
the basic one dedents the body to column 0, drops `for` headers and prints
instead of returning. Verified live on an ordinary nested-helper function:

    Generated flattened code did not execute: No module named 'helper'
    Generated flattened code did not execute: name 'i' is not defined
    Not flattening compute: advanced flattener produced no usable callable

serialize_function/deserialize_function already handle every case flattening
was meant to rescue. Round-tripped in a fresh interpreter with the defining
module off sys.path: nested helper 45, deep nesting 65, module-level helper 19,
closure 40, exec()-created 5, args+kwargs 21 -- all matching the direct call.
decorator.py no longer reaches for either module. So: delete them.

Removed clustrix/function_flattening.py (1027) and dependency_resolution.py
(445), and the five test files that only ever tested them. Kept and re-pointed
the tests that cover live behaviour: the GPU workflow simulation now proves the
serializer round trip instead of flattening, the tensor01 and cluster GPU tests
keep their real remote execution and lose only the complexity assertions.
clustrix.dependency_analysis (the public analyze_function_dependencies) is a
different module and is untouched.

Also #120 item 2, the same defect class. _create_local_work_chunks injected
`_parallel_<var>` into callees that never declared it, so every chunk raised
TypeError, which _execute_local_parallel swallowed under a blanket except and
converted into a silent sequential re-run -- auto_parallel never parallelized
anything locally and said nothing useful. Chunks are now built only for a
signature that can receive them, and TypeError on the parallel path propagates.

scripts/check_for_secrets.py is a black-only reformat; it was not black-clean
at HEAD and the mandated `black clustrix/ tests/ scripts/` run touches it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012gTBDPK16HUZ3kHQ2QyjuU
There was no CHANGELOG.md anywhere in the repository. This one records
what actually changed, and keeps a standing 'Implemented but unverified'
section so backends that have never been run against real hardware are
never quietly listed as working.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012gTBDPK16HUZ3kHQ2QyjuU
executor_kubernetes.py was rewritten so job status/result collection
raise on unreadable outcomes instead of reporting fake success, and pod
results are now a signed base64 payload (CLUSTRIX_RESULT_B64 +
CLUSTRIX_RESULT_HMAC via decode_signed_result) instead of a bare
"CLUSTRIX_RESULT:<repr>" string run through ast.literal_eval. Also, the
#80 module refactor moved _get_k8s_result/_get_k8s_error_log/
_cleanup_k8s_job off ClusterExecutor onto executor.k8s_manager, and
get_job_status() now requires active_jobs entries to carry a "manager"
key.

- tests/test_kubernetes_integration.py: run the real
  build_worker_program() worker as a subprocess to produce genuine
  signed pod-log output (no hand-written stand-ins for the retired
  format), call executor.k8s_manager.* where ClusterExecutor has no
  shortcut, and tag active_jobs entries with "manager": "kubernetes".
  Removed a dead patch("clustrix.executor.cloudpickle") left over from
  before cloudpickle usage moved into executor_kubernetes.py.
- tests/test_cloud_providers_gcp_real.py: GCPProvider.list_instances(),
  .create_instance(), .is_valid_region()/.is_valid_zone() never existed
  (verified via `git log -S`) -- these assertions were against a
  fabricated API since the tests were added, unrelated to the recent
  rewrite. Rewritten against the real, currently-implemented API:
  list_clusters()/create_compute_instance() now raise "Not authenticated
  with GCP" instead of proceeding with a None client, and region/zone
  behavior is verified against the provider's actual defaults and its
  real (unauthenticated) get_available_regions()/
  get_available_instance_types() fallback lists.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012gTBDPK16HUZ3kHQ2QyjuU
serialize_function contained:

    _ = get_environment_info()  # For compatibility with tests

Shipped code calling a function purely so that a mock in
tests/test_integration.py would be hit, then discarding the result. It
is the same anti-pattern as #116's isinstance(..., Mock) branches, and
it cost a 'pip list' subprocess on every job submission to compute
nothing.

Faking that freeze output is also why the environment-replication bug
survived so long: get_environment_requirements() was dropping 187 of
563 packages and every test that mocked the freeze step still passed.

Also remove the guard that let the remote setup continue after failing
to install dill and cloudpickle. The generated worker now refuses to
fall back to stdlib pickle -- pickle serializes a function by qualified
name and cannot resolve it in a fresh interpreter -- so swallowing that
failure only moved the error to a later and far more confusing point.
pip's own upgrade stays non-fatal: pip's version is not part of the
replicated environment.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012gTBDPK16HUZ3kHQ2QyjuU
…e widget assertions

ProfileManager.__init__ hardcoded config_dir="~/.clustrix/profiles", ignoring
CLUSTRIX_CONFIG_DIR. Every caller that constructs ProfileManager() with no
explicit config_dir -- the widget's default and notebook_magic_core.py's
default -- silently read and wrote a real user's ~/.clustrix, even under
tests/conftest.py's isolate_config_dir fixture. On this machine that had
accumulated 47+ "Current configuration (N)" profiles in ~/.clustrix/profiles/
profiles.yml. Default now resolves via clustrix.config.get_config_dir(),
which honors CLUSTRIX_CONFIG_DIR.

That fix alone did not make every failing assertion correct: several tests
in test_modern_widget_comprehensive.py encoded a ProfileManager/widget shape
that no longer matches the code (single default profile vs. one built-in
template per backend; "clustrix.yml" vs. the deliberate "profiles.yml"
default; "auto"/"~/.ssh/id_rsa" placeholders vs. the active profile's real
ClusterConfig defaults of "pip"/None). Rewrote those assertions to match
current, intentional behavior, and rewrote
test_widget_initialization_with_mock_ipython (renamed to
..._with_real_ipython) to drive the real installed ipywidgets/IPython
instead of the file's MockWidgets shim, per the no-mocking-the-thing-under-
test rule.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012gTBDPK16HUZ3kHQ2QyjuU
The document described a system that has been deleted, and described it
approvingly -- 'complexity-based triggering system works' was never
true. Rather than delete it outright, record why the approach was
abandoned, since the reasoning is the part worth keeping: it explains
what would have to be true before anyone builds this again, and names
the two questions the original never answered.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012gTBDPK16HUZ3kHQ2QyjuU
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012gTBDPK16HUZ3kHQ2QyjuU
22 failures across the four executor test modules, all of them tests that
had been left behind by the split of executor.py into executor_core /
executor_connections / executor_schedulers / executor_kubernetes.

Most were mock theatre in the sense of #117: they patched names the
refactor moved (clustrix.executor.setup_remote_environment,
clustrix.executor.cloudpickle, clustrix.executor.logger) so the patches
were silent no-ops, replaced backward-compatibility aliases nothing calls
any more (_execute_remote_command, _check_job_status, _submit_slurm_job),
fed a Mock a canned string and asserted the string came back. None of them
could fail for a real reason.

Rather than re-point the mocks at the new call graph, these now exercise
real code:

  * cluster_type="local" really runs the function, so submission, status,
    result collection, error logs and the active_jobs["manager"] routing
    are all checked against real execution;
  * create_job_script is pure, so each scheduler's directives are checked
    against real generated output;
  * where a real cluster would be needed, the assertion is on the error
    path -- a submission with no connection must raise and record no job,
    a failed cancellation must not drop the job from tracking;
  * the Kubernetes tests use the real kubernetes client against a real
    kubeconfig file on disk.

Assertions deliberately changed, with the reason recorded in each
docstring:

  * test_execute_command_not_connected expected "Not connected"; the
    shipped message is "SSH client not connected. ...".
  * test_get_job_status_completed/_failed hand-built an active_jobs entry
    with no "manager" key; get_job_status now dispatches on it.
  * test_get_result_success mocked SFTP into writing an unsigned pickle;
    results are HMAC-verified before deserialization now, so an unsigned
    one is refused by design.
  * test_parallel_job_submission passed timeout= to wait_for_result, which
    takes only a job ID.

One test deleted rather than repaired:
test_setup_kubernetes_cloud_manager_exception asserted that a Mock raising
ImportError produced a log line. CloudProviderManager's constructor stores
two attributes and cannot raise, and auto_configure catches its own
exceptions, so that branch is unreachable without a mock.

Verified: 59 passed, 2 skipped across the four modules (was 22 failed, 35
passed, 2 skipped); tests/unit 573 passed; black 26.3.1, flake8 7.3.0 and
mypy all clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012gTBDPK16HUZ3kHQ2QyjuU
…ential_manager suites

All 14 failures and 2 errors (actually 17 failures + 3 errors once the
suite was run fresh) traced to tests written against field/function names
that never existed or were renamed, plus fixtures scoped to the wrong test
class. Two real bugs turned up in clustrix/auth_fallbacks.py along the way
and are fixed here too: requires_password_fallback() crashed with
AttributeError whenever a key-setup result explicitly set "error": None
(the normal case from ssh_utils.setup_ssh_keys' success path), and
get_cluster_password() could fall through to a real, blocking tkinter GUI
prompt in this environment because `import clustrix` puts 'ipykernel' in
sys.modules as a side effect, making detect_environment() always report
"notebook".

- tests/test_config_real.py: moved temp_config_dir fixture to module scope
  (TestConfigurationWorkflows couldn't see the class-scoped one); renamed
  "partition"->"default_partition", "namespace"->"k8s_namespace",
  "private_key_path"->"key_file" throughout (fields that were never named
  that); dropped assertions/kwargs for fields that never existed on
  ClusterConfig at all (gpu, cleanup_on_failure, node_selector,
  tolerations, service_account, image_pull_secrets, k8s_project_id,
  k8s_zone, k8s_gpu_type/count, k8s_preemptible, k8s_autoscaling,
  k8s_min/max_nodes, account, qos); rewrote test_configuration_precedence,
  which relied on a CLUSTRIX_DEFAULT_CORES env var override that has no
  implementation anywhere in config.py.
- tests/test_auth_fallbacks_real.py: moved temp_credentials_dir fixture to
  module scope; rewrote every test that called requires_password_fallback()
  with a ClusterConfig instead of the Dict[str, Any] key-setup-result it
  actually takes; fixed get_cluster_password()'s hostname= kwarg name and
  get_password_gui()/get_password_widget()'s single-prompt-arg signature;
  gave setup_auth_with_fallback() a real (non-mock) setup_ssh_keys_func
  callable instead of calling it with the wrong arity; skip the GUI test
  outside an interactive terminal (mirrors the existing CLI test's skip)
  since this machine's tkinter would otherwise open a real blocking dialog;
  rewrote test_secure_password_handling to exercise the real secret
  redaction on save_to_file() rather than the non-existent repr masking it
  originally asserted.
- tests/test_credential_manager.py: 1Password was removed in Issue #97
  ("use only .env, environment vars, and GitHub secrets"), so
  FlexibleCredentialManager has 3 sources, not the 4 these tests still
  asserted; fixed the default-location test to compare against
  get_config_dir() instead of a hardcoded ~/.clustrix, since conftest.py's
  session-scoped isolate_config_dir fixture deliberately redirects
  CLUSTRIX_CONFIG_DIR for the whole test run.
- clustrix/auth_fallbacks.py: requires_password_fallback() now treats a
  present-but-None "error" key the same as an absent one.

No test in either file touches the developer's real ~/.clustrix (verified
via `find ~/.clustrix -newermt` before and after every run); clustrix/
config.py was left untouched per the file-ownership boundary for this
issue -- see the sweep report for the config.py defects found instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012gTBDPK16HUZ3kHQ2QyjuU
tests/test_auth_fallbacks_real.py used
password="irrelevant-because-key-already-works" twice, which
check_for_secrets correctly reports as an assigned credential -- the
scanner cannot know the value is a stand-in, and the CI security job
would have failed on it. Renamed to a value the scanner's existing
fixture vocabulary recognises, rather than teaching it a suppression
marker that could later hide a real secret.

executor_schedulers.py carried a second docstring-shaped string in the
middle of the class body, left over from a refactor. The class already
has a real docstring, so this one was a dead expression statement.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012gTBDPK16HUZ3kHQ2QyjuU
…nce docs

ClusterConfig's dataclass-generated __repr__ printed every field
verbatim, so a password, API key or HF token landed in any traceback,
log line or notebook cell that displayed a config:

  'hunter2-real'   leaks: True
  'hf_realtoken'   leaks: True
  'sk-realkey'     leaks: True

save_to_file already refused to write those in plaintext; showing them
on screen instead was barely an improvement. They are masked as '***'
rather than omitted, so it stays visible that a value is set, and
environment_variables is masked per entry on the same rule that governs
saving -- OMP_NUM_THREADS stays readable, AWS_SECRET_ACCESS_KEY does not.

CLAUDE.md's configuration-priority list named 'environment variables' as
a level. No such level exists: nothing reads a CLUSTRIX_<FIELD>
variable. Only CLUSTRIX_CONFIG_DIR (where files live) and whatever
password_env_var names (a password, nothing else) are consulted. That
gap matters more now that saved configs omit secrets by default, so the
corrected text says plainly that password_env_var is currently the only
supported way to supply a credential without writing it to disk.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012gTBDPK16HUZ3kHQ2QyjuU
jeremymanning and others added 21 commits August 19, 2026 03:30
Round three of the documentation review reached docs/source/api/*.rst and the
module docstrings automodule/currentmodule renders from them. Every claim
below was checked against the code before it was rewritten.

api/config.rst
- local_cache_dir is defined (config.py:140) and read nowhere in clustrix/.
  configuration.rst was right to say "Not read."; this page was wrong to list
  it as a live setting. Say it is accepted and stored but has no effect.
- The hf_token bullet was truncated mid-sentence and wrong: hf_jobs.py:249-251
  falls back to $HF_TOKEN and then to the `hf auth login` token cache.
- "Individual settings are *not* configurable" by environment was false.
  There is no general CLUSTRIX_<FIELD> layer, but HF_TOKEN, HF_HOME and the
  variable named by password_env_var are all read. Say exactly that.

api/cost_monitoring.rst
- generate_cost_report has no duration_seconds parameter; its signature is
  (provider, instance_type="default") and it hardcodes estimate_cost(_, 1.0),
  so its "session cost" is a one-hour quote. Both places now say so.
- cost_tracking_decorator's instance_type never prices anything:
  stop_monitoring calls estimate_cost("default", ...) (cost_monitoring.py:103).
- "Always read pricing_warning" was a trap. aws_pricing.py:100 falls back to
  the hardcoded table inside get_instance_pricing, so the monitor labels the
  record pricing_source="api" with pricing_warning=None. Verified with no AWS
  credentials: p3.2xlarge -> 3.06/hr, source 'api', warning None, while the
  logger says "Using hardcoded pricing ... (last updated: 2025-01-01)".
- The price tables are a 2025-01 snapshot, not live pricing. Say so.
- nvidia-sml -> nvidia-smi.

api/file_packaging.rst - the prefix is clustrix_packages_ (file_packaging.py:954),
so the documented cleanup glob never matched.

api/notebook_magic.rst - kubernetes does get dedicated fields (namespace,
image, service account, pull policy; modern_notebook_widget.py:1118-1160).
Same fix already applied to index.rst and README.md; this copy was missed.

api/local_executor.rst - cancel_job raises ValueError for an unknown job ID
and RuntimeError for a known one, not RuntimeError always.

api/dependency_analysis.rst - the example used cluster_exists without importing it.

clustrix/local_executor.py (docstrings only)
- execute_loop_parallel's example raised AttributeError: the chunk worker is a
  closure a process pool cannot pickle, so use_threads=True is required. Fixed
  and verified by running; expected outputs added and checked with doctest.
- choose_executor_type's CPU example claimed False but yields True as written:
  a function defined in a REPL or doctest is unpicklable and takes the threads
  branch. Use importable functions and state the caveat.

clustrix/cost_monitoring.py (docstrings only) - the same instance_type and
"current session" corrections, plus the missing `cluster` import in the
example, which raised NameError as published.

scripts/check_docs_examples.py - check docstrings too
Two of the bugs above hid in docstrings, which the checker did not read. It
now also scans every module named by an automodule/currentmodule directive
under docs/source -- derived from the directives, not hand-listed. Inside a
docstring it recognises doctest runs, .. code-block:: python, and literal
blocks introduced by Example::/Examples::/Usage::; each is executed in a fresh
copy of the owning module's globals, or statically verified if marked
# cluster-required. Expected doctest output is not compared; that limit is
stated in the module docstring.

Verification: check_docs_examples 168 blocks, 168 passed, 0 failed;
sphinx build succeeded, no warnings; black/flake8/mypy clean;
pytest -m "not real_world" 1764 passed, 11 skipped, 28 deselected, 0 failed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012gTBDPK16HUZ3kHQ2QyjuU
…e not

The third review found the fabrication this whole cycle exists to remove
still sitting on the project's front page. "# Loop gets parallelized
automatically" and its variants appeared in README, CLAUDE.md, three API
pages, a tutorial and four notebooks.

Every one was false twice over. Measured against the real functions the
comments were attached to:

  process_datasets   detect_loops=None  accepts_chunks=False
  sample_loop        detect_loops=None  accepts_chunks=False

`for filename in data_files` is not a range at all, so detect_loops
declines it outright; and none of these functions accept the
_chunk_range_<var>/_chunk_index keywords, so _create_work_chunks produces
no chunks and the call runs whole. The reader was promised distribution
and got sequential execution.

Each comment now says what actually happens and points at the
auto-parallelization contract in the limitations page.

Worth noting why the tooling did not catch this: scripts/check_docs_examples.py
executes each block and checks it does not raise. A block whose code is
fine while its comment lies passes cleanly. Comments are not executable,
so no amount of example-running would have found these -- only reading
them against the code did.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012gTBDPK16HUZ3kHQ2QyjuU
…ebook tree

Round four of the documentation review found false "this loop is
automatically parallelized" claims surviving in notebook cells, and a stale
duplicate notebook tree that five published Colab badges pointed into.

Parallelization. Verified against clustrix/decorator.py
(_create_local_work_chunks, _create_work_chunks, _accepts_chunk_kwargs),
clustrix/loop_analysis.py and clustrix/utils.py::detect_loops: a loop is only
split when its range is a literal range(<int>), its iterations carry no
dependency, and the function accepts the chunk keyword (_parallel_<var>
locally; _chunk_range_<var> and _chunk_index remotely). Fail any one and the
function runs whole and clustrix logs the reason at INFO.

- complete_api_demo: rewrote the "Automatic Parallelization" section to state
  the three conditions, and replaced its example -- `for item in items` over a
  list argument was labelled "will be parallelized" and is declined on all
  three counts -- with a pair showing one declined loop and one that really is
  split (25 chunks, verified locally and in an IPython session).
- complete_api_demo: 'parallel_jobs' no longer claims one job per iteration
  (chunks are ~max_parallel_jobs, and results come back per chunk, unflattened).
- complete_api_demo: the two "# Parallelized" comments on range(100) loops were
  false -- both bodies append to a list and neither callee takes a chunk
  keyword. One is now labelled as running whole (its point was memory, not
  parallelism); the Monte Carlo example was rewritten to actually qualify.
- complete_api_demo summary: "Automatic Parallelization" bullet now states the
  conditions.
- basic_usage: "# This loop could be parallelized" -> it is not; says why.
- filesystem_tutorial: summary bullet claiming loop processing is parallelized
  contradicted the correction already made at cell 17; now consistent.

Duplicate tree. docs/notebooks/ is deleted and every reference repointed at
docs/source/notebooks/, which is the tree docs/source/index.rst already builds
and which has 15 notebooks to the old tree's 8 (seven of them, including the
un-overhauled clustrix_demo.ipynb advertising the deleted auto_gpu_parallel
feature and four keywords @cluster does not accept, existed only there). All
12 Colab badges now resolve to tracked paths on master. README's tutorial
location updated.

Also: nbformat.validate() failed on basic_usage (markdown cells carrying
`outputs`) and on four tutorials declaring nbformat_minor 4 while using cell
ids, which need 4.5. Fixed; all 15 notebooks now read and validate.
Step 3 was titled "use all your cores on one machine" and did not use
them. Its example failed the auto-parallelization contract three ways at
once: range(n) is not a literal range, `total += math.sqrt(i)` is a
loop-carried dependency, and the function declared no _parallel_i
parameter. Any one of those is enough for clustrix to decline. The prose
mentioned only the REPL/source condition, which was the one thing that
did not apply.

This is the most-read page in the set, and the same defect the previous
rounds removed from README and the notebooks.

The replacement is verified to distribute:

  values returned: 50000
  matches sequential exactly: True

25 chunks across 4 workers, concatenated back in order, identical to
what the undecorated function returns. The section now states the three
requirements before the example rather than after the disappointment,
and notes why the file needs a __main__ guard: the worker processes
re-import the module.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012gTBDPK16HUZ3kHQ2QyjuU
Round four of the doc review found the overhaul was scoped to docs/source/
and never opened the guides one click outside it. Several instructed readers
to run scripts that don't exist.

Operations guides, fixed to point at what actually works:
- REAL_CLUSTER_JOB_TESTING.md: scripts/run_cluster_job_tests.py moved to
  tests/real_world/cluster_validation/ in #76 and is only importable as a
  module from the repo root; repointed all 14 call sites plus one dangling
  scripts/test_real_world_credentials.py reference.
- CREDENTIAL_SETUP.md: dropped two nonexistent diagnostic scripts in favor
  of the real `run_real_world_tests.py --check-creds`, and corrected the
  false claim that real-world tests run on push/PR -- the workflow is
  workflow_dispatch + schedule only, deliberately, per #113/#118.
- docs/aws/*: test_aws_preflight.py and test_aws_eks_real.py live in
  tests/integration/, not the working directory; fixed 9 call sites across
  4 files. Also dropped a real AWS account ID hardcoded in a troubleshooting
  guide and a setup script (the variable was never actually used).

Historical records, marked as such rather than rewritten to look correct:
- PRICING_API_DEPLOYMENT.md describes a pricing_service daemon, a
  clustrix[pricing] extra, and an /etc/clustrix/clustrix.yml schema that
  never existed and that config.py's unknown-key check would reject.
  Banner added pointing at the real, working pricing_clients/cost_providers
  system documented in PRICING_API_REFERENCE.md / PRICING_USER_GUIDE.md.
- TECHNICAL_DESIGN_AUTH_ENHANCEMENT.md (#66): partially implemented.
  Banner lists what shipped (use_env_password, password_env_var, the
  auth_fallbacks chain) against what didn't (create_cluster_widget,
  validate_kerberos_auth, --password-env-var).
- ssh_key_automation_technical_design.md (#57): flagged the one code
  sample using paramiko.AutoAddPolicy() directly as superseded by
  configure_host_key_policy(), which every real call site now uses.
- COMPLEXITY_THRESHOLD_ANALYSIS.md: "root cause still under investigation"
  was true when written; the two-venv pickle/dill fix for #120 resolved it.
  Cross-referenced from function_dependency_design.md, which already
  pointed here.

docs/source/api/config.rst (env-var section only): replaced the false "two
CLUSTRIX_ variables plus three more" claim with the actual set read on the
ordinary SSH-without-password path (executor_connections.py calling
FlexibleCredentialManager.ensure_credential("ssh"), which loads all of
~/.clustrix/.env and reads ~22 SSH/cloud variables) and the separate,
non-automatic setup_ssh_keys_with_fallback() path (auth_fallbacks.py's
CLUSTRIX_PASSWORD_<HOST>/CLUSTRIX_DEFAULT_PASSWORD/CLUSTER_PASSWORD).

pyproject.toml / setup.py: azure_provisioner.py imports
azure-mgmt-authorization, which was declared in neither file, and
azure-mgmt-compute/-resource/-network were declared only in [test], so
`pip install clustrix[azure]` couldn't actually provision anything. Added
all four to the azure/cloud/all extras in both files (kept identical).
Also found and fixed a real break along the way: azure-mgmt-resource
26.0.0 dropped the `from azure.mgmt.resource import
ResourceManagementClient` re-export the code uses, so every affected extra
now pins <26.0.0 -- verified by installing clustrix[azure] into a clean
venv and importing clustrix.kubernetes.azure_provisioner /
clustrix.cloud_providers.azure before and after the cap.

Verified: scripts/check_docs_examples.py (0 failed) and --include-notes
(239 passed, 63 pre-existing failures, identical before/after via git
stash); python -m sphinx -b html docs/source build succeeded.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012gTBDPK16HUZ3kHQ2QyjuU
…ry job

Quick Checks failed on the branch: scripts/aws/cleanup_resources.py and
destroy_cluster.py imported boto3 at module scope, so even --help raised
ModuleNotFoundError on a machine without the AWS SDK. boto3 is not a
clustrix dependency and the CI job installs only the dev extra. The
tests asserting that --help works were right; the scripts were wrong.
boto3 is now imported inside the client-construction functions, which is
where it is actually needed.

  cleanup_resources.py: help_shown=True
  destroy_cluster.py:   help_shown=True
  (both with boto3 unavailable)

Separately, the fast_ci "CI Status" gate listed security-scan among its
needs but never checked its result -- so a failing security scan still
printed "All CI checks passed". That is the same decorative-gate problem
as the --exit-zero flake8 step removed earlier in this branch. The gate
now checks every job it depends on and names the one that failed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012gTBDPK16HUZ3kHQ2QyjuU
…at lied

The fifth review made the point that mattered: previous rounds fixed the
files they were handed and left identical claims everywhere else. This
round greps for the claim pattern across every .md, .rst, .ipynb and .py
in the repository instead of working from a list.

Remaining false claims, all verified against the code before removal:
README.md:408 ("This loop will be automatically distributed"),
examples/filesystem_tutorial.py:156 -- the repository's only example
script -- and a slurm_tutorial.ipynb docstring whose own corrective
comment sat three lines below it. None of those loops iterate a literal
range() and none of those functions accept a chunk keyword, so all three
ran sequentially. The only surviving instance is inside
docs/gpu/GPU_PARALLELIZATION_DESIGN.md, which is marked WITHDRAWN and is
a record of a deleted feature.

basic_usage.ipynb carried a fabricated benchmark: two functions with
byte-identical bodies, one decorated parallel=False and the other
parallel=True, timed against each other with a printed "Speedup: Nx".
Neither was ever parallelized, so the ratio measured timing noise
between one code path and itself. Replaced with a benchmark whose two
sides genuinely differ, and a note that a speedup number means nothing
until you have checked the parallel version actually parallelized.

Two gates were not gating:

- scripts/check_quality.py, which CLAUDE.md and README both name as the
  recommended pre-commit check, ran `pytest tests/ -q` -- 2200 tests,
  including the ~400 under tests/real_world/ that open live SSH and cloud
  connections. The check you are told to run before committing was the
  thing dialling out. It now uses CI's selector, invoked through
  sys.executable rather than whatever pytest is first on PATH.
- scripts/pre_push_check.py passed inline flake8 ignores that duplicated
  .flake8's policy with a drifted list and skipped scripts/ entirely, so
  it could pass while CI failed. It now runs exactly what CI runs.

docs/testing_guidelines.md documented @pytest.mark.kubernetes, .ssh and
.flaky. None are registered, --strict-markers is on, and no test uses
them, so following the guide produced a hard collection error; .flaky
additionally needs a plugin the project does not depend on. Replaced
with the markers that exist.

Quickstart Step 3: the `for i in range(50_000): pass` loop is
load-bearing -- it is what the analyser splits -- and read as a decoy.
Now explained. The chunk count is os.cpu_count()-dependent and was
stated flat. And whether that step runs locally at all depends on
cluster_host, not cluster_type, so a config file with a host silently
sends it down the remote path; that is now called out.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012gTBDPK16HUZ3kHQ2QyjuU
…ck scopes

The AWS cleanup test assertions were widened by hand and not re-run
through black. Quick Checks caught it.

fast_ci checked black on clustrix/ tests/ while tests.yml checked
clustrix/ tests/ scripts/, so a file under scripts/ could pass one gate
and fail the other. Same scope now.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012gTBDPK16HUZ3kHQ2QyjuU
docs-test burned its entire 10-minute cap on "Get:5 noble-security
InRelease" and was cancelled -- twice, despite the Acquire::*::Timeout
options added for exactly this. Those options bound individual fetches;
they did not bound the step.

pandoc is genuinely required: nbsphinx renders 30 notebook pages with
it, so removing the dependency would silently drop them from the built
documentation. Instead, try the install against the runner image's
existing package lists first and only refresh them if that fails, under
a hard wall-clock bound apt cannot ignore.

The step now ends with 'pandoc --version', so if pandoc is still absent
the job fails there rather than letting sphinx build notebook-less docs
that look fine.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012gTBDPK16HUZ3kHQ2QyjuU
Every matrix job failed with 7 tests that pass locally.

Six were `HfHubHTTPError.__init__() missing 1 required keyword-only
argument: 'response'`. Current huggingface_hub makes `response` required
and keyword-only; the version I had locally (0.36.0) still allowed
`HfHubHTTPError("msg")`. Raising the floor to >=0.34.0 earlier in this
branch is what let CI resolve a newer release than my machine had. The
tests now pass a real requests.Response, which is correct on both old and
new versions and is what the library does itself.

The seventh was `ModuleNotFoundError: No module named 'sklearn'`.
tests/test_decorator_real.py::test_machine_learning_workflow trains a
real model -- a good test -- and passed locally only because this
developer environment happens to have scikit-learn installed. Nothing
declared it. Added to the dev extra in both pyproject.toml and setup.py,
which are verified still identical field by field.

That is the same class of defect as the missing azure-mgmt-authorization
found earlier: a dependency that exists on someone's machine and nowhere
in the metadata.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012gTBDPK16HUZ3kHQ2QyjuU
…emon threads

Every CI job printed its pytest summary and then sat there until the
fifteen-minute cap killed it -- 5m41s of dead time on ubuntu/3.11, and the
same on all eight matrix jobs.

Root cause: tests/comprehensive/test_edge_cases_real.py::test_deadlock_prevention
builds a deliberate AB-BA lock inversion in two *non-daemon* threads, joins
them with timeout=5, and abandons them when (as designed) they deadlock.
Py_FinalizeEx calls threading._shutdown(), which joins every surviving
non-daemon thread with no timeout, so the interpreter could never finalize.
The test is also re-run by reflection from test_comprehensive_edge_case_suite,
so four threads were wedged, not two.

Evidence, from a faulthandler dump armed at pytest_unconfigure:

  AT-UNCONFIGURE non-daemon alive: 4
    ALIVE Thread-6 target=...potential_deadlock.<locals>.worker1
    ALIVE Thread-7 target=...potential_deadlock.<locals>.worker2
    ALIVE Thread-8 target=...potential_deadlock.<locals>.worker1
    ALIVE Thread-9 target=...potential_deadlock.<locals>.worker2
  Thread 0x33da2b000: test_edge_cases_real.py line 635 in worker2
  Thread 0x33ca1f000: test_edge_cases_real.py line 629 in worker1
  Thread 0x33ba13000: test_edge_cases_real.py line 635 in worker2
  Thread 0x33aa07000: test_edge_cases_real.py line 629 in worker1
  Thread 0x1fbd91d80: threading.py line 1477 in _shutdown

Marking those two threads daemon changes nothing the test observes -- the
deadlock still happens, the joins still time out, is_alive() is still True and
the assertions are untouched -- but abandoning them no longer wedges the
process.

Same defect, second site: tests/real_world/conftest.py::_within claimed to
abandon its worker, but a ThreadPoolExecutor's workers are non-daemon and
concurrent.futures joins all of them, untimed, at interpreter exit even after
shutdown(wait=False). Abandoning a seventy-second DNS lookup only moved the
wait to process exit. Replaced with an actual daemon thread, preserving the
existing semantics (OSError -> None, timeout -> None, anything else re-raised).

Isolating measurement, tests/comprehensive/test_edge_cases_real.py alone:
  before: summary at 59.98s, still not exited 90s later
  after:  summary at 62.60s, exited 0.66s later

Full CI selection:
  before: [345.05] 1764 passed ... -- never exited, killed after 6 minutes
  after:  [343.58] 1764 passed ... / [345.64] EXITED rc=0
SimpleAsyncClusterExecutor owns a four-worker ThreadPoolExecutor and
exposes shutdown(), but nothing ever called it, and decorator.py built a
fresh executor on every async submission. Five async calls created five
pools:

  BEFORE: distinct thread pools alive: 1 ['ThreadPoolExecutor-4']
  AFTER:  distinct thread pools alive: 1 ['ThreadPoolExecutor-0']

The name index is the tell: -4 means five pools had been constructed and
the earlier four garbage-collected. So this was churn rather than an
unbounded leak -- worth stating accurately -- but four threads were
being started and torn down per submission for no reason.

The pool cannot be closed at the end of the call, because the submitted
work outlives it; that is the point of async submission. So the lifetime
is the process, and one executor is shared. The class also gains
__enter__/__exit__ so callers who *can* bound the lifetime are able to.

Caching it introduced exactly the shared-state problem this branch has
already fixed twice, and two decorator tests caught it immediately: with
the executor cached, a later test received the instance an earlier test
had built from a patched class. tests/conftest.py resets it between
tests, alongside the config singleton and the credential manager.

Found while root-causing the CI hang; not the cause of it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012gTBDPK16HUZ3kHQ2QyjuU
…ailures

test_single_venv_program_refuses_to_fall_back_to_stdlib_pickle hides dill
and cloudpickle from a child interpreter to prove the generated worker
refuses rather than feeding dill bytes to stdlib pickle. It did that with
a meta_path finder implementing find_module/load_module -- the legacy API
Python 3.12 removed. On 3.12 the blocker is ignored entirely, the child
imports dill normally, the program succeeds and the assertion that it
should have failed fires.

So the test was passing vacuously on 3.10 and 3.11 (nothing proved that
the blocker worked) and failing on 3.12 for a reason unrelated to what it
tests. Rewritten with find_spec, and verified the blocker now actually
blocks:

  import dill under blocker -> rc 1 | ImportError: dill

Also set fail-fast: false on the test matrix. One job failing cancelled
the other six, so a 3.12-only problem presented as seven broken jobs and
hid whatever the other combinations would have said.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012gTBDPK16HUZ3kHQ2QyjuU
Windows reached 96% of the suite and was cancelled at the 15-minute cap,
still making progress -- it was not hung. The cap dates from when this
job ran only tests/unit, about 350 tests. It now runs the whole
non-billable suite, roughly 1790 (issue #113).

Measured on this branch: ubuntu 9m39s-10m46s, macOS 12m56s-13m48s,
Windows over 15. The process-spawning tests are the difference -- the
serialization round-trips each start a fresh interpreter, which is much
more expensive on Windows.

30 minutes bounds a job that legitimately takes longer. It does not relax
anything: every test still has to pass, and pytest's own --timeout=120
still bounds any individual test that wedges.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012gTBDPK16HUZ3kHQ2QyjuU
The published widget screenshots were captured by hand in a live
notebook, so they carried, legibly, in images linked from README and the
documentation index:

  - the real cluster hostname
  - the real username
  - the author's home directory, /Users/<name>/clustrix
  - the SSH private key path
  - a real cluster's conda path under /optnfs
  - a HuggingFace organisation name

Regenerated from placeholders: hpc.example.edu, researcher,
~/.clustrix/jobs, your-org. scripts/render_widget_screenshots.py renders
the real widget to standalone HTML with those values -- pointing
CLUSTRIX_CONFIG_DIR at a throwaway directory first, so the developer's
own profile store cannot leak in through the profile name either -- and
inlines the stylesheet the widget normally publishes separately, so the
page looks the way it does in a notebook.

01-before-light.jpg is deleted rather than regenerated. It showed the
pre-rewrite widget, which no longer exists, so there is nothing to
re-render; it leaked a home path; and nothing referenced it.

The older PNG set under _static/img/screenshots/ was checked image by
image and is already placeholder-only (login.hpc.university.edu,
your_username, your-gcp-project-id). Left alone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012gTBDPK16HUZ3kHQ2QyjuU
clustrix/validation.py hardcoded two real hostnames in a TEST_CLUSTERS
list that shipped inside the package. That named someone's actual
infrastructure to everyone who installed clustrix, and was useless to
them besides -- the validation pass could only ever check machines they
did not have access to.

The targets now come from CLUSTRIX_VALIDATION_SSH_HOST and
CLUSTRIX_VALIDATION_SLURM_HOST. With neither set it says so and checks
nothing, rather than attempting to connect to hosts the user does not
own:

  No validation clusters configured. Set CLUSTRIX_VALIDATION_SSH_HOST
  and/or CLUSTRIX_VALIDATION_SLURM_HOST to run this against your own
  cluster; nothing will be checked otherwise.

That function also referenced a logger the module never defined, so the
warning would have raised NameError; added.

Also removed: a real hostname used as a widget placeholder, two code
comments naming real machines, and a README row naming the SLURM cluster
a verified run happened on. The claim is unchanged -- it ran on a real
production SLURM cluster -- only the identifier is gone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012gTBDPK16HUZ3kHQ2QyjuU
…cs/notes/scripts

Replaces real Dartmouth cluster hostnames (discovery/ndoli/tensor01/tensor02
.dartmouth.edu and worker nodes), the real SSH username (f002d6b), and local
home paths (/Users/jmanning) with consistent fake placeholders across docs/,
notes/, and scripts/. The two evidence transcripts (docs/evidence/*.txt) keep
their real results/timings/output verbatim -- only identifying strings were
substituted, and a header now documents that. scripts/verify_cluster_usecases.py
and scripts/collect_execution_evidence.py no longer hardcode any cluster
hostname or username; both now read CLUSTRIX_TEST_SLURM_HOST(_2),
CLUSTRIX_TEST_SSH_HOST(_2), and CLUSTRIX_TEST_USERNAME from the environment
and fail with a clear message when unset.

CONTRIBUTORS.md was left untouched (real third-party emails; needs explicit
sign-off before editing).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012gTBDPK16HUZ3kHQ2QyjuU
tests/ named one developer's actual infrastructure in 86 files: the
hostnames discovery/ndoli/tensor01/tensor02.dartmouth.edu and worker
nodes, the SSH account f002d6b, the institution's /dartfs-hpc home
layout, and absolute /Users/jmanning paths. The repository is public,
so all of it was published.

Targets now resolve in ONE place, tests/real_world/credential_manager.py,
from the same environment variables scripts/ already reads:

    CLUSTRIX_TEST_SSH_HOST      CLUSTRIX_TEST_SSH_HOST_2
    CLUSTRIX_TEST_SLURM_HOST    CLUSTRIX_TEST_SLURM_HOST_2
    CLUSTRIX_TEST_USERNAME
    CLUSTRIX_TEST_SLURM_REMOTE_DIR

Tests ask for a role ("ssh", "slurm"), never a hostname, via
get_test_host / require_test_host / require_test_username /
require_test_remote_work_dir. The require_* helpers skip with the
variable name in the reason, so a run that could not find its target
says so instead of failing or quietly passing. No assertion was
weakened and no test was deleted.

The network gate is rebuilt on the same footing: is_dartmouth_network()
becomes can_reach_configured_cluster(), which resolves the configured
hosts under one shared 3s budget (previously one budget per host) and
returns False immediately when nothing is configured. The marker
dartmouth_network becomes cluster_network, registered in
tests/conftest.py so it is declared in every run -- tests/real_world's
own conftest is not loaded when that directory is excluded, which is
the documented CI-safe command.

Files and identifiers named after the machines are renamed by role:
tensor01 -> gpu_cluster, ndoli -> slurm_cluster. Values that were only
ever fixtures now look like fixtures: hpc.example.edu, hpc2.example.edu,
testuser, /remote/home/testuser -- matching the placeholders used in
docs/ and scripts/.

tests/audit_results.json was a 245KB generated report from
audit_antipatterns.py, stale and full of real hostnames; it is removed
and gitignored, and the script that writes it no longer hardcodes an
absolute home path.

One behavioural consequence worth stating: the old gate opened whenever
socket.gethostname() contained ".dartmouth.edu", which on the author's
machine let 14 tests in tests/test_real_world_gpu_functionality.py run
against hostnames that do not exist. Twelve of them passed anyway --
they never reached a cluster. They now skip with a reason, which is why
the suite reports 1753 passed / 23 skipped where it used to report 1764
passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012gTBDPK16HUZ3kHQ2QyjuU
pyproject.toml still registered a pytest marker named after a specific
university's campus network. tests/conftest.py now registers
`cluster_network` in its place, so the old registration is deleted
rather than renamed -- verified the new marker resolves under
--strict-markers (10 of 397 real-world tests select on it).

The remaining references were in prose: MIGRATION.md, two docs pages and
four notes files describing the old `is_dartmouth_network()` gate, plus
`/dartfs-hpc` storage paths. Renamed to match the code as it now stands
(`can_reach_configured_cluster`, `cluster_network`) so the docs describe
functions that exist, and generic storage paths.

The working tree is now free of the real cluster names, username, home
paths and account handles. CONTRIBUTORS.md is deliberately untouched --
it carries contributors' email addresses including a third party's, and
removing someone's credit is the maintainer's call, not mine.

This does NOT address the published git history, which still contains
all of it across 29 commits going back to 2025-07-01, on a public repo
with 3 forks. That needs a history rewrite and a force-push, which is
destructive and outward-facing, so it is flagged for a decision rather
than done.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012gTBDPK16HUZ3kHQ2QyjuU
`_write_config_file_securely` called `os.fchmod`, which does not exist on
Windows before Python 3.13. It raised AttributeError *after* `os.open`
returned a descriptor and *before* `os.fdopen` took ownership of it, so every
call leaked the descriptor. That is a genuine cross-platform defect -- POSIX
just hides it, while Windows refuses to delete a file with an open handle and
surfaced it as `PermissionError: [WinError 32]` in four teardowns. On Windows
this also meant `save_to_file`/`save_config` never worked at all.

Code fixes (affect real users, not just tests):

* clustrix/config.py: guard the fchmod hardening with hasattr and close the
  descriptor on any failure between open and fdopen.
* clustrix/config.py: `_load_default_config` no longer lets a RuntimeError
  from `Path.home()` (undeterminable home directory) abort `import clustrix`.
* clustrix/executor_cloud.py: the job directory is interpolated into generated
  *Python* source, so quote it with repr() -- bare quotes let an embedded
  quote close the literal, and a backslash become an escape.
* clustrix/credential_manager.py, clustrix/cli_credentials.py: read and write
  `.env` as UTF-8. The template contains non-ASCII, so on Windows the write
  died mid-encode and left a zero-byte credential file behind.

POSIX-only properties, skipped on Windows with a reason naming the property,
and documented in docs/source/limitations.rst (new section: config and
credential files are NOT permission-restricted on Windows, with mitigations):

* 0600/0644 file-mode assertions (NTFS uses ACLs; chmod only toggles
  read-only).
* Shell-quoting tests that verify quoting by running a real POSIX shell.
* Tests whose payload uses fcntl.flock or signal.SIGALRM.

Test-harness fixes (portable, no assertion weakened on Linux/macOS):

* Read repo source files as UTF-8 rather than the locale codec.
* Point USERPROFILE/HOMEDRIVE/HOMEPATH, not just HOME, when redirecting the
  home directory; Windows expands ~ from those.
* Parse the emulated `cat` command with shlex.split instead of splitting on
  whitespace and stripping quote characters.
* Use a POSIX remote_work_dir in the integration test (it goes into a bash
  job script, and the validator rightly refuses a `C:\...` path).
* Measure elapsed time with perf_counter; the Windows wall clock ticks in
  ~15.6 ms steps and reported 0.0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012gTBDPK16HUZ3kHQ2QyjuU
The Windows wall clock ticks in ~15.6 ms steps, so the data-processing
workflow's elapsed time measured as exactly 0.0 and `processing_time > 0`
failed. Only the Python 3.12 Windows job hit it; 3.11 was a coin flip on the
same clock. Same fix as tests/test_decorator_real.py.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012gTBDPK16HUZ3kHQ2QyjuU
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant