Skip to content

fix(config): fail loud on unresolved env vars - #9045

Open
akindu-k wants to merge 16 commits into
jaseci-labs:mainfrom
akindu-k:fix/8773-fail-loud-env-interpolation
Open

fix(config): fail loud on unresolved env vars#9045
akindu-k wants to merge 16 commits into
jaseci-labs:mainfrom
akindu-k:fix/8773-fail-loud-env-interpolation

Conversation

@akindu-k

@akindu-k akindu-k commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator

Summary

Fixes #8773.

jac.toml lets you write ${VAR} in a value, and Jac swaps in an environment variable when it reads the file. Both ${VAR} and ${VAR:?message} are documented as errors when the variable is not set, and the function that does the swapping raised one correctly. But the function that walks the config caught that error and returned the raw text instead, so both forms did nothing at all. The literal ${K8S_NAMESPACE:?set K8S_NAMESPACE} became the configured value and the command carried on.

That is how a Kubernetes namespace ended up named after a shell expression while --dry-run reported no errors.

What changes

The walker no longer throws the error away. It also keeps track of where it is in the file, so the message names the setting rather than only the variable.

You write Variable not set
${VAR} jac.toml is invalid: <setting>: Environment variable VAR is not set
${VAR:?message} jac.toml is invalid: <setting>: Environment variable VAR is required: message
${VAR:-default} unchanged, uses the default

The reported case now stops instead of deploying to a literal namespace:

$ jac run src/main.jac
✖ Error: jac.toml is invalid: scale.kubernetes.namespace: Environment variable K8S_NAMESPACE is required: set K8S_NAMESPACE

Array items are located too, for example project.authors[0]: Environment variable JAC_MISSING_AUTHOR is not set. Every layer behaves the same way: the base jac.toml, profile overlays, and jac.local.toml.

What this means for existing projects

This is the real cost of the change, so it is worth reading before the code.

The swap happens when the config is read, not when a value is used, and every command reads jac.toml. A project with a deploy-only variable can therefore no longer run jac check or jac fmt without exporting it, even though neither command touches that value.

That is the correct behaviour, since ${VAR} is documented as required and honouring it in only some commands is what caused this bug in the first place. But people will hit it, so both ways out are now written down in the release note and the config reference:

  • export the variable, or
  • use ${VAR:-default} for values that only some commands need.

The config reference also now explains a related trap: an inline [profiles.<name>] table lives inside jac.toml, so its variables are resolved on every command even when that profile is never activated. A separate jac.<profile>.toml file is only read when the profile is active, which makes it the better home for deploy-only values.

One config in this repo was affected. [client.npm.auth] used _authToken = "${NODE_AUTH_TOKEN}" and relied on the placeholder reaching the generated .npmrc unexpanded, so npm could resolve it later at install time. That is no longer possible, and there is no syntax that restores it: ${VAR:-} writes an empty token and npm answers 401, and a nested ${VAR:-${VAR}} corrupts the value when the variable is set, because the pattern stops at the inner brace. The jac-client reference now says plainly that the variable has to be exported before the command that reads jac.toml, and that a genuinely optional registry should leave the table out rather than give it a blank value.

How it is implemented

The fix itself is in _interpolate_recursive. It stops catching the error, and carries a path string down as it walks so the failure can name scale.kubernetes.namespace instead of just K8S_NAMESPACE. The path lives in the walker rather than in interpolate_env_vars, which stays a plain string function that knows nothing about jac.toml.

Making the error fatal everywhere. Once the error could escape, it turned out several places caught it and carried on, so the run either continued on the wrong config or ended in a traceback: profile overlays applied during dispatch, and file targets that belong to a different project than the current directory. "an unloadable jac.toml stops the command" was being written out separately at each of those places, so it is now one small module, jaclang/cli/config_errors.jac, and every CLI discovery and overlay call site uses it. Two details it now gets right in one place instead of four:

  • tomllib.TOMLDecodeError is a subclass of ValueError, so a plain except ValueError would report a malformed file as an invalid one. Malformed is checked first.
  • Only ValueError is fatal. OSError and KeyError during discovery still warn and carry on, so a permissions problem does not stop a command.

The jaclang/project/ call sites keep raising rather than exiting, which is the right behaviour for a library.

Checked by hand

run, check, fmt, build, clean, config show and test all exit 2 with no traceback on an unloadable config, and print the same message. --help and --version still work when the config is broken. Malformed files still report as malformed, not invalid. Profile activation was checked through JAC_PROFILE, --profile and [environment] default_profile, and file targets through relative paths, absolute paths, --no-takeover, and a target project whose base file is fine but whose overlay is not.

One gap left alone: jac check <file-in-another-project> reports the same error through its own per-file reporting (Error checking '...', exit 1) rather than these handlers. It is clear and has no traceback, so check's reporting contract was not changed.

Tests

Suite On main Here
jac/tests/project/ 323 324
jac/tests/cli/ 348 349
jac/tests/client/test_cli.jac 28 28

Two tests added, both table-driven, both extending files that already own the subject.

tests/project/test_config.jac covers the config-loading path, which had none before: ${VAR} in the base file, ${VAR:?message} in the base file, and ${VAR:?message} in an overlay, each asserting the setting name appears in the message.

That test calls merge_from_toml_file directly, so it never reaches the CLI handlers. Those are covered by a test in tests/cli/test_run_project_scope.jac, whose subject is already jac run <file> config discovery and which supplies the fixtures. It drives the real run() in-process over an unresolvable base file, an unresolvable profile overlay, and a resolvable default. Reverting the discovery guards fails the first case and reverting the overlay guards errors on the second, so it pins both.

tests/client/test_cli.jac had an assertion that locked in the old behaviour, with a comment saying so. It now sets NODE_AUTH_TOKEN and checks the token is actually resolved.

Known limitation

Only the first unresolvable variable is reported per run, so a config with many of them is fixed one at a time. Collecting them all needs an accumulator threaded through the walk, which brings back the catch-and-return-the-raw-value shape this PR exists to remove. Better done separately, with its own review.

@MusabMahmoodh MusabMahmoodh left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right fix, and deleting the catch rather than special-casing :? is the right shape: the swallow was making ${VAR} mean two different things depending on whether anyone had thought about it. The two new tests drive real toml through the real loader and cover the overlay path, which is where I would have expected the gap.

One thing before merge and one thing that is not yours, both below. jac-check red here is the runtime-cache flake, not your diff: the job dies in "Warm the runtime cache" on rmtree: could not remove .../rt/f4ca7e3e.../openai/types/conversations/__pycache__, before it reads a single file. A re-run clears it. Linus hit the same thing on #8175 yesterday, so I will file it separately rather than have people keep re-running.

Comment thread jac/jaclang/project/impl/config.impl.jac Outdated
Comment thread jac/jaclang/project/impl/config.impl.jac Outdated
@MusabMahmoodh

MusabMahmoodh commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Filed the jac-check flake I mentioned as #9053 - it also has main red at 9b02a4b2d9 on the same stale runtime id, so it is not specific to your branch. Re-running clears it.

@MalithaPrabhashana MalithaPrabhashana left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We filed #8773, so here is a validation pass from our side. Ran this on macOS
against pr-9045 (2eb371b) with main at 3ad7d91b5 as the baseline, both in
dev mode off the checkout so the config code under test is really yours.

The reported bug is fixed

Same project from the issue, jac scale deploy --dry-run --show-yaml, neither
variable exported.

main today, exit 0, and the literal ships into the manifests:

kind: Secret
metadata:
  name: interp-repro-secrets
  namespace: ${K8S_NAMESPACE:?set K8S_NAMESPACE}
stringData:
  SOME_API_KEY: ${A_VAR_NOBODY_SET}

(namespace: ${K8S_NAMESPACE:?set K8S_NAMESPACE} appears in 5 places.)

This branch, same command, exit 2:

✖ Error: jac.toml is invalid: scale.kubernetes.namespace: Environment variable K8S_NAMESPACE is required: set K8S_NAMESPACE

With both variables set it is exit 0 again and the real values land
(namespace: prod-ns, SOME_API_KEY: real-key-123), so the happy path is
untouched.

Also checked and fine: ${VAR:-default} unchanged, list elements are located
(project.authors[0]), deep tables get the full path
(scale.kubernetes.resources.limits), run / check / fmt / build /
clean / config show all give the same clean exit 2, and jac --help and
--version still work with a broken config.

Suites on this machine:

suite main this branch
tests/project/ 320 passed, 3 failed 321 passed, 3 failed
tests/cli/ 342 passed, 6 failed, 1 skipped 342 passed, 6 failed, 1 skipped
tests/client/test_cli.jac 28 passed 28 passed

The 3 test_template.jac failures are already red on main, not from this PR.
I also reverted only the except hunk on your branch and re-ran
tests/project/test_config.jac: 129 passed with the fix, 128 passed + 1 failed
without it, so the new test really does pin the behaviour. The branch already
contains current main, nothing to merge.

Four things I ran into.

1. The profile overlay path still ends in a traceback

Put the value in jac.prod.toml instead of jac.toml and activate the profile:

$ JAC_PROFILE=prod jac run src/main.jac        # BT_UNSET not exported
⚠ Failed to apply profile: scale.kubernetes.namespace: Environment variable BT_UNSET is required: you must set BT_UNSET for prod
✖ Error: Error executing 'run': scale.kubernetes.namespace: Environment variable BT_UNSET is required: you must set BT_UNSET for prod
Traceback (most recent call last):
  ...
  File ".../jaclang/project/impl/plugin_config.impl.jac", line 109, in get_jac_config
    config.apply_profile_overlay(profile or None);
  ...
ValueError: scale.kubernetes.namespace: Environment variable BT_UNSET is required: you must set BT_UNSET for prod
exit 1

Two spots miss it. _apply_profile (cli/impl/dispatch.impl.jac:129) catches
ValueError and only warns, and PluginConfigBase.get_jac_config
(project/impl/plugin_config.impl.jac:109) has no handler at all. The base file
is fine because _discover_project_config catches it and exits 2 cleanly.

Still better than main, which is exit 0 and RAN OK for the same run. But it
does not look like the rest of the feature. Worth noting your new overlay test
calls merge_from_toml_file directly, so it never goes through either handler
and stays green either way. This is the only one I would want looked at before
merge.

2. Only one missing variable is reported per run

The dict comprehension raises on the first bad key, so you fix them one at a
time. On jacBuilder's real jac.toml that is 47 runs before the config loads:

run 1  -> ECR_REGISTRY
run 2  -> K8S_APP_NAME
run 3  -> K8S_NAMESPACE
...
run 47 -> SHARED_DATA_PVC

Not a blocker, but collecting them and printing all at once would save a lot of
time on big configs. It also buries other errors: our file additionally has a
[scale.microservices] was removed error, and that message only appears after
all 47 variables are set.

3. Inline [profiles.*] is strict, jac.prod.toml is lazy

[profiles.prod.scale.kubernetes]
namespace = "${BT_UNSET:?prod only}"

in jac.toml fails every command even when the prod profile is never used. The
same content in jac.prod.toml is only read when the profile is active, so it
does not fail. That asymmetry is probably fine, but it is worth a line in the
docs, because moving deploy-only variables into an overlay file is the easy
answer to the "you now have to export everything" note in your description.

4. The jac-client doc line is not quite right

It now suggests ${NODE_AUTH_TOKEN:-} to keep a registry entry optional. That
writes an empty token, not a pass-through. Measured through create_npmrc:

${NODE_AUTH_TOKEN}                     -> config rejected
${NODE_AUTH_TOKEN:-}                   -> //npm.pkg.github.com/:_authToken=
${NODE_AUTH_TOKEN:-${NODE_AUTH_TOKEN}} -> //npm.pkg.github.com/:_authToken=${NODE_AUTH_TOKEN}

Only the third keeps the old behaviour where npm resolves the token itself at
install time. An empty token is not optional, npm just gets a 401. Either
mention the third form, or say plainly that the leave-it-to-npm pattern is gone
and the variable has to be exported at build time.

Not covered

No real cluster, --dry-run only. macOS only. I did not test the LSP or
jac serve paths, though get_config() is unguarded in a lot of call sites so
1 may show up in more places than the profile one.

None of this changes the verdict for me. The fix does what #8773 asked for and I
am happy with it from our side.

@akindu-k

akindu-k commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks for the validation pass, this was thorough. I reproduced all four on Linux and fixed 1, 3 and 4. Details below.

1. Profile overlay traceback — fixed

Reproduced exactly, including the double report and exit 1. Root cause is the one you identified: _discover_project_config treats an invalid base file as fatal, while _apply_profile downgraded the same failure to a warning and let execution continue into get_jac_config, which then re-raised unguarded.

Rather than add a handler to get_jac_config, I fixed the asymmetry at the CLI layer, where an invalid config already becomes a clean exit. _apply_profile now routes the overlay through a helper that exits the same way the base file does:

def _apply_overlay_or_exit(config: any, profile: str | None) {
    import from jaclang.project.tomlio { MalformedJacTomlError }

    try {
        config.apply_profile_overlay(profile);
    } except MalformedJacTomlError as exc {
        _exit_on_malformed_jac_toml(exc);
    } except ValueError as exc {
        _exit_on_invalid_jac_toml(exc);
    }
}

_apply_profile runs before load_handler, so get_jac_config is never reached and the library keeps raising, which is correct for a library. Your run now gives:

$ JAC_PROFILE=prod jac run src/main.jac
✖ Error: jac.toml is invalid: scale.kubernetes.namespace: Environment variable BT_UNSET is required: you must set BT_UNSET for prod
exit 2

No warning line, no traceback, identical to the base-file path. Verified for all three activation routes: JAC_PROFILE, --profile, and [environment] default_profile.

One thing that fell out of writing it: tomllib.TOMLDecodeError subclasses ValueError, so a bare except ValueError would have caught a malformed overlay too and mislabelled it is invalid. Hence the MalformedJacTomlError branch first, mirroring _discover_project_config. Confirmed a malformed overlay still reports jac.toml is malformed: Expected ']' ....

You were right that the existing overlay test bypasses the handler. Added jac/tests/cli/test_invalid_config_exit.jac, table-driven over ${VAR}, ${VAR:?msg} and ${VAR:-default}, asserting SystemExit code 2 for the first two and a resolved value for the third. In-process, no subprocess. tests/cli/ goes 348 -> 349.

4. The npmrc doc line — fixed, you are right

My line was wrong. Measured through create_npmrc on this branch:

${NODE_AUTH_TOKEN}                     -> rejected: client.npm.auth.//npm.pkg.github.com/._authToken: ... is not set
${NODE_AUTH_TOKEN:-}                   -> //npm.pkg.github.com/:_authToken=
${NODE_AUTH_TOKEN:-${NODE_AUTH_TOKEN}} -> //npm.pkg.github.com/:_authToken=${NODE_AUTH_TOKEN}

An empty token is a 401, not optional. The doc now names the third form as the way to keep npm resolving it at install time, and says plainly that ${VAR:-} writes an empty token.

3. Inline [profiles.*] vs jac.<profile>.toml — documented

Confirmed: [profiles.prod.scale.kubernetes] in jac.toml fails every command even when prod is never activated, because it is part of the base file. Added to the interpolation section of the config reference, framed as you suggested, since moving deploy-only variables into an overlay file is the practical answer to the export-everything note.

2. One variable per run — confirmed, not taking it here

Reproduced: three missing variables in one file report only the first, the rest stay hidden. Your 47-run number is real and the burying of other config errors is the worse half of it.

I am leaving it out of this PR deliberately. The clean way to collect is an accumulator threaded through the walk, which means the string branch catches and returns the raw value again and relies on a caller to raise. That is the exact shape this PR removes, and re-adding it guarded only by an invariant in another function is not something I want to slip in under an approved diff. It also changes the error contract a third time after two rounds of review. Happy to do it as a follow-up against a fresh review, or to fold it in here if you would rather it ship together.

Battle test

Beyond your matrix, on Linux: all of run/check/fmt/build/clean/config show/test give exit 2 with zero tracebacks and the same message; --help and --version still exit 0 with a broken config; jac.local.toml is covered; deep tables give the full path (scale.kubernetes.resources.limits.cpu); list elements give project.authors[0]; ${VAR:-default} is untouched everywhere; happy paths with the variables set all still work. Suites: tests/project/ 324, tests/cli/ 349 (+1 for the new test), tests/client/test_cli.jac 28. The one skip in tests/cli/ is the scale server test gating on dotenv, present with and without my changes.

@greptile-apps

greptile-apps Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR makes unresolved environment-variable references in jac.toml fatal and adds the affected setting path to the diagnostic.

  • Propagates interpolation failures from nested dictionaries and lists.
  • Centralizes clean CLI handling for malformed and invalid configurations.
  • Applies fatal handling to profile overlays and cross-project file discovery.
  • Updates configuration, npm authentication, and release documentation.
  • Adds project and CLI integration coverage for unresolved variables and fallback values.

Confidence Score: 5/5

The PR appears safe to merge; the previously reported discovery and nested-placeholder issues are resolved, and no new actionable failures remain.

The current code consistently propagates unresolved interpolation errors with setting paths and converts them into clean CLI exits across initial discovery, profile overlays, and cross-project targets. Both previous threads are resolved, and the replacement integration test retains coverage of the relevant fatal paths.

Important Files Changed

Filename Overview
jac/jaclang/project/impl/config.impl.jac Propagates unresolved-variable failures while adding dictionary and list paths to diagnostics.
jac/jaclang/cli/config_errors.jac Centralizes malformed and invalid configuration reporting with a clean status-2 exit.
jac/jaclang/cli/commands/impl/execution.impl.jac Makes invalid cross-project configuration discovery and profile overlays fatal while preserving warnings for lookup and filesystem failures.
jac/jaclang/cli/impl/dispatch.impl.jac Reuses centralized configuration-error handling during initial discovery and profile application.
jac/tests/cli/test_run_project_scope.jac Covers fatal base and overlay interpolation failures through the cross-project run path.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[CLI reads jac.toml] --> B[Recursively interpolate values]
    B --> C{Variable resolves?}
    C -->|Yes or default provided| D[Load configuration]
    C -->|No| E[Attach setting path]
    E --> F[CLI configuration-error handler]
    F --> G[Print invalid jac.toml error]
    G --> H[Exit with status 2]
    D --> I{Active profile?}
    I -->|Yes| J[Apply profile overlay]
    J --> B
    I -->|No| K[Continue command]
Loading

Reviews (4): Last reviewed commit: "test: fold config-load exit coverage int..." | Re-trigger Greptile

Comment thread jac/jaclang/project/impl/config.impl.jac
Comment thread jac/jaclang/cli/docs/reference/plugins/jac-client.md Outdated
@akindu-k

akindu-k commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator Author

Both valid. Reproduced and fixed.

Root cause is the same in both places I have now touched: "an unloadable jac.toml is fatal" was being re-implemented per call site, and each site got it slightly wrong. Rather than add a fourth copy, I extracted jaclang/cli/config_errors.jac:

def exit_on_config_error(exc: Exception) {
    if isinstance(exc, MalformedJacTomlError) {
        console.error(f"jac.toml is malformed: {exc.original}", hint=f"File: {exc.file_path}");
    } else {
        console.error(f"jac.toml is invalid: {exc}");
    }
    sys.`exit(2);
}

_discover_project_config, _apply_overlay_or_exit, _discover_config_from_file and _pin_run_scope all call it now. Dispatch loses its two local exit helpers, and the MalformedJacTomlError-before-ValueError ordering (needed because tomllib.TOMLDecodeError subclasses ValueError) is defined once instead of at every site.

The two file-target handlers keep warning on OSError/KeyError and only treat ValueError as fatal, so a permissions or lookup failure during discovery still degrades gracefully.

Confirmed before:

$ cd projA && jac run ../projB/src/main.jac      # projB has an unset ${GT_UNSET:?...}
⚠ Failed to discover config from .../projB/src: ...
Warning: Failed to discover config from .../projB/src: ...
Warning: Failed to discover config from .../projB/src: ...
✖ Error: Error executing 'run': ...
Traceback (most recent call last):
exit 1

After:

✖ Error: jac.toml is invalid: scale.kubernetes.namespace: Environment variable GT_UNSET is required: export GT_UNSET
exit 2

One nuance: jac check <file-in-another-project> does not route through these two handlers, so it surfaces the same error through its own per-file reporting (Error checking '...': scale.kubernetes.namespace: ..., exit 1). Not suppression and no traceback, so I left check's reporting contract alone.

Covered by a second case in tests/cli/test_invalid_config_exit.jac, driving _discover_config_from_file directly over ${VAR}, ${VAR:?msg} and ${VAR:-default}.

Nested token fallback

You are right, and it is worse than the report: the breakage is in the working case. With the variable set, ${NODE_AUTH_TOKEN:-${NODE_AUTH_TOKEN}} gives abc123}, a corrupted token, because ([^}]*) stops at the inner brace and the outer one is left behind.

UNSET -> '${NODE_AUTH_TOKEN}'
SET   -> 'abc123}'

That was my suggestion two commits ago and it was wrong, so I removed it rather than replace it with another workaround. Adding escape syntax to the interpolation parser is a real feature and does not belong in a bug fix, so the doc now states the situation plainly: the leave-it-to-npm pattern is gone, export NODE_AUTH_TOKEN before the command that reads jac.toml, ${VAR:-} is not a substitute because it writes an empty token and npm answers 401, and a genuinely optional registry should omit the [client.npm.auth] table instead of giving it a blank value.

Checks

run/check/fmt/build/clean/config show/test all exit 2 with no traceback on an unresolvable base file; malformed base and malformed overlay both still report is malformed, not is invalid; unresolvable overlay reports is invalid; ${VAR:-default}, --help and --version unaffected; cross-project happy path still runs. Suites: tests/cli/ 350 (348 on main, +2 for the new file), tests/project/ 324, tests/client/test_cli.jac 28.

@akindu-k

akindu-k commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator Author

Follow-up on P1: the main claim was fixed in 46b4680, but the second sentence of your comment ("active-profile overlays on this path can instead expose the same error without the clean invalid-config exit") was still true, and I had not covered it. Fixed in 5d9c261.

_discover_config_from_file guards the discovery call, but re-applies the active profile to the newly discovered config after the try block:

if config is not None {
    if active_profile {
        config.apply_profile_overlay(active_profile);   // unguarded
    }
    add_venv_to_path(config);
}

So a target project whose base file is fine but whose jac.<profile>.toml is not still ended in a traceback:

$ cd projA && JAC_PROFILE=prod jac run ../projB/src/main.jac
✖ Error: Error executing 'run': scale.kubernetes.namespace: Environment variable GT_PROD is required: export GT_PROD
Traceback (most recent call last):
exit 1

Grepping for the pattern found three unguarded overlay applications in execution.impl.jac (lines 56, 181 and 627: file-target discovery, run-scope pinning, and cwd re-discovery), against one guarded call in dispatch. So the overlay wrapper moved into config_errors.jac next to exit_on_config_error and all four call sites use it:

def apply_overlay_or_exit(config: any, profile: str | None) {
    try {
        config.apply_profile_overlay(profile);
    } except ValueError as exc {
        exit_on_config_error(exc);
    }
}

The project/ call sites are left raising, which is correct for a library; only the CLI converts to an exit.

Same command now:

✖ Error: jac.toml is invalid: scale.kubernetes.namespace: Environment variable GT_PROD is required: export GT_PROD
exit 2

Re-checked the whole P1 surface on the pushed commit: relative and absolute file targets, --no-takeover, and the base-file variant all give exit 2, zero tracebacks, zero warning lines, and the target does not run. Happy paths unaffected.

tests/cli/test_invalid_config_exit.jac has a third case driving a cross-project target whose overlay is unresolvable. Suites: tests/cli/ 351 (348 on main), tests/project/ 324, tests/client/test_cli.jac 28.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: ${VAR:?message} in jac.toml is silently inert -- the required-variable error is swallowed

4 participants