Skip to main content
← Back to list
01Issue
BugShippedSwamp CLI
Assigneesjohn

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

  1. Create a command/shell model instance:
    swamp model create command/shell test-shell
  2. Drop a workflow file into workflows/workflow-<uuid>.yaml with a runtime expression in driverConfig.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
  3. 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 path

The 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 Definition data goes through two evaluation passes:

    1. DefinitionExpressionEvaluator.evaluate() (CEL-only) at src/domain/workflows/expression_evaluators.ts.
    2. ExpressionEvaluationService.resolveRuntimeExpressionsInDefinition() called from src/domain/workflows/execution_service.ts (around the model-method execution path), which resolves env.* and vault.*.
  • Workflow data goes through only the first pass:

    • WorkflowExpressionEvaluator.evaluate() at src/domain/workflows/expression_evaluators.ts:58–122 extracts all expressions, but at line ~89 explicitly skips runtime expressions:
      if (containsRuntimeExpression(expr.celExpression)) {
        continue;
      }
    • There is no equivalent second pass that calls resolveRuntimeExpressionsInData over the workflow data before per-step driverConfig is forwarded to the driver (see execution_service.ts where step.driverConfig / job.driverConfig are pulled into the DriverPlan unchanged).

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 separate resolveRuntimeExpressionsInWorkflow() call invoked at workflow execution start (analogous to resolveRuntimeExpressionsInDefinition).
  • 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.
02Bog Flow
OPENTRIAGEDIN PROGRESSSHIPPED+ 2 MOREASSIGNED+ 8 MOREREVIEW+ 5 MOREPR_LINKED+ 1 MORECOMPLETE

Shipped

5/12/2026, 8:01:17 AM

Click a lifecycle step above to view its details.

03Sludge Pulse
john assigned john5/18/2026, 6:09:30 PM
Editable. Press Enter to edit.

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.volumes does 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:

  1. 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.
  2. Feed it into a model method via --input or a CEL inputs.x reference.
  3. 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.