Skip to content

Traps and fixes

Bugs that passed typecheck and unit tests, and only showed up when running. They are here because most are not obvious in code review, and because the pattern that unites them is more useful than each isolated case.

The pattern. All of them involve the boundary between what the code declares and what the environment does: an error wrapped by a library, a schema whose default is not what it looks like, a tool that logs what it receives. None is caught by a type.


Symptom. oren run --verbose printed the token in plain text.

Cause. The execution engine logs the contents of withNewFile, and input.json went through there. All of the CLI’s careful redaction was undone by one flag.

✔ withNewFile /oren/input.json (contents: "{\"token\":\"super-secret\"}")

Fix. Input with a secret field travels as a mounted secret, which the engine never prints. Input with no secret stays an ordinary file — visible and cacheable, without paying the cost for nothing.

Lesson. Redacting in your own application’s output is not enough when a layer below also logs.


Symptom. An output declared secret was correctly redacted, but appeared in plain text once interpolated into an ordinary string.

output {"biggest":"***"} ← redacted
input {"note":"the biggest file was /source/c.txt"} ← leaked

Cause. The expression resolver computed the taint correctly and the runner discarded it, redacting only by the fields declared secret in the contract.

Fix. An input’s secrecy comes from two sources: the declaration in the contract and contamination by a secret value used in the expression that filled it.

Lesson. Taint propagation needs per-field granularity. An aggregate boolean “this structure has something secret” does not say what to redact.


Symptom. In the pipeline that gave rise to this project, major and patch were never incremented. Every tag is 0.X.0.

Cause. The worker read incremenMajorVersion while the pipeline sent incrementMajorVersion. With no contract, the unknown field was simply ignored.

The worse part. The new model accepted the same bug. The spec declared additionalProperties: false as the default, but JSON Schema assumes the opposite when the key is absent — and the validator did not enforce it.

Fix. Inputs and outputs are closed by default, with a name suggestion:

unknown field: "incremenMajorVersion" — did you mean "incrementMajorVersion"?

Lesson. A default declared in prose is not a default applied. It is worth testing the behaviour the documentation promises.


The worker’s failure did not reach the user

Section titled “The worker’s failure did not reach the user”

Symptom. A worker broke and the output was exit code: 1, nothing else. You had to reproduce the container by hand to find out why.

Cause. Dagger’s ExecError already carries stdout, stderr and exitCode — we were not reading them.

Lesson. When a library wraps the error, the object usually holds more than the message shows. Worth inspecting before assuming the information was lost.

Related fix. The engine’s LogOutput shows graph operations (withExec, withNewFile), not the worker’s output. Filtering that log would never have worked — the information was not there. The worker’s output has to be captured and attributed to the step.


Symptom. --yes printed “saving to .oren/consent.json” and saved nothing.

Cause. The flush used a second instance of the store; the grants were in the first.

Lesson. A side effect announced in a log is not a side effect verified. The test that catches this does not look at the message, it looks at the file.


Symptom. content: "created by oren" reached Terraform as -var=by and -var=oren.

Cause. for pair in $(jq ...) does word splitting.

Fix. Positional arguments with set --, which preserve spaces. It was in terraform-plan, terraform-apply and build-docker-image — the last of which would have failed with any buildArg containing a space.

Lesson. The pattern is common enough to become a checklist item when writing a worker in shell.


The engine was not ready when it said it was

Section titled “The engine was not ready when it said it was”

Symptom. ETIMEDOUT on the first run after creating the engine.

Cause. A container answers docker ps before the engine socket exists. Connecting in that window fails with a timeout that explains nothing.

Why it went unnoticed. Every first run creates the container — it was the debut experience of every new user. It only failed to show in development because the engine was already up.

Fix. Wait for the socket before handing back control.

Lesson. Test the cold start, not just the warm path.


process.exitCode in an asynchronous command

Section titled “process.exitCode in an asynchronous command”

Symptom. oren doctor returned 0 even with Docker missing.

Cause. process.exitCode was set after the process had already finished.

Fix. Throw an error and let the handler decide the code.


Rootless buildah needs unshare(CLONE_NEWUSER), unavailable in an unprivileged container. It was my first choice for building an image without a daemon; I only found out by running it. kaniko was made for exactly this.

Lesson. “Rootless” and “works inside a container without privileges” are not the same property.


Symptom. The build wrote docker-archive and the push expected oci-archive.

Cause. The file format was in no contract at all. A build implementation writing OCI would break any push expecting the other, with nothing indicating why.

Fix. archiveFormat became a required output of the build and an input of the push.

Lesson. When two tasks exchange an artefact, its format is part of the contract — not just the path.


The image reference pattern refused a port

Section titled “The image reference pattern refused a port”

Symptom. localhost:5000/app was rejected.

Cause. My pattern did not allow for a port in the host — which is the shape of every registry that is not Docker Hub.

Fix. A new pattern, validated against ten real references before being applied.

Lesson. A regex for a known format deserves a battery of real cases, not just the ones that came to mind.


The engine cache’s entryCount reported 4328 entries for 1.2 MB on disk. I could not explain the number, and removed it from the output rather than showing it.

Lesson. A number you cannot explain is worse than no number — the user will try to interpret it.