Environment variables in flows
Maestro flows can read variables supplied at invocation time — a launcher
choice, a test account, an API base URL. Locally you pass them with
maestro test -e KEY=VALUE. Through the farm, you pass an env object on
POST /run and the farm forwards each pair as -e KEY=VALUE to every flow
in the job.
curl -X POST "$FARM_URL/run" \
-H "Authorization: Bearer $FARM_TOKEN" \
-F apk=@app-testing.apk \
-F flowsArchive=@e2e.tar.gz \
-F flows='["login/signin"]' \
-F env='{"E2E_LAUNCHER":"samsung","E2E_TEST_USER":"momentum-ci"}'
In the flow:
appId: com.example.app
---
- launchApp
- tapOn: "Sign in"
- inputText: ${E2E_TEST_USER}
The wire format
env is a JSON-encoded flat object of string to string, sent as an
ordinary multipart form field alongside flows.
It is entirely optional, and absent means absent: a request with no env
field produces byte-for-byte the same Maestro invocation it did before this
feature existed. If you are not using it, nothing about your pipeline
changes.
Validation is strict — the farm rejects rather than escapes, so a value can
never be reinterpreted somewhere downstream. Every failure is a 400 naming
the offending key:
| Rule | Rejected example |
|---|---|
| Must parse as JSON and be a flat object of string values | env='["A","B"]' |
Keys must match ^[A-Za-z_][A-Za-z0-9_]*$ |
"2FA", "my-key", "a.b" |
| Values must not contain a newline, carriage return or NUL | a pasted multi-line PEM |
| A secret-named key's value must be at least 4 characters | {"API_TOKEN":"ab"} |
That last rule is not arbitrary — a two-character secret would make the masking below replace every occurrence of those two characters throughout every stored log. Rejecting is safer than producing shredded artifacts.
Ordering and duplicates
Keys are sorted before the -e flags are built, so the same request
always produces the same command line. A JSON object cannot hold a duplicate
key, so where maestro test would accept a repeated -e FOO= and take the
last one, here you simply get one entry.
This matters because a future farm CLI is meant to be a drop-in for
maestro: the mapping between the two has to be lossless and written down
rather than assumed.
Secrets
⚠️ Read this before sending a password through env.
env values reach Maestro in full — that is what makes the feature useful.
But the farm also persists your flow's stdout/stderr and everything Maestro
writes to its debug-output directory as artifacts, and keeps them
indefinitely by default. So a credential passed in needs to not appear in
what gets stored, and the farm has one mechanism for that.
Masking is by naming convention
A key whose name contains any of these, case-insensitively, is treated as secret:
PASSWORD TOKEN SECRET API_KEY APIKEY
Its value is replaced with ***REDACTED*** everywhere the farm stores text:
artifact files, stdout/stderr previews, job history, error messages.
This follows Device Cloud's convention,
plus APIKEY without the underscore — Device Cloud's published list does not
cover that spelling, and an unmasked credential is worse than a small
divergence from someone else's convention.
A key without one of those markers is stored and forwarded verbatim. Name
your variables accordingly. E2E_TEST_PASSWORD is masked; E2E_TEST_PW is
not.
What masking actually guarantees
It is literal substring replacement, over three forms of the value: the raw
string, its JSON-escaped form, and its XML entity-escaped form. Those three
cover how Maestro writes values into commands-*.json and into hierarchy
dumps.
What that gets you: the value as you supplied it will not appear in stored text.
What it does not get you: protection against a transformation. If a flow base64-encodes the value, URL-encodes it, truncates it, or prints a hash of it, that derivative is not matched and will be stored.
Two more limits worth stating plainly:
- Values reach Maestro unmasked, always. Masking governs only what the
farm stores. They are passed as command-line arguments, which means they
are visible in the farm host's process list (
ps) for the duration of the invocation. - A flow that prints something sensitive the farm never saw is not covered. Masking only knows the values you sent it.
Why this exists at all
Maestro has an open, unfixed defect —
mobile-dev-inc/Maestro#2512
— where every -e value is written verbatim into commands-*.json inside
its --debug-output directory. The farm collects that directory wholesale as
artifacts, which are then readable through the dashboard and by a coding
agent through the MCP tools.
So without masking, passing a password to a flow would publish it. Most of this feature is the containment, not the passthrough. If that upstream issue is ever fixed, some of the above may stop being necessary — check it before relying on any of this staying true.
Worked example: a password from your secret store
Say Momentum's flows need a real test account.
1. Name the variables so masking catches them. E2E_TEST_USER is a
username — not secret, and useful to see in logs. E2E_TEST_PASSWORD
contains PASSWORD, so it is masked.
2. Keep the value out of your pipeline logs too. The farm masks its own
stored output; it cannot do anything about your CI printing the value before
the request is even made. Build the JSON with jq rather than string
interpolation, and never set -x around it:
ENV_JSON=$(jq -nc \
--arg user "$E2E_TEST_USER" \
--arg pass "$E2E_TEST_PASSWORD" \
--arg launcher samsung \
'{E2E_TEST_USER:$user, E2E_TEST_PASSWORD:$pass, E2E_LAUNCHER:$launcher}')
curl -sf -X POST "$FARM_URL/run" \
-H "Authorization: Bearer $FARM_TOKEN" \
-F apk=@app-testing.apk \
-F flowsArchive=@e2e.tar.gz \
-F flows='["login/signin"]' \
-F env="$ENV_JSON"
jq -n also handles quoting and escaping correctly for a password
containing ", \ or $, which hand-built JSON reliably gets wrong.
3. Check the artifacts once. After the first run, open the job in the
dashboard and look at the failing step's maestro.log and the
commands-*.json artifacts. You should see ***REDACTED*** where the
password would be. If you see the value, the key name did not match the
convention — rename it.
4. Use a dedicated test account. Masking reduces exposure; it does not make a production credential safe to hand to a test runner on a shared device.
Where the value is stored
The job record keeps the env you sent, with secret values already replaced, so job history shows which variables a run used without holding the credentials.
Where you can read it back is uneven, and worth knowing:
GET /run/:idandGET /jobs(the CI API) do not include it. Those response shapes are frozen for CI compatibility and were not changed.GET /api/jobs/:idandGET /api/jobs(the dashboard API) return the whole job row, soenvJsonis present there today — as a JSON string, not a parsed object. Treat this as an implementation detail rather than a stable API field; avoid building integrations against it.
What is deliberately not supported
The farm never merges your variables into the Maestro child process's
environment. They are passed as -e flags and nothing else. This is
deliberate: the farm's own process holds its session secret and token
encryption key, and a flow that inherited the process environment would be
one step from reading them.