Relationships
#291 Workflow-level runtime expressions (env.*, vault.*) not resolved in driverConfig — docker driver receives literal ${{ ... }} strings
Opened by john · 5/7/2026· Shipped 5/12/2026
Summary
${{ env.* }} and ${{ vault.* }} runtime expressions inside a workflow's driverConfig.volumes are never resolved before the docker driver builds its argv. Docker receives the literal string ${{ env.HOME }} (etc.) and rejects it.
This forces every workflow that needs to mount a per-user host path (e.g. credentials at ${HOME}/.foo, the current repo at ${PWD}) to hardcode an absolute path, which makes the workflow non-portable across machines and users.
Relation to #263
This is one layer earlier than #263. In #263, vault expressions in step inputs were being resolved into __SWAMP_VSEC__ sentinels but the sentinels weren't unwrapped before the docker invocation — the model received the sentinel string. Here, the runtime expression is in workflow driverConfig (not step inputs), so it never enters the runtime resolver at all: it survives WorkflowExpressionEvaluator (which deliberately skips runtime expressions, see below), there is no subsequent runtime-resolution pass over workflow data, and the literal ${{ env.HOME }} reaches docker -v untouched. The #263 fix operates downstream of the resolver, but the resolver was never run for these fields.
Steps to reproduce
- Create a
command/shellmodel instance:swamp model create command/shell test-shell
- Drop a workflow file into
workflows/workflow-<uuid>.yamlwith a runtime expression indriverConfig.volumes:id: <a-valid-uuidv4> name: test-env-vol description: Verify env-substitution in driverConfig.volumes. version: 1 tags: {} inputs: {} jobs: - name: print dependsOn: [] weight: 0 steps: - name: ls driver: docker driverConfig: image: alpine:3 volumes: - ${{ env.HOME }}:/host-home:ro task: type: model_method modelIdOrName: test-shell methodName: execute inputs: run: "ls /host-home | head -1" dependsOn: [] weight: 0 allowFailure: false
- Run it:
swamp workflow run test-env-vol
Expected
The docker driver receives the resolved volume string, e.g. /Users/me:/host-home:ro, and the container starts.
Actual
The docker driver receives the literal ${{ env.HOME }} and docker rejects it:
docker: Error response from daemon: create ${{ env.HOME }}: "${{ env.HOME }}"
includes invalid characters for a local volume name, only
"[a-zA-Z0-9][a-zA-Z0-9_.-]" are allowed. If you intended to pass a host
directory, use absolute pathThe same expression in workflow inputs flowing into a model method does get resolved correctly — only driverConfig (and other non-input workflow-level fields) is affected.
Source diagnosis
Looking at the source on 20260505.231643.0-sha.5a337b81:
Model
Definitiondata goes through two evaluation passes:DefinitionExpressionEvaluator.evaluate()(CEL-only) atsrc/domain/workflows/expression_evaluators.ts.ExpressionEvaluationService.resolveRuntimeExpressionsInDefinition()called fromsrc/domain/workflows/execution_service.ts(around the model-method execution path), which resolvesenv.*andvault.*.
Workflow data goes through only the first pass:
WorkflowExpressionEvaluator.evaluate()atsrc/domain/workflows/expression_evaluators.ts:58–122extracts all expressions, but at line ~89 explicitly skips runtime expressions:if (containsRuntimeExpression(expr.celExpression)) { continue; }
- There is no equivalent second pass that calls
resolveRuntimeExpressionsInDataover the workflow data before per-step driverConfig is forwarded to the driver (seeexecution_service.tswherestep.driverConfig/job.driverConfigare pulled into theDriverPlanunchanged).
So workflow-level ${{ env.* }} and ${{ vault.* }} are only ever evaluated for fields that subsequently flow into a model Definition (via task.inputs). For workflow fields that go straight to the driver — driverConfig.image, driverConfig.volumes, driverConfig.env, driverConfig.extraArgs, etc. — the expressions are never substituted and the driver gets the raw ${{ ... }} string.
Suggested fix
Add a runtime-expression resolution pass over the workflow data, mirroring the model-definition path:
- Either inside
WorkflowExpressionEvaluator.evaluate()after the CEL-only pass, or as a separateresolveRuntimeExpressionsInWorkflow()call invoked at workflow execution start (analogous toresolveRuntimeExpressionsInDefinition). - The walker (
extractExpressions/replaceExpressions) and the runtime evaluator (ExpressionEvaluationService.resolveRuntimeExpressionsInData) already handle arbitrary nested data, so the missing piece is the second-pass invocation, not new walking logic.
Confirmed by inspecting the persisted .swamp/workflows-evaluated/workflow-*.yaml after a run — the file retains the literal ${{ env.HOME }} because it is saved post-CEL-pass, pre-runtime-pass, and there is no third file capturing post-runtime-pass workflow state (because that pass doesn't exist).
Environment
- swamp
20260505.231643.0-sha.5a337b81 - macOS (Darwin 22.6.0)
- docker engine: standard Docker Desktop
- Source inspected at the same version via
swamp source fetch.
Shipped
Click a lifecycle step above to view its details.
john commented 5/18/2026, 6:26:55 PM
Follow-up after #291 shipped (caught it in swamp 20260518.174231.0-sha.7cbab349): the expression resolver now appears to traverse string fields inside model input data, not just workflow-definition fields like driverConfig.volumes — so any ${{ … }} that appears as prose inside model payloads is now eagerly parsed.
Concretely, a mandible-issue-lifecycle plan from a previous (pre-#291) triage included this line in payload.potentialChallenges as documentation:
driverConfig.volumesdoes not resolve${{ env.* }}/${{ vault.get(...) }}expressions
That text was stored verbatim in the issue's lifecycle history. When a subsequent mandible-triage workflow re-reads that history and hands it to the six reviewer + revise steps, all of them now fail identically:
Invalid expression: Expected IDENTIFIER, got MULTIPLY
> 1 | env.*
^…and the revise step then fails with No such key: attributes once it tries to consume the partial review output.
Repro shape:
- Have a CEL-valued workflow input whose value is a string that happens to contain the literal sequence
${{ env.* }}(or any other invalid-CEL${{ ... }}substring) — e.g. a plan body, a user-supplied issue body, a description field. - Feed it into a model method via
--inputor a CELinputs.xreference. - Pre-#291: string is passed through opaque. Post-#291: evaluator descends into the string, finds
${{ … }}, tries to parse the inner expression, fails.
Same swamp config worked end-to-end at 13:22 UTC under the previous build; failed at 18:15 under the new one. Workflow YAMLs unchanged in between, only the swamp binary moved.
Sign in to post a ripple.