# Environments & values

> For the complete documentation index, see [llms.txt](/llms.txt)

A suite's externals — the URL it points at, non-secret settings, credentials
— live with the **environment on the box**, never in a bundle. The same
bundle runs in `test` and in `prod`, and a new bundle cannot reset an
environment's configuration.

## An environment

Each environment is declared in the box's configuration, which unotest
maintains with you:

| Setting | Meaning |
|---|---|
| `name` | Stable id (`test`, `staging`, `prod`). It names the run history, the queue and the viewer, so renaming one is renaming its history. |
| `target` | The URL the tests run against, injected into every run as `APP_BASE_URL` — which `unotest.config` auto-wires `baseUrl` from. |
| `defaultBranch` | Which branch's newest bundle the environment follows for schedules and after a restart (`main` by default). |
| `allowSwitch` | Whether anyone may point the environment at another bundle from the viewer. Off for environments where "what is deployed" is the point. |
| `concurrency` | Runs at a time in this environment (1 by default). |
| `variables` | Non-secret values every run gets. |

## Three layers of values

| Layer | What | Who sees the value |
|---|---|---|
| **target** | The URL, as `APP_BASE_URL` | everyone |
| **variables** | Non-secret values every run gets | administrators; the viewer's Variables panel |
| **secrets** | Credentials | nobody — only a run's process |

The configuration gives each environment its initial `target` and
`variables`. Everything set through the page or the commands below layers
**over** that: a value set on the box wins over the configuration, and a
secret wins over a variable of the same name. No restart is needed — a run
reads the current values when it starts.

### What a run sees

For every run the box builds the process environment in this order, later
entries winning:

1. box policy (`UNOTEST_BROWSER_CHANNEL=bundled` — the box uses the
   bundled browser);
2. `APP_BASE_URL` = the target (an override if set, else the configured one);
3. `variables` from the configuration;
4. `variables` set on the box;
5. `secrets`.

The viewer's **Variables** panel lists the same names as read-only rows
with a `box` badge; a secret's value is never sent to the browser, only its
name. Changing values restarts that environment's viewer: the page reloads,
and a debug session open in it ends.

## The values page

*Administration → Environments → the environment.* One table, one row per
key:

| Row | What it shows |
|---|---|
| `APP_BASE_URL` (`target`) | The URL, tagged `config` or `override`. |
| a variable | Its value, tagged `config` (from the configuration) or `box` (set here or by `env push` / `env set`). |
| a secret (`secret` tag) | Dots. The value is not on the page. |

- **Edit** (pencil) opens the value in place; Enter or the check mark
  saves, Esc or the cross cancels. Overriding the target and reverting it
  (**back to configured**) work the same way.
- **Remove** (bin) is offered for what the box itself holds — a `box`
  variable, a secret, a target override — and asks first. A `config`
  variable comes from the configuration and is not removable here.
- The **add row** at the bottom: `NAME`, value, a **secret** checkbox,
  **Add**. Ticked, the value goes into the secret store.

Each change is one audit event: `env.value.set` or `env.value.removed`,
carrying the name and never the value.

### Revealing a secret

The **eye** asks the box for a secret's value and shows it in the row (the
eye becomes *hide*); the **copy** button puts it on the clipboard without
showing it.

**Every look is audited.** Each reveal or copy is one `secret.revealed`
event with who, which secret(s), and `how: show | copy`. The answer is
served with `Cache-Control: no-store`, so a revealed value does not survive
in a browser cache. If you see reveals you did not expect in *Recent
audit*, that is the trail doing its job.

### JSON view

The **JSON** switch shows the same rows as one document:

```json
{
  "APP_BASE_URL": "https://staging.acme.io",
  "REGION": "eu",
  "GEMINI_API_KEY": "••••"
}
```

**Reveal all** fills in the secrets (one audited look, listing every
name). **Edit** makes the document editable; **Save** sends the whole
environment at once; **Cancel** discards. On save:

- `APP_BASE_URL` equal to the configured target, or absent, clears the
  override; any other URL sets one.
- A secret left as `"••••"` — or as the value *Reveal all* showed — is
  kept as it is. A different string replaces it. A removed key removes
  the secret. To add a **secret**, use the table's add row.
- Every other key is a variable: a new key becomes a variable, a removed
  one is removed.
- Anything that is not an object, a non-string value, or a name that
  could not be a variable is refused on the page; nothing is sent.

The batch is applied or refused as a whole (`env.values.replaced` in the
trail). A refusal from the box is shown as the box phrased it — for
instance *cannot keep what the environment does not hold* when the page
and the box disagree about which secrets exist.

### Without JavaScript

Every row is an ordinary form: **set** / **remove** post it. Reveal, copy
and the JSON view need the script.

## From the command line

The same values can be set from a laptop or a deploy job, with a project
token minted for values (see [Tokens](#tokens)).

### `env push` — send your files

```sh
UNOTEST_BOX_URL=https://tests.example.com \
UNOTEST_BOX_TOKEN=unob_… \
npx @unotest/web env push dev
```

`dev` is the **local** environment — the one `--env dev` uses — and the
command reads exactly what a local run reads: `unotest/.env`, `.env.dev`,
`.secrets`, `.secrets.dev`, later files winning. Then:

- `.env*` → the environment's `variables`
- `.secrets*` → its `secrets`
- `APP_BASE_URL`, from either → its `target`
- `UNOTEST_*` → **not sent**, listed as skipped: they configure the runner
  on your machine
- empty values → not sent, listed as skipped

The box environment is assumed to have the same name; `--box-env <name>`
says otherwise. `--env-file` / `--secrets-file` read named files instead
(repeat for layers; later files win). `--dry-run` reads and lists without
sending; `--json` prints the box's answer.

`env push` **replaces** the three layers: a name your files no longer carry
is removed from the box, and the command says which. Values set by hand
(`env set`, the values page) are part of what gets replaced.

A value is never printed — not on success, not with `--json`, not in
`--dry-run`. Names are.

### `env set`, `env rm` — one value

```sh
printf '%s' "$GEMINI_API_KEY" | npx @unotest/web env set dev GEMINI_API_KEY --secret
printf '%s' 'eu-west' | npx @unotest/web env set dev REGION
printf '%s' 'https://pr-42.preview.example' | npx @unotest/web env set dev APP_BASE_URL
npx @unotest/web env rm dev REGION
npx @unotest/web env rm dev APP_BASE_URL     # back to the configured target
```

The value comes from **stdin**, never from the command line, so it does not
land in shell history or `ps`. `--secret` makes it a secret;
`APP_BASE_URL` is always the target.

`env set` has **no name filter**: unlike `env push`, it accepts `UNOTEST_*`
too. The asymmetry is deliberate — `env push` reads the files a local run
reads, and those `UNOTEST_*` configure the runner on *your* machine, so
sending them would be an accident. Naming one by hand is a decision, so it
is allowed. That is how a box gets the runner settings it does need, such
as `UNOTEST_JUDGE_MODE` and the judge's key:

```sh
printf '%s' local | npx @unotest/web env set dev UNOTEST_JUDGE_MODE
npx @unotest/web env set dev UNOTEST_JUDGE_PREAMBLE < preamble.txt
```

The only name the box refuses is `APP_BASE_URL` as a plain variable — it is
the environment's target, set as shown above. A value cannot be empty: the
box has no way to declare one, so use a non-empty sentinel and treat it as
the default in your scenario.

## Tokens

The CLI authenticates with a **project token** (`unob_…`). Tokens are
issued per project by unotest and come in two scopes:

| Scope | May |
|---|---|
| `bundles` | push bundles and order runs — what a CI job needs |
| `bundles+values` | also write the project's environment values |

A `bundles` token used with `env push` or `env set` is refused with
`forbidden` (*this token pushes bundles only*). That is deliberate: the
token in your CI secret store that pushes suites on every commit should
not also be able to rewrite an environment's credentials.

The token decides the project; a command names only the environment.
Nothing in a request can point it at another project's values.

## Where values go, and where they never go

- **Into a run's process** — target, variables and secrets, in the order
  above. The runner masks secret values in logs and artifacts as it does
  locally.
- **Into the viewer** — names only, with a `box` badge.
- **Over the wire from the CLI** — values go in; no answer ever carries one
  back. Secrets are write-only on that door.
- **Into the audit trail** — names only, never a value.
- **Into a bundle** — never. `unotest/.env*` and `.secrets*` are excluded
  from `bundle push` even when committed.

The one place a secret's value is ever shown again is the values page, to
an administrator, with a `secret.revealed` event for each look.
