From 253120997a33e1c021fb7c88609a78ef84f8b1de Mon Sep 17 00:00:00 2001 From: Andreas Trawoeger Date: Sat, 24 Oct 2026 21:52:30 +0200 Subject: [PATCH] feat(task): a task's resolved values reach a step's process environment as DATA MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A task's `params:` are substituted into a plan step's TEXT, and the shell then PARSES that text. A value that is DATA therefore becomes SOURCE: a `$(…)` inside a value EXECUTES. The text channel cannot fix that — the value has to arrive by a route the shell does not re-parse. The environment is that route. A value delivered as an environment variable is DATA, because the shell never re-parses the result of an expansion, and the command verb applies the step's env as a shell-QUOTED export, so the value arrives as ONE word whatever it contains. This change opens that channel: the task's RESOLVED values — its `vars`, its `env`, its resolved `params` and `TASK_DIR` — are handed to the SDK as `kit.RunnerConfig.StepEnv`, and the SDK's `kit.MergeStepEnv` merges them into every step's environment. Three rules bound the channel, and each is a rule the SDK's own merge already owns, so the plugin adds no second implementation of any of them: - the step's OWN `env:` WINS a name collision — the author spoke last about their own step, and the task's value is a default; - a name the shell cannot export (outside `[A-Za-z_][A-Za-z0-9_]*`) is REJECTED and RETURNED, and the SDK renders it as a NOTE on the step's message ("not exported (not a valid shell identifier): …") — disclosed, never dropped silently; - a piped stdin is not a resolved value, so it is NOT exported. The map is CLONED before `captureStdin` mutates it, because the command verb renders an exported value into the command TEXT, and a NUL byte (a piped binary stream carries one) would make the exec un-runnable ("fork/exec /usr/bin/sh: invalid argument"). The stream keeps its own channel: `TASK_STDIN` / `TASK_STDIN_FILE` stay reachable through the text expansion they have always had. A lowercase param is a second, independent win: the expander's grammar is uppercase-only, so a lowercase param was previously unreachable from a step at all. The environment is what makes it reachable BY NAME. The evidence is a live arm, not a unit test alone. `scripts/gate-task.sh` grew arm 1c: a task whose param carries `$(touch )` runs against a real charly binary, and the arm asserts BOTH outcomes — the value arrives VERBATIM in the step's environment, AND the marker file does not exist. The silent outcome is the failure this channel exists to remove, so it is asserted too. `candy/plugin-task/stepenv_test.go` covers the same contract at unit level: the export, the collision rule, the disclosure of a non-exportable name, and the stdin exclusion. The pin bump also made ONE line of this repo's coherence control stale: its header comment read "Measured at charly v2026.267.2313 (pinning sdk v0.2026266.1111 against this module's v0.2026276.1822)", and "this module's v0.2026276.1822" named the pin this module no longer carries. The measurement is history and stays; the false present-tense claim does not, so the clause now reads "against a module pinning sdk v0.2026276.1822 - the pin this module carried at the time of that measurement". The control's self-test fixtures keep their versions on purpose: they are SYNTHETIC (a leading pair, a lagging pair, an absent pin) and do not read this module's live pins, so they still prove the comparator can pass and can fail. Producer-first: the DATA channel is an SDK capability (`kit.StepEnvExporter`, `kit.RunnerConfig.StepEnv`, `kit.MergeStepEnv`, `opencharly/sdk#475`, released as `sdk v0.2026297.1851`), and this module's `require` therefore rises to it. This repo's own coherence control (`scripts/check-coherence.sh`, run by the `coherence` CI job on every PR) asserts that charly's `sdk`/`spec` pins LEAD this module's requires, so the producer leg had to land first — and it has: `opencharly/charly#1062` merged as `87f6302456f9c821877e719cbe90615368cd85bc` with the tag `v2026.297.1957`, and charly's `main` now pins `sdk v0.2026297.1851` / `spec v0.2026296.1036`. The live gate below was run against that branch (`CHARLY_REF=feat/sdk-pin-1851`) and against `main`, which is the configuration that ships. The tier is `analysed on a live system`, not `fully tested and validated`: this repo's own gate (build, test, vet, gofmt, the linter, `charly box validate`) and its live gate (a real charly binary with this candy compiled in, driving the changed path) both ran on the committed tree, and the changed path ran live — but the FULL assembled bed is charly's, and by the producer-first order it cannot run on this head until this change carries a tag that charly can pin. Assisted-by: Claude Code minimax/minimax-m3.1-flash:cloud (analysed on a live system) --- README.md | 38 ++++++ candy/plugin-task/go.mod | 26 ++-- candy/plugin-task/go.sum | 107 +++++++++++----- candy/plugin-task/runner.go | 23 ++++ candy/plugin-task/stepenv_test.go | 201 ++++++++++++++++++++++++++++++ scripts/check-coherence.sh | 3 +- scripts/gate-task.sh | 48 +++++++ 7 files changed, 401 insertions(+), 45 deletions(-) create mode 100644 candy/plugin-task/stepenv_test.go diff --git a/README.md b/README.md index 8eece17..4b6af8d 100644 --- a/README.md +++ b/README.md @@ -185,6 +185,44 @@ charly task list --json # every declared task as JSON charly task --all # every task in dependency order ``` +## A param reaches a step as DATA + +A step's command runs through a shell, so anything substituted into the command's +**text** is parsed by that shell: a `$(…)` inside a value EXECUTES. A value that is +DATA must not be delivered as SOURCE. + +So a task's **resolved values** — its `vars`, its `env`, its `params` (declared +defaults overlaid with `--param` overrides) and the task's `TASK_DIR` — also reach +every step as the step's **process environment**: + +```yaml +publish: + task: + description: Publish a build + params: + out: {description: the artifact path} + plan: + - run: write the artifact + command: 'printf "%s" "$out" > "$TASK_DIR/artifact"' +``` + +`$out` is read by the **shell** (an environment variable), so the value arrives as +one value whatever it contains — a space, a `;`, a `$(…)` — and the shell never +re-parses it. A param whose name is lowercase is reachable this way; the `${…}` +text form is upper-case-only, so a lowercase param was previously unreachable from a +step. + +Three rules bound the channel: + +- **the step's own `env:` wins** a name collision — the author spoke last about + their own step, and the task's value is a default; +- **a name the shell cannot export** (outside `[A-Za-z_][A-Za-z0-9_]*`) is + **disclosed** on the step's message, never dropped silently; +- **a piped stdin is not a value** — `TASK_STDIN` / `TASK_STDIN_FILE` stay + reachable through the text form they have always had, and are not exported, + because the command verb renders an exported value into the command text and a + NUL byte would make the exec un-runnable. + ## Full Go-Task parity `description`, `dir`, `env`, `vars`, `depends_on`, `sources`, `generates`, `status`, diff --git a/candy/plugin-task/go.mod b/candy/plugin-task/go.mod index 6869422..75d38c1 100644 --- a/candy/plugin-task/go.mod +++ b/candy/plugin-task/go.mod @@ -1,10 +1,10 @@ module github.com/opencharly/plugin-task/candy/plugin-task -go 1.26.4 +go 1.26.8 require ( - github.com/opencharly/sdk v0.2026276.1822 - github.com/opencharly/spec v0.2026276.1636 + github.com/opencharly/sdk v0.2026297.1851 + github.com/opencharly/spec v0.2026296.1036 golang.org/x/term v0.45.0 ) @@ -12,6 +12,9 @@ require ( cuelang.org/go v0.16.1 // indirect github.com/alecthomas/kong v1.15.0 // indirect github.com/cockroachdb/apd/v3 v3.2.1 // indirect + github.com/containerd/containerd/v2 v2.4.1 // indirect + github.com/containerd/errdefs v1.0.0 // indirect + github.com/containerd/log v0.2.0 // indirect github.com/emicklei/dot v1.11.0 // indirect github.com/emicklei/proto v1.14.3 // indirect github.com/expr-lang/expr v1.17.8 // indirect @@ -27,18 +30,19 @@ require ( github.com/oklog/run v1.1.0 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect - github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/pelletier/go-toml/v2 v2.4.3 // indirect github.com/protocolbuffers/txtpbfmt v0.0.0-20260217160748-a481f6a22f94 // indirect github.com/robfig/cron/v3 v3.0.1 // indirect + github.com/sirupsen/logrus v1.10.2 // indirect github.com/tidwall/gjson v1.19.0 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect - go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/net v0.57.0 // indirect - golang.org/x/sys v0.47.0 // indirect - golang.org/x/text v0.40.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20231106174013-bbf56f31fb17 // indirect - google.golang.org/grpc v1.61.0 // indirect - google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect + go.yaml.in/yaml/v3 v3.0.5 // indirect + golang.org/x/net v0.58.0 // indirect + golang.org/x/sys v0.48.0 // indirect + golang.org/x/text v0.42.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260825221802-da73d73af1c5 // indirect + google.golang.org/grpc v1.83.2 // indirect + google.golang.org/protobuf v1.36.12 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/candy/plugin-task/go.sum b/candy/plugin-task/go.sum index 62681fb..4c7a0db 100644 --- a/candy/plugin-task/go.sum +++ b/candy/plugin-task/go.sum @@ -2,6 +2,10 @@ cuelabs.dev/go/oci/ociregistry v0.0.0-20251212221603-3adeb8663819 h1:Zh+Ur3OsoWp cuelabs.dev/go/oci/ociregistry v0.0.0-20251212221603-3adeb8663819/go.mod h1:WjmQxb+W6nVNCgj8nXrF24lIz95AHwnSl36tpjDZSU8= cuelang.org/go v0.16.1 h1:iPN1lHZd2J0hjcr8hfq9PnIGk7VfPkKFfxH4de+m9sE= cuelang.org/go v0.16.1/go.mod h1:/aW3967FeWC5Hc1cDrN4Z4ICVApdMi83wO5L3uF/1hM= +github.com/Microsoft/go-winio v0.6.3-0.20251027160822-ad3df93bed29 h1:0kQAzHq8vLs7Pptv+7TxjdETLf/nIqJpIB4oC6Ba4vY= +github.com/Microsoft/go-winio v0.6.3-0.20251027160822-ad3df93bed29/go.mod h1:ZWa7ssZJT30CCDGJ7fk/2SBTq9BIQrrVjrcss0UW2s0= +github.com/Microsoft/hcsshim v0.15.0-rc.4 h1:aZFX4LH0S20Lgjq0wG61StIClj7im4yzrxIClkaR8Z8= +github.com/Microsoft/hcsshim v0.15.0-rc.4/go.mod h1:BA9CBztgu4h/6Jsvo1O1M4qjWw09PoYpaEYgexPE578= github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= github.com/alecthomas/kong v1.15.0 h1:BVJstKbpO73zKpmIu+m/aLRrNmWwxXPIGTNin9VmLVI= @@ -10,12 +14,24 @@ github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= github.com/bufbuild/protocompile v0.14.1 h1:iA73zAf/fyljNjQKwYzUHD6AD4R8KMasmwa/FBatYVw= github.com/bufbuild/protocompile v0.14.1/go.mod h1:ppVdAIhbr2H8asPk6k4pY7t9zB1OU5DoEw9xY/FUi1c= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cockroachdb/apd/v3 v3.2.1 h1:U+8j7t0axsIgvQUqthuNm82HIrYXodOV2iWLWtEaIwg= github.com/cockroachdb/apd/v3 v3.2.1/go.mod h1:klXJcjp+FffLTHlhIG69tezTDvdP065naDsHzKhYSqc= +github.com/containerd/cgroups/v3 v3.1.3 h1:eUNflyMddm18+yrDmZPn3jI7C5hJ9ahABE5q6dyLYXQ= +github.com/containerd/cgroups/v3 v3.1.3/go.mod h1:PKZ2AcWmSBsY/tJUVhtS/rluX0b1uq1GmPO1ElCmbOw= +github.com/containerd/containerd/api v1.12.0 h1:kuQm82SbDrCuO4n7hf2L8zsBtZLuympyq5X/VotfX2A= +github.com/containerd/containerd/api v1.12.0/go.mod h1:EBcSzoi9Vl18cdODaXUCskf3D2NT8lsSXeZJnU5jIUc= +github.com/containerd/containerd/v2 v2.4.1 h1:DUx/ZJN7cEu0WuzHClDB+68H/bqMEH5pWoEjf0ae4hc= +github.com/containerd/containerd/v2 v2.4.1/go.mod h1:vgLdtvl3prFk1d3ZVqQcsDD5Q9IKM+vAIdU54U85w+I= +github.com/containerd/continuity v0.5.0 h1:7a85HZpCSs+1Zps0Ee3DPSuAWY+0SJM1JNM51nlEVDg= +github.com/containerd/continuity v0.5.0/go.mod h1:/lNJvtJKUQStBzpVQ1+rasXO1LAWtUQssk28EZvJ3nE= +github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/log v0.2.0 h1:BewD/umNgVnoczglOpX8eRMyEy5t5iPlu5AIpnWDONc= +github.com/containerd/log v0.2.0/go.mod h1:/M7L7CXKcPTfNC74XzaK+5H5KbO5+4lJVpuVI6vRLoM= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= -github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/emicklei/dot v1.11.0 h1:zsrhCuFHAJge/aZIC4N4LdHy5tqYu4tWEaUzIwdYj4Y= github.com/emicklei/dot v1.11.0/go.mod h1:DeV7GvQtIw4h2u73RKBkkFdvVAz0D9fzeJrgPW6gy/s= github.com/emicklei/proto v1.14.3 h1:zEhlzNkpP8kN6utonKMzlPfIvy82t5Kb9mufaJxSe1Q= @@ -25,6 +41,10 @@ github.com/expr-lang/expr v1.17.8/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40 github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= github.com/fatih/color v1.15.0 h1:kOqh6YHBtK8aywxGerMG2Eq3H6Qgoqeo13Bk2Mv/nBs= github.com/fatih/color v1.15.0/go.mod h1:0h5ZqXfHYED7Bhv2ZJamyIOUej9KtShiJESRwBDUSsw= +github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= +github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI= github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= @@ -62,72 +82,93 @@ github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPn github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0= github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= +github.com/moby/sys/mountinfo v0.7.2 h1:1shs6aH5s4o5H2zQLn796ADW1wMrIwHsyJ2v9KouLrg= +github.com/moby/sys/mountinfo v0.7.2/go.mod h1:1YOa8w8Ih7uW0wALDUgT1dTTSBrZ+HiBLGws92L2RU4= +github.com/moby/sys/userns v0.2.1 h1:4OvdM7BcPkASbuouHsbW3aeMJSFlYDldBRnXVZhaRk8= +github.com/moby/sys/userns v0.2.1/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= github.com/oklog/run v1.1.0 h1:GEenZ1cK0+q0+wsJew9qUg/DyD8k3JzYsZAi5gYi2mA= github.com/oklog/run v1.1.0/go.mod h1:sVPdnTZT1zYwAJeCMu2Th4T21pA3FPOQRfWjQlk7DVU= github.com/opencharly/plugin-egress/candy/plugin-egress v0.2026237.1419 h1:n3aw1FXKRwiGXHYWn3sJhxdIi75/c++3czIszZBbITc= github.com/opencharly/plugin-egress/candy/plugin-egress v0.2026237.1419/go.mod h1:5Uz4H7GYjW9WS8yPJGlr+dRv/jrR985d+PbaxJeAJZ8= github.com/opencharly/plugin-init/candy/plugin-init v0.2026237.1424 h1:Bldh1DoESB1OhP9itppLcViak/z/INZRIFv+Nm28KNg= github.com/opencharly/plugin-init/candy/plugin-init v0.2026237.1424/go.mod h1:tzB4pMZllnxK6Jpyeq5jxg4EQe1p544rQkhNdmPn788= -github.com/opencharly/sdk v0.2026276.1822 h1:HLqIt7+qGWkhVyvR8D03R87yDEGXKNaFFfz3ggatPgQ= -github.com/opencharly/sdk v0.2026276.1822/go.mod h1:Oo+hxnhcA5RgCWvKVffKCucVJirTY0tpMGve/Da6Sm4= -github.com/opencharly/spec v0.2026276.1636 h1:cGNaTzOAXZ1+ysC+75Wejz3BS1cYCTaL7xJcQHTZ790= -github.com/opencharly/spec v0.2026276.1636/go.mod h1:VrjzjP2glMbDwP4JNm50OV0MraHFZnFKTl+bVlouoso= +github.com/opencharly/sdk v0.2026297.1851 h1:E85eZNFjzx/Fy7KqGyM4uDvW3fuJiKzkAqMXYMpuovA= +github.com/opencharly/sdk v0.2026297.1851/go.mod h1:JATa64xjMSgqEdOhRRm4iiptgfaetvmHOwybt9kKZko= +github.com/opencharly/spec v0.2026296.1036 h1:F3Up2zG5hLw2SFVOR772cMQ1JegU/+HjoK8dOPGR/AU= +github.com/opencharly/spec v0.2026296.1036/go.mod h1:VrjzjP2glMbDwP4JNm50OV0MraHFZnFKTl+bVlouoso= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= -github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= -github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY= +github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= -github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/protocolbuffers/txtpbfmt v0.0.0-20260217160748-a481f6a22f94 h1:2PC6Ql3jipz1KvBlqUHjjk6v4aMwE86mfDu1XMH0LR8= github.com/protocolbuffers/txtpbfmt v0.0.0-20260217160748-a481f6a22f94/go.mod h1:JSbkp0BviKovYYt9XunS95M3mLPibE9bGg+Y95DsEEY= github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/sirupsen/logrus v1.10.2 h1:G2SED73/qrAu6YwbdxOD6peLkCBI3z7L+ykJFTXJBBo= +github.com/sirupsen/logrus v1.10.2/go.mod h1:SLEg8TqYulVKKfIGHldVp2K2aYz2DKSVBq4g/H5bR7Q= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.7.2 h1:4jaiDzPyXQvSd7D0EjG45355tLlV3VOECpq10pLC+8s= github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= github.com/tidwall/gjson v1.19.0 h1:xwxm7n691Uf3u5OFjzngavjGTh55KX5q/9w9xHW88JU= github.com/tidwall/gjson v1.19.0/go.mod h1:V37/opeE/JbLUOfH0QTXiNez2l0RUjYUhpT4szFQAfc= github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= -go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= -go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= -golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= -golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= -golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= -golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= -golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.46.0 h1:FHt5/CDyVxi/8IM1CH7VE/rRgq3kLHa2mSTVMO8AWyc= +go.opentelemetry.io/otel v1.46.0/go.mod h1:Gj3SEScelsNC45tp4nSxRYlS+f5iez7W8XPMCt905kE= +go.opentelemetry.io/otel/metric v1.46.0 h1:yBnkXvgV7AXFILZc5K6IZe/CBFF3OS7BJ8ov6/lj0K8= +go.opentelemetry.io/otel/metric v1.46.0/go.mod h1:iPmdWqifKUdzziPkvvzIJXITl56fQx2mGM/DHLB3/2o= +go.opentelemetry.io/otel/sdk v1.46.0 h1:h5CNQQjEbuQXY/JfZtgt3i7HVFV3aHPO2OAwO2eTYPI= +go.opentelemetry.io/otel/sdk v1.46.0/go.mod h1:GAERFXFt5SYCEB+YiKUbMBeza6UaDH7GmGOZEfh2gSM= +go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= +go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= +go.opentelemetry.io/otel/trace v1.46.0 h1:OULy7ccdJnZtJ0UDYFOIGaCmiWzJ8Vi2G/Rsu60qs1c= +go.opentelemetry.io/otel/trace v1.46.0/go.mod h1:J7GAXweO77XSFkB/rmAqk9D6ihszhFjLU+d9WuUxDLI= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= +golang.org/x/crypto v0.56.0 h1:GUh5Ii4J5jtcseSMiRqr1jXCNHoxjeV9Fmekc2oLy6Y= +golang.org/x/crypto v0.56.0/go.mod h1:OMW5y6CY9l38uPLmxU6l6pwcXp1obtLo3e6gT7gQR2I= +golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c= +golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o= +golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= +golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= -golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= -golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= +golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= -golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= -golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= -golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= -golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= -golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= -google.golang.org/genproto/googleapis/rpc v0.0.0-20231106174013-bbf56f31fb17 h1:Jyp0Hsi0bmHXG6k9eATXoYtjd6e2UzZ1SCn/wIupY14= -google.golang.org/genproto/googleapis/rpc v0.0.0-20231106174013-bbf56f31fb17/go.mod h1:oQ5rr10WTTMvP4A36n8JpR1OrO1BEiV4f78CneXZxkA= -google.golang.org/grpc v1.61.0 h1:TOvOcuXn30kRao+gfcvsebNEa5iZIiLkisYEkf7R7o0= -google.golang.org/grpc v1.61.0/go.mod h1:VUbo7IFqmF1QtCAstipjG0GIoq49KvMe9+h1jFLBNJs= -google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af h1:+5/Sw3GsDNlEmu7TfklWKPdQ0Ykja5VEmq2i817+jbI= -google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= +golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= +golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI= +golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260825221802-da73d73af1c5 h1:1VUiZAXyC+zmiFYi+WLtBzr68Cj8wOofHjjrA/kkizc= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260825221802-da73d73af1c5/go.mod h1:DjtHYE8FKJLivXcBEjGwndXfIC23G0VpXiXKqG179uA= +google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU= +google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8= +google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= +google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/candy/plugin-task/runner.go b/candy/plugin-task/runner.go index 4ae2508..82483c6 100644 --- a/candy/plugin-task/runner.go +++ b/candy/plugin-task/runner.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "io" + "maps" "os" "os/exec" "strings" @@ -220,11 +221,32 @@ var verbResolverFor = func(ex *sdk.Executor) kit.VerbResolver { // vars + resolved params + env for ${VAR} expansion, plus the captured-stdin // exposure when stdin is a pipe. // +// The same merged env is handed over as the DATA channel (kit.RunnerConfig.StepEnv): +// the task's RESOLVED values reach the step's PROCESS ENVIRONMENT, so a value is +// DATA and never shell SOURCE. See the clone below. +// // It also returns the stdinCapture whose temp file the caller MUST remove once the // walk is done (runTask defers stdin.cleanup()). func newTaskRunner(ex *sdk.Executor, projDir string, t spec.Task, params map[string]string) (*kit.Runner, stdinCapture) { env := workflowkit.MergedEnv(t, params) env["TASK_DIR"] = workflowkit.ResolveTaskDir(projDir, t.Dir, env) + // The DATA channel — the task's RESOLVED values as the step's process environment. + // + // The TEXT channel (the expander) substitutes a value into a step's command TEXT, and + // the shell then PARSES that text, so a value that is DATA becomes SOURCE: a `$(…)` + // inside a value EXECUTES (opencharly/plugin-task#34). The environment is the other + // channel: a value delivered as an environment variable is DATA, because the shell + // never re-parses the result of an expansion. The command verb applies the step's + // env as a shell-QUOTED export, so the value arrives as one word whatever it + // contains. + // + // The map is CLONED here, BEFORE captureStdin mutates env: the piped bytes are a + // runtime STREAM, not a resolved value, and the command verb renders an exported + // value into the command TEXT — where a NUL byte (a piped binary stream carries + // one) would make the exec un-runnable ("fork/exec /usr/bin/sh: invalid argument"). + // The stream keeps its own channel: TASK_STDIN / TASK_STDIN_FILE stay reachable + // through the text expansion they have always had. + stepEnv := maps.Clone(env) // Stdin is read ONLY when the plan declares it consumes it. The read is bounded by // EOF alone, so an inherited OPEN pipe — a harness, `bash -c`, any parent that // neither writes nor closes — would block the walk forever, and `charly task` would @@ -247,6 +269,7 @@ func newTaskRunner(ex *sdk.Executor, projDir string, t spec.Task, params map[str Exec: kit.ShellExecutor{}, Mode: kit.ModeLive, Env: env, + StepEnv: stepEnv, Verbs: verbs, Grammar: taskGrammar{base: checkkit.PlanGrammar{}}, }) diff --git a/candy/plugin-task/stepenv_test.go b/candy/plugin-task/stepenv_test.go new file mode 100644 index 0000000..0751889 --- /dev/null +++ b/candy/plugin-task/stepenv_test.go @@ -0,0 +1,201 @@ +package task + +import ( + "context" + "os" + "strings" + "testing" + + "github.com/opencharly/sdk" + "github.com/opencharly/sdk/kit" + "github.com/opencharly/spec/spec" +) + +// stepenv_test.go — the DATA channel: the task's RESOLVED values reach the step's +// PROCESS ENVIRONMENT as data, so a value is never parsed by a shell on its way to a +// command (opencharly/plugin-task#34). +// +// The TEXT channel (the expander) substitutes a value into a step's command TEXT and +// the shell then PARSES that text, so a value that is DATA becomes SOURCE: a `$(…)` +// inside a value EXECUTES. The environment is the other channel — a value delivered +// as an environment variable is DATA, because the shell never re-parses the result of +// an expansion. +// +// What these tests prove is the HANDOVER and the merge, on the real plan walk. The +// SHELL half — that a value carrying a metacharacter arrives verbatim and the shell +// did not execute it — is proved LIVE, in scripts/gate-task.sh, because a unit test +// with a fake verb has no shell to fool. + +// envCapturingResolver records the env the plan walk handed the step's verb, so a +// test can see exactly what the step's PROCESS ENVIRONMENT would carry. +type envCapturingResolver struct { + env map[string]string + seen bool +} + +func (r *envCapturingResolver) RunVerb(_ context.Context, op *spec.Op) (spec.CheckResult, bool) { + if op.Plugin == "command" { + r.env = op.Env + r.seen = true + return spec.CheckResult{Status: spec.StatusPass}, true + } + return spec.CheckResult{}, false +} + +func (r *envCapturingResolver) RunProvisionAct(_ context.Context, _ *spec.Op, _ string) (spec.CheckResult, bool) { + return spec.CheckResult{}, false +} + +// captureStepEnv drives the REAL plan walk over a one-step task and returns the env +// the step's verb received. +func captureStepEnv(t *testing.T, task spec.Task, params map[string]string) (map[string]string, *runResult) { + t.Helper() + old := verbResolverFor + cap := &envCapturingResolver{} + verbResolverFor = func(*sdk.Executor) kit.VerbResolver { return cap } + defer func() { verbResolverFor = old }() + + ts := &taskSet{dir: t.TempDir(), tasks: map[string]spec.Task{"t": task}} + res, err := runTask(context.Background(), nil, ts, "t", params, false, false) + if err != nil { + t.Fatalf("runTask: %v", err) + } + if !cap.seen { + t.Fatalf("the plan walk never dispatched the step's verb — the walk did not run") + } + return cap.env, res +} + +// TestNewTaskRunner_ExportsTheTasksResolvedValues proves the handover: the runner +// carries the task's merged env — its vars, its env, its RESOLVED params, and the +// task's dir — as the values a step's process environment holds. +// +// A lowercase param is in the map: the expander's grammar is uppercase-only, so a +// lowercase param is unreachable from a step's TEXT. The environment is what makes it +// reachable BY NAME (opencharly/plugin-task#34). +func TestNewTaskRunner_ExportsTheTasksResolvedValues(t *testing.T) { + dir := t.TempDir() + task := spec.Task{ + Description: "d", + Vars: map[string]string{"UPPER": "from-vars"}, + Env: map[string]string{"TASKENV": "from-env"}, + } + r, cap := newTaskRunner(nil, dir, task, map[string]string{"out": "a value with spaces"}) + defer cap.cleanup() + + stepEnv := r.StepEnv() + for name, want := range map[string]string{ + "UPPER": "from-vars", + "TASKENV": "from-env", + "out": "a value with spaces", + "TASK_DIR": dir, + } { + got, ok := stepEnv[name] + if !ok { + t.Fatalf("the data channel must export %q (present=%v, exported=%v)", name, ok, stepEnv) + } + if got != want { + t.Fatalf("the data channel exported %q=%q, want %q", name, got, want) + } + } +} + +// TestRunTask_StepEnvCarriesAParamAsData is the DISCRIMINATING test: a param's value +// reaches the step's verb VERBATIM, metacharacters and all. Before the data channel +// the step's env was empty, so this value reached the step nowhere at all. +func TestRunTask_StepEnvCarriesAParamAsData(t *testing.T) { + evil := `$(touch /tmp/r1-repro/PWNED) "double" 'single' $HOME; rm -rf /` + env, _ := captureStepEnv(t, spec.Task{ + Description: "d", + Plan: []spec.Step{{Run: "x", Op: spec.Op{Plugin: "command", PluginInput: map[string]any{"command": "true"}}}}, + }, map[string]string{"out": evil}) + + got, ok := env["out"] + if !ok { + t.Fatalf("the step's process env must carry the param (present=%v, env=%v)", ok, env) + } + if got != evil { + t.Fatalf("the param must reach the step VERBATIM, got %q want %q", got, evil) + } +} + +// TestRunTask_StepsOwnEnvWinsOnACollision proves the collision rule the data channel +// inherits: the step's OWN `env:` wins, because the author spoke last about their own +// step and the host's value is a default. The second param proves the channel is +// ON at all — a test that asserts only the collision would also pass with no channel. +func TestRunTask_StepsOwnEnvWinsOnACollision(t *testing.T) { + env, _ := captureStepEnv(t, spec.Task{ + Description: "d", + Plan: []spec.Step{{Run: "x", Op: spec.Op{ + Plugin: "command", + Env: map[string]string{"out": "step-wins"}, + PluginInput: map[string]any{"command": "true"}, + }}}, + }, map[string]string{"out": "host-value", "other": "host-value-too"}) + + if got := env["out"]; got != "step-wins" { + t.Fatalf("the step's own env must win the collision, got %q", got) + } + if got := env["other"]; got != "host-value-too" { + t.Fatalf("a name with no collision must carry the host's value, got %q", got) + } +} + +// TestRunTask_StdinBytesAreNotExported proves the boundary of the data channel: the +// piped bytes are a runtime STREAM, not a resolved value, and they are NOT exported. +// +// The reason is mechanical, not stylistic: the command verb renders an exported value +// into the command TEXT, and a NUL byte (a piped binary stream carries one) makes the +// exec un-runnable — "fork/exec /usr/bin/sh: invalid argument". The stream keeps the +// channel it has always had: ${TASK_STDIN} / ${TASK_STDIN_FILE} in the step's text. +func TestRunTask_StdinBytesAreNotExported(t *testing.T) { + old := os.Stdin + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + os.Stdin = r + go func() { + _, _ = w.Write([]byte("piped-bytes")) + _ = w.Close() + }() + defer func() { os.Stdin = old }() + + env, _ := captureStepEnv(t, spec.Task{ + Description: "d", + Plan: []spec.Step{{Run: "x", Op: spec.Op{ + Plugin: "command", + PluginInput: map[string]any{"command": `printf %s "${TASK_STDIN}"`}, + }}}, + }, map[string]string{"kept": "a resolved value"}) + + // The channel is ON — a resolved value is exported — while the stream is NOT. + if got := env["kept"]; got != "a resolved value" { + t.Fatalf("the data channel must export the resolved values, got kept=%q", got) + } + if v, ok := env["TASK_STDIN"]; ok { + t.Fatalf("the piped bytes must NOT be exported, got TASK_STDIN=%q", v) + } + if _, ok := env["TASK_STDIN_FILE"]; ok { + t.Fatal("the stdin temp file must NOT be exported either") + } +} + +// TestRunTask_NonExportableNameIsDisclosed proves a value the SHELL cannot export is +// DISCLOSED, never lost silently: a name outside ^[A-Za-z_][A-Za-z0-9_]*$ cannot be +// an environment variable, and a value that vanishes silently reads exactly like a +// host that never supplied it. +func TestRunTask_NonExportableNameIsDisclosed(t *testing.T) { + _, res := captureStepEnv(t, spec.Task{ + Description: "d", + Plan: []spec.Step{{Run: "x", Op: spec.Op{Plugin: "command", PluginInput: map[string]any{"command": "true"}}}}, + }, map[string]string{"my-param": "value"}) + + if len(res.Steps) == 0 { + t.Fatal("the walk produced no step result") + } + msg := res.Steps[0].Result.Message + if !strings.Contains(msg, "not exported (not a valid shell identifier): my-param") { + t.Fatalf("a name the shell cannot export must be disclosed on the step's message, got %q", msg) + } +} diff --git a/scripts/check-coherence.sh b/scripts/check-coherence.sh index 1ef5ee8..1bd7e6d 100755 --- a/scripts/check-coherence.sh +++ b/scripts/check-coherence.sh @@ -8,7 +8,8 @@ # are older RAISES sdk/spec past the APIs charly's OTHER compiled-in plugins were built # against, and the build dies fifty errors deep in plugin-box / plugin-build / # plugin-cmd with errors that name neither this repo nor the cause. Measured at charly -# v2026.267.2313 (pinning sdk v0.2026266.1111 against this module's v0.2026276.1822): +# v2026.267.2313 (pinning sdk v0.2026266.1111 against a module pinning sdk +# v0.2026276.1822 — the pin this module carried at the time of that measurement): # 50 such errors, and the gate never reached a single verb assertion (plugin-task#14). # # The ordering below is the invariant that makes the build coherent, and asserting it diff --git a/scripts/gate-task.sh b/scripts/gate-task.sh index 282d35d..3ce7b6b 100755 --- a/scripts/gate-task.sh +++ b/scripts/gate-task.sh @@ -117,6 +117,15 @@ silent-skip: command: "echo should-not-run" - run: the silent step that ran command: "echo ran-quietly" +datachannel: + task: + description: a param reaches a step as DATA, never as shell source + params: + out: + description: the value under test + plan: + - run: the param arrives in the step's process environment, verbatim + command: 'printf "%s" "$out" > "$TASK_DIR/datachannel.out"' YML cd "$PROJ" @@ -196,6 +205,45 @@ echo "$comp_out" | grep -q 'the leaf step (its Message is the reason)' || { printf '%s\n' "$comp_out" | sed 's/^/ | /' echo " the composite carried the leaf step's reason AND its path across the hop" +# --------------------------------------------------------------------------- +# 1c) The DATA channel: a param reaches a step as DATA, never as shell source +# (opencharly/plugin-task#34). The TEXT channel substitutes a value into a +# step's command TEXT and the shell then PARSES that text, so a value that is +# DATA becomes SOURCE. The environment is the other channel: a value delivered +# as an environment variable is DATA, because the shell never re-parses the +# result of an expansion. +# +# The step reads "$out" (the SHELL's own expansion), never "${out}" (the +# expander's text channel), so this arm measures the data channel ALONE. The +# param is named lowercase on purpose: the expander's grammar is uppercase-only, +# so a lowercase param is unreachable from a step's text — the environment is +# what makes it reachable BY NAME. +# +# The value carries a `$(…)`, so one outcome is loud and one is silent: the +# value arrives VERBATIM and the shell does NOT execute it, or the shell ran the +# caller's value and the marker file exists. The arm asserts both, because the +# silent one is the failure this channel exists to remove. +# --------------------------------------------------------------------------- +echo "== a param reaches a step as DATA (the shell must not parse it) ==" +MARKER="$WORK/PWNED" +rm -f "$MARKER" "$PROJ/datachannel.out" +dc_out="$("$CH" task datachannel --param "out=\$(touch $MARKER)" 2>&1 || true)" +if [ ! -f "$PROJ/datachannel.out" ]; then + echo "FAIL: the step did not write its output — the task must have run its plan" >&2 + printf '%s\n' "$dc_out" >&2; exit 1 +fi +dc_value="$(cat "$PROJ/datachannel.out")" +if [ "$dc_value" != "\$(touch $MARKER)" ]; then + echo "FAIL: the param must reach the step's process environment VERBATIM, got '$dc_value'" >&2 + printf '%s\n' "$dc_out" >&2; exit 1 +fi +if [ -e "$MARKER" ]; then + echo "FAIL: the shell EXECUTED the caller's value — it was delivered as SOURCE, not as data" >&2 + exit 1 +fi +printf '%s\n' "$dc_out" | sed 's/^/ | /' +echo " the param arrived as data: verbatim, and unexecuted" + # --------------------------------------------------------------------------- # 2) The generic maintenance verbs, each exercised LIVE on a real git fixture. # ---------------------------------------------------------------------------