#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.
Sign in to post a ripple.