fix: Record task-emitted openjd_env macros for WrappedAction.Environment - #367
fix: Record task-emitted openjd_env macros for WrappedAction.Environment#367leongdl wants to merge 2 commits into
Conversation
RFC 0008 requires WrappedAction.Environment to include every openjd_env variable emitted by any earlier action in the session, "regardless of whether that action ran normally or via a wrap hook". A task's onRun and an onWrapTaskRun hook both run with no running-environment identifier, so _action_callback discarded their macros, and the next task's hook was handed an empty WrappedAction.Environment. Record such a macro in _session_env_vars, and remove it there on openjd_unset_env. _created_env_vars is deliberately untouched, so no child process environment changes and a task behaves the same wrapped or unwrapped. That reproduces the released openjd-rs behaviour: openjd-sessions 0.5.5 writes its cumulative env_vars for every SetEnv and passes that map to seed_wrapped_action_symbols, while evaluate_env_vars builds process environments from created_env_vars alone. Attributing the macro to the wrap environment instead would put it in later subprocess environments, which no openjd-rs version does. Malformed macros keep their current handling. Outside an environment there is no environment action to fail, so they are logged at debug and ignored rather than failing the task. Also fix a concurrency defect this change widens. _collect_session_env_list iterated _session_env_vars live while _action_callback writes it on the LoggingSubprocess stdout thread, raising "RuntimeError: dictionary keys changed during iteration" in 3 of 3 probe runs. The reader now iterates a copy. A lock would let this reader block the thread forwarding a live child's output. Verified against the upstream conformance fixture 2023-09/WRAP_ACTIONS/jobs/wrap-openjd-env-task-grand-child-visible-next-task, which fails before this change and passes after. Full sweeps: WRAP_ACTIONS 97/97, EXPR 356/356. Unit suite 957 passed, coverage 75%. The 8 new tests are mutation-checked against 10 mutants, all caught. For review: openjd_redacted_env reaches this callback as an ENV message, so a task's redacted export now also reaches WrappedAction.Environment and the wrap hook's argv. That matches both an environment's redacted export through this same map and released openjd-rs, and log redaction is unaffected. A test pins the behaviour, so excluding redacted values is a one-line change if preferred. Signed-off-by: David Leong <116610336+leongdl@users.noreply.github.com>
| return | ||
| # Assert for the type checker; the type is guaranteed by the ActionMonitoringFilter | ||
| assert isinstance(value, str) | ||
| self._session_env_vars.pop(value, None) |
There was a problem hiding this comment.
A task-emitted openjd_unset_env now removes the name from _session_env_vars even when an entered environment still owns it in _created_env_vars. That makes the two views disagree in the direction that breaks wrapped/unwrapped equivalence — the opposite direction from the SET branch above.
Concretely (this is exactly what test_task_emitted_unset_removes_only_the_named_variable pins): env Setter exports TASKEMIT_DOOMED, a task prints openjd_unset_env: TASKEMIT_DOOMED, and afterwards:
- an unwrapped task still gets
TASKEMIT_DOOMED=doomed-value(the test assertsSAW=[doomed-value]), because_created_env_varswas untouched; - a wrapped task no longer sees it, because
WrappedAction.Environmentis where the hook gets the variable list from, and the name is gone.
So a task can silently strip a session variable from every subsequent wrapped task while leaving it present for every unwrapped one. The SET branch is careful to avoid precisely this class of divergence ("a task behaves the same wrapped or unwrapped"); the UNSET branch introduces it.
The parity argument in the comment also does not obviously carry over: openjd-rs erasing its cumulative map is consistent there only because that map is not the sole source of the wrapped environment in the same way. If the intent is "a task cannot mutate the effective environment", then the safer reading is that an out-of-environment unset should only be able to remove a name that was itself recorded out-of-environment — i.e. not one an entered environment still holds. Otherwise this needs to be called out as a deliberate, spec-visible asymmetry.
There was a problem hiding this comment.
Correct on the facts, deliberately unfixed. Leaving this thread open, because the asymmetry is real and worth a maintainer decision rather than my judgement.
Everything you describe is what happens, and my own test asserts the pair: the unwrapped reader still gets SAW=[doomed-value] while the wrapped hook no longer sees the name.
Where I would push back is on it being this PR's divergence. I checked the released Rust implementation, which is the parity target the whole change is measured against: in openjd-sessions 0.5.5, UnsetEnv does self.env_vars.remove(&key) unconditionally, before and independently of the per-environment write, and self.env_vars is what seeds WrappedAction.Environment. So a task-emitted unset removes the name from the wrap symbol while created_env_vars keeps it in the process environment — the same split, in the same direction. Your suggested alternative, restricting an out-of-environment unset to names recorded out-of-environment, would be a deliberate divergence from that.
So the two candidate readings are "match the reference implementation" and "preserve wrapped/unwrapped equivalence for unset", and they conflict. I have taken the first because parity is what this PR is for, and because the SET branch's equivalence claim is about process environments, which no unset here touches. But you are right that it needs to be visible rather than implied: the UNSET branch comment names the parity, and I would rather the maintainers rule on whether the spec should say what an out-of-environment unset may remove. Happy to invert it in this PR if the answer is the second reading.
| return | ||
| # Assert for the type checker; the type is guaranteed by the ActionMonitoringFilter | ||
| assert isinstance(value, dict) | ||
| self._session_env_vars[value["name"]] = value["value"] |
There was a problem hiding this comment.
Security note on the widened SET branch: this path also receives openjd_redacted_env, because _handle_redacted_env re-dispatches as ActionMessageKind.ENV with a plain name/value dict (_action_filter.py:676). So a task printing openjd_redacted_env: SECRET=hunter2 now lands its cleartext value in _session_env_vars, and from there into WrappedAction.Environment, which a wrap hook interpolates into its own command/argv.
Before this change a task-emitted redacted export was dropped here, so this is a new exposure surface, not just a new symbol value. Two concrete leak channels worth checking before merging:
- argv is world-readable. A hook rendering
{{WrappedAction.Environment}}puts the cleartext into the child process command line, visible via/proc/<pid>/cmdlineto any process of the same uid (and topsfor the session user). The_redacted_valuesscrubber only covers the log stream, not process argv. _redacted_valuesonly helps if the exact substring survives.apply_message_redactiondoes a literalfind, so the value is scrubbed when a hook echoes it verbatim — but the "Running command ..." line in_subprocess.py:663goes throughredact_openjd_redacted_env_requests, which only redacts when the literal tokenopenjd_redacted_env:appears in the command line. A hook command that embeds the value (not the token) gets no protection from that helper and relies entirely on the filter having already seen the macro. Since the value is recorded on the stdout thread and the hook is launched from the caller thread, that ordering holds for a later task but is worth confirming, especially for the newly-reachable task-emitted case.
test_task_emitted_redacted_env_is_listed_like_any_other_export already flags this as a deliberate choice and invites inversion — this comment is to make sure the argv-exposure half of the tradeoff is on the record, since the test comment only argues about log redaction ("log redaction is unaffected"). Excluding redacted names from _session_env_vars (or recording a placeholder) would keep the RFC 0008 MUST satisfied for ordinary exports without pushing secrets through argv.
There was a problem hiding this comment.
Correct, and the argv half is a fair addition to the record. Leaving this open as a maintainer decision; I have not changed the behaviour, for one reason worth checking before you rule on it.
The argv exposure is not new with this PR. An environment's openjd_redacted_env export has always been recorded into _session_env_vars by the in-environment branch at line 2446, which pre-dates this change, so it already reaches WrappedAction.Environment and already lands in a hook's argv. What this PR adds is the task-emitted path to the same destination. Excluding redacted values for tasks only would leave two macros with the same name behaving differently depending on which action printed them, which I think is worse than the exposure; excluding them for both paths is defensible but is a behaviour change to existing sessions and a divergence from released openjd-rs, where RedactedEnv also writes the cumulative map that seeds the symbol.
Your second point is the sharper one and I had not verified the ordering, so thank you: redact_openjd_redacted_env_requests only fires on the literal openjd_redacted_env: token, so a hook embedding the value gets nothing from it and depends entirely on the filter having already registered the value. For the newly-reachable case that ordering does hold — _redacted_values.add happens in _handle_redacted_env on the stdout thread strictly before the callback that records the variable, so by the time any later hook can render it the value is registered — but that is an implicit dependency, not a guarantee anything asserts.
Your placeholder suggestion is the option I would pick if maintainers want the exposure closed: it satisfies the RFC MUST for ordinary exports without putting cleartext in argv. test_task_emitted_redacted_env_is_listed_like_any_other_export is where to invert it, and it is one line.
| rather than a lock, because the writer runs on the thread that forwards | ||
| a running child's output and must not be able to block on this reader. | ||
| """ | ||
| return [f"{name}={value}" for name, value in self._session_env_vars.copy().items()] |
There was a problem hiding this comment.
_session_env_vars is now growable from arbitrary task stdout, and it has no bound. Before this PR only an environment-entry action could add to it — a template-controlled, small set. Now any task that prints openjd_env: lines in a loop appends session-lifetime entries that are never reclaimed (environment exit deliberately does not remove them, per the docstring above), and _collect_session_env_list() renders all of them into WrappedAction.Environment.
Two consequences:
- Memory: a long-lived session with a chatty task grows this dict without limit.
dict.copy()on every wrap-hook launch also copies it in full each time. WrappedAction.Environmentbecomes a launch-failure vector: a hook that renders{{WrappedAction.Environment}}(as the RFC examples and the tests in this PR do) puts every entry into the child argv. Once the accumulated list crossesARG_MAX(~128 KiB per arg / 2 MiB total on Linux),Popenfails withE2BIGand every subsequent wrapped task in the session fails to start — not just the task that emitted the macros. A single misbehaving task can now poison the rest of the session for wrapped execution.
The PR's own concurrency test writes 20 000 entries into this map, so the scale is not hypothetical. Worth considering a cap on the number of task-emitted entries (or on total serialized size) with a warning when it is hit, since unlike the environment-entry path there is no template-side limit on how many a task can emit.
There was a problem hiding this comment.
Correct in mechanism, and I am not fixing it here. Leaving the thread open, since the concern outlives this PR.
The unbounded growth and the E2BIG path are both real, and the consequence you name — one task poisoning wrapped execution for the rest of the session — is the part worth taking seriously.
Two corrections to the scope, though. First, the vector is not new in kind: an environment's onEnter script can already emit openjd_env: in a loop, and those exports go into this same map through the pre-existing in-environment branch, so an unbounded session-lifetime map is reachable on mainline today. What this PR changes is which action can trigger it. Second, released openjd-rs has the same unbounded cumulative map feeding the same symbol, so capping here alone would diverge.
One nit on the evidence: the concurrency test's 20 000 entries were written directly to the dict by the test, not produced through the macro path, so they show the reader's cost rather than a production growth rate. That test has since been replaced with a deterministic one for unrelated reasons, so the number is gone either way.
A cap is the right idea and I would rather it be designed once, for both paths and both implementations, with a defined behaviour when it is hit — silently dropping exports and failing a hook at ARG_MAX are both bad, and choosing between "reject the macro" and "truncate the symbol" is a spec question. That is a follow-up issue rather than something to bolt onto a regression fix. Happy to file it with this analysis if you want it tracked.
…exception Two review findings on the tests, no production change. CodeQL flagged the concurrency test for catching BaseException. RuntimeError, the failure being pinned, is an Exception, so narrowing loses no coverage and clears the alert. Narrowing it exposed the larger problem: the test was not pinning anything. With the reader's dict.copy() removed, it passed 3 of 3 runs on its own. It had only failed inside the full class, where sibling tests supplied the contention that made the interleaving land inside the iteration -- so the earlier "mutant caught" result was an artifact of load, not of the assertion. Replaced with a deterministic version. A value whose __format__ inserts into the map stands in for the IO thread, so the write happens during the read by construction: iterating a snapshot tolerates it, iterating the live mapping raises on the next step. Now fails 3 of 3 against the un-snapshotted reader, and it also asserts the insert landed, so the value cannot be inert. The threaded version is gone rather than kept alongside, since it demonstrated the race without pinning it, and the docstring records that the original failure was observed with real threads. Signed-off-by: David Leong <116610336+leongdl@users.noreply.github.com>
| # task behaves the same wrapped or unwrapped. openjd-rs holds the | ||
| # same split: its cumulative env_vars feeds the wrap symbols, | ||
| # while evaluate_env_vars builds process environments from | ||
| # created_env_vars alone, so a task macro never reaches one. |
There was a problem hiding this comment.
The comment here claims "a task behaves the same wrapped or unwrapped," but the SET branch breaks that in the overwrite direction — and the PR's own test_task_export_does_not_displace_an_environments_value_in_a_child pins the divergence:
- an unwrapped task gets
TASKEMIT_BOTH=from-env(the test assertsSAW=[from-env]), because_created_env_varsis untouched; - a wrapped task is handed
TASKEMIT_BOTH=from-taskinWrappedAction.Environment, because_session_env_vars[name] = valueat line 2452 overwrites unconditionally and_collect_session_env_list()renders the effective value.
So this is the same class of wrapped/unwrapped divergence as the UNSET branch below, not the careful counterexample to it. Worth correcting the comment at minimum.
The part that concerns me more than the docstring is the reachability change. Before this PR a task could not write _session_env_vars at all, so WrappedAction.Environment was entirely template-controlled: declarative variables: plus environment-entry openjd_env. Now any task's stdout can define or redefine any name in it, and the RFC 0008 examples (and the hook in test_wrap_hook_export_reaches_the_next_tasks_wrapped_environment) render that symbol straight into the hook's command. A hook shaped like env {{WrappedAction.Environment}} -- {{WrappedAction.Command}} therefore lets task A set LD_PRELOAD, PATH, PYTHONPATH, or NODE_OPTIONS for every subsequent wrapped task in the session, including tasks from other steps — a cross-task influence channel that does not exist for unwrapped execution and did not exist before this change.
Note this is not the same as the redacted-value/argv point raised separately: that one is about a secret's cleartext transiting argv, this one is about a task choosing the names and values another task's wrapped launch is configured with. Even a hook that does not itself interpolate the symbol into argv is affected if it forwards the list into the child's real environment, which is the documented purpose of the symbol.
RFC 0008's MUST is about inclusion of earlier openjd_env variables; it does not obviously require that a task-emitted name be allowed to shadow a name an entered environment owns in _created_env_vars. Restricting the outside-environment write to names not currently owned by any entered environment (or recording it under a separate task-emitted layer that loses to _created_env_vars when _collect_session_env_list() merges) would satisfy the MUST while keeping the two views from contradicting each other. If shadowing is intended, it deserves to be called out as a spec-visible, security-relevant asymmetry rather than asserted as equivalence in a comment.
Fixes: no GitHub issue; found by the upstream conformance fixture named below
What was the problem/requirement? (What/Why)
RFC 0008 requires
WrappedAction.Environmentto carry everyopenjd_envexport from any earlier action in the session, "regardless of whether that action ran normally or via a wrap hook" (rfcs/0008-environment-wrap-actions.md:447-450).A task's
onRun, and anonWrapTaskRunhook that replaces it, run with_running_environment_identifierset toNone, becauserun_taskcalls_reset_action_state(). TheENVandUNSET_ENVbranches of_action_callbackdiscarded the macro in that case, so the next task's hook received an emptyWrappedAction.Environment.This was deliberate in 0.12.1: the comment at
_session.py:2409-2424cited the base wiki rule thatopenjd_env"can only be emitted by the Action for entering an Environment", called the behaviour a known divergence from openjd-rs, and noted that no conformance fixture covered it. That fixture now exists.Example template, and where it breaks
Two tasks. Each
onRunexports a variable. The wrap hook prints the environment it was handed, then runs the wrapped command so the task's macro is forwarded through its stdout.Task 2's hook must print
WAENV=TASK1_VAR=set-by-task-1. Before this change it printed nothing:The macro is visibly parsed and forwarded. It is the recording that was skipped, so this is not a plumbing or buffering problem.
Upstream fixture, on branch
conformance-wrap-actions-gaps:conformance-tests/2023-09/WRAP_ACTIONS/jobs/wrap-openjd-env-task-grand-child-visible-next-task.test.yaml.What was the solution? (How)
Record such a macro in
_session_env_vars, and remove it there onopenjd_unset_env. Leave_created_env_varsuntouched, so the environment of every child process is unchanged and a task behaves the same wrapped or unwrapped.That reproduces the released openjd-rs behaviour rather than inventing one. In openjd-sessions 0.5.5 (shipped in openjd-cli 0.1.14, which passes this fixture),
apply_messagewrites the cumulativeenv_varsfor every SetEnv regardless of which action is running, and that map is thesession_env_varsargument toseed_wrapped_action_symbols(session.rs:1262,:1534,:1784). Process environments come fromevaluate_env_vars, which readscreated_env_varsalone:WrappedAction.EnvironmentThe rejected column is why
_created_env_varsis left alone: it has no counterpart in any openjd-rs version and would let one task's stdout mutate sibling tasks' environments.Walkthrough of the fix
1.
_action_callback,ENVbranch. TheNoneguard now records instead of discarding. Malformed payloads keep the old path:cancel_action_mark_failedis checked first, because the filter pairs that flag with a parse-errorstrrather than the name/value dict, and because outside an environment there is no environment action to fail — failing the task instead would be a behaviour change for existing jobs.2.
UNSET_ENVbranch. The mirror:popthe name. An unset is the one remover from this map, matching openjd-rs, whereUnsetEnverases the cumulative map whether or not an environment declared the name.3.
_collect_session_env_list, a concurrency defect this change widens. The reader iterated_session_env_varslive while_action_callbackwrites it on the LoggingSubprocess stdout thread. That raisesRuntimeError: dictionary keys changed during iteration— reproduced 3 of 3 probe runs before, 0 of 4 after. The hazard pre-existed for environment-emitted macros, but this change adds writes on the task path, which is exactly when this list is built foronWrapTaskRun. The reader now iterates adict.copy(). Not a lock: the writer runs on the thread forwarding a live child's output and must not be able to block on the reader. An inline comment asserting there was "no new hazard" was wrong and is gone.4.
_log_discarded_env_macro. Now reached only for malformed macros, so its docstring and message say that. Itsisinstance(value, dict)branch became unreachable — both call sites are gated oncancel_action_mark_failed, where the payload is always astr— so it is deleted rather than left as dead code with a passing test.5. Docstrings.
_session_env_varsgains the routing rule and a pointer to read openjd-rs at its released tag, notmain: openjd-rs #362 repointed wrap-hook seeding atlive_session_env_vars(), somaincurrently fails this same fixture. That is tracked as an openjd-rs regression, with the fix in openjd-rs#372.What is the impact of this change?
WrappedAction.Environmentgains variables a task exported. Real process environments are unchanged, so unwrapped jobs and every existing subprocess see exactly what they saw before.One consequence to review deliberately:
openjd_redacted_envreaches_action_callbackas anENVmessage with a name/value dict (_action_filter.py:675), so a task's redacted export is now recorded like a plain one and reaches the hook's argv. That matches what an environment's redacted export has always done through this same map, and matches released openjd-rs, whereRedactedEnvalso writes the cumulative map. Log redaction is unaffected — the filter holds the value in_redacted_valueseither way. A test pins the behaviour, so excluding redacted values is a one-line inversion if you prefer it.How was this change tested?
test_subprocess.pyhas 6 failures intest_run_gracetime_when_process_ends_but_grandchild_uses_stdout; they reproduce with the source change reverted, so they are a pre-existing macOS environment problem.test/openjd/sessions_v0/test_wrap_actions.py, checked against 10 mutants: both record paths, the unset path,popreplaced byclear(), the malformed path in both directions, three ways of leaking into_created_env_vars, keying on the wrong field, and the live-dict read. All 10 are caught.@serial_processand uses variable names unique to it. Three of the 10 mutants survived a first draft of these tests, includingpop→clear(), which would let one task's unset wipe every session variable.sh -candas_posix()conventions as the rest of this file, which Windows CI runs, but they were run on macOS only.Was this change documented?
Yes. The
_session_env_varsattribute docstring states the routing rule and the openjd-rs comparison._collect_session_env_listdocuments why it snapshots, and_log_discarded_env_macrodocuments its narrowed role. The stale comment claiming the discard was intentional is replaced by the RFC citation that reverses it.Is this a breaking change?
No. No public interface changes. The behaviour change is confined to the
WrappedAction.Environmentsymbol, which gains entries it was required to have.Does this change impact security?
Worth a look, and flagged above rather than buried: a task's
openjd_redacted_envexport now reachesWrappedAction.Environment, and a wrap hook interpolating that symbol puts the value in its argv, readable by other processes on the host. This is consistent with the pre-existing behaviour for an environment's redacted export and with released openjd-rs, and log redaction is unaffected. If maintainers want redacted values kept out of the symbol, that is a deliberate divergence from openjd-rs and the pinning test is where to invert it.By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.