smoke resolves its target instance-wide: it can write to a different project's — or a different environment's — app of the same name #29

Closed
opened 2026-07-13 19:42:08 +00:00 by dan-claude-bot · 1 comment
dan-claude-bot commented 2026-07-13 19:42:08 +00:00 (Migrated from github.com)

The gap

smoke resolves the application it writes to by name, against every application the token can see:

const apps = (await client.get("/applications")) as Array<{ uuid: string; name: string }>;
const target = apps.find((a) => a.name === resolved.target);

src/cli.ts, the smoke command

GET /applications is not scoped to a project or an environment. It returns every application in the token's team, across all projects and all environments on that instance. .find() then takes the first one whose name matches and writes to it.

So smoke_target: core does not name an application. It names whichever application called core Coolify happens to list first.

Why this is live, not hypothetical

It does not need two projects. One project with two environments on one Coolify instance is enough — and that is the ordinary shape: instance: is an optional, per-environment binding, so with none set, prod and staging read the same .coolify.env and live on the same control plane, on different servers. Both environments have an application called core.

cast smoke --env staging can therefore POST its canary vars onto prod's core. Nothing catches it:

  • the team assert passes — same team
  • the instance is the right instance
  • the app is a real app, with a real uuid, and the writes succeed

The verb whose entire job is to be a write against a live application picks its victim by a name lookup that cannot distinguish prod from staging.

What the damage actually is

Bounded, but not nil, and worth being precise about:

  • The test's conclusion survives. It probes whether Coolify's bulk env endpoint upserts rather than replaces, which is a property of the Coolify version, not of the app — so the answer is still true for that instance.
  • The mutation does not survive. Two env vars are POSTed to an application the operator did not name and does not know was touched. smoke deletes both on the happy path.
  • On the failure path it is worse. If the bulk write turns out to be destructive, smoke throws before cleaning up — and what it leaves behind (INFRA_SMOKE_KEEP, or a stray INFRA_SMOKE_PROBE) is left on the wrong application, possibly prod's.

The shape

This is the same failure this repo keeps naming (#12, #14, #17, #18, #21): a coordinate that is not actually a coordinate. A bare resource name is unique only within a project and an environment, exactly as a bare <repo> is unique only within an org (#12) — and cast has already learned that lesson twice. smoke is the one caller still resolving a name in a namespace it never established.

It is also the last piece of the smoke_target move in #21. That fixed where the target is declared — project-scoped, environments.<env>.projects.<repo>.smoke_target. It deliberately did not touch how the declared name is resolved, which is this.

Proposal

Resolve the target inside the project and environment it was declared under, instead of instance-wide.

fetchLive(client, projectName, coolifyEnv) already does exactly this lookup, and smoke now takes <org>/<repo>, so the project is in hand. What it still lacks are the read-side coordinates every other verb has:

  • --project <name> — Coolify's name for the project, when it isn't the repo's
  • --environment <name> — Coolify's name for the environment, when it isn't ours

…the D-241 split, one level up from the resource. Then refuse rather than guess when the named app is not in that project + environment — the LiveLookup disposition: an absent target is not an empty result, it is the absence of anything to write to.

One wrinkle worth deciding deliberately. The deprecated state-file-scoped smoke_target (kept in #21 so unmigrated state files keep working) has no project to scope tocast smoke --env prod with no repo cannot be resolved this way at all. So either that path keeps the instance-wide lookup with the bug intact, or the <org>/<repo> positional becomes required and the deprecated key is dropped in the same pass. The second is cleaner, and the deprecation warning is already in place to lead there.

## The gap `smoke` resolves the application it writes to by name, against **every application the token can see**: ```ts const apps = (await client.get("/applications")) as Array<{ uuid: string; name: string }>; const target = apps.find((a) => a.name === resolved.target); ``` — `src/cli.ts`, the `smoke` command `GET /applications` is not scoped to a project or an environment. It returns every application in the token's team, across all projects and all environments on that instance. `.find()` then takes **the first one whose name matches** and writes to it. So `smoke_target: core` does not name an application. It names *whichever application called `core` Coolify happens to list first*. ## Why this is live, not hypothetical It does not need two projects. **One project with two environments on one Coolify instance is enough** — and that is the ordinary shape: `instance:` is an optional, per-environment binding, so with none set, `prod` and `staging` read the same `.coolify.env` and live on the same control plane, on different servers. Both environments have an application called `core`. `cast smoke --env staging` can therefore POST its canary vars onto **prod's** `core`. Nothing catches it: - the team assert passes — same team - the instance is the right instance - the app is a real app, with a real uuid, and the writes succeed The verb whose entire job is to be a **write** against a live application picks its victim by a name lookup that cannot distinguish prod from staging. ## What the damage actually is Bounded, but not nil, and worth being precise about: - The test's **conclusion** survives. It probes whether Coolify's bulk env endpoint upserts rather than replaces, which is a property of the Coolify *version*, not of the app — so the answer is still true for that instance. - The **mutation** does not survive. Two env vars are POSTed to an application the operator did not name and does not know was touched. `smoke` deletes both on the happy path. - On the **failure** path it is worse. If the bulk write turns out to be destructive, `smoke` throws before cleaning up — and what it leaves behind (`INFRA_SMOKE_KEEP`, or a stray `INFRA_SMOKE_PROBE`) is left on the wrong application, possibly prod's. ## The shape This is the same failure this repo keeps naming (#12, #14, #17, #18, #21): **a coordinate that is not actually a coordinate.** A bare resource name is unique only *within* a project and an environment, exactly as a bare `<repo>` is unique only within an org (#12) — and cast has already learned that lesson twice. `smoke` is the one caller still resolving a name in a namespace it never established. It is also the last piece of the `smoke_target` move in #21. That fixed *where the target is declared* — project-scoped, `environments.<env>.projects.<repo>.smoke_target`. It deliberately did not touch *how the declared name is resolved*, which is this. ## Proposal Resolve the target inside the project and environment it was declared under, instead of instance-wide. `fetchLive(client, projectName, coolifyEnv)` already does exactly this lookup, and `smoke` now takes `<org>/<repo>`, so the project is in hand. What it still lacks are the read-side coordinates every other verb has: - `--project <name>` — Coolify's name for the project, when it isn't the repo's - `--environment <name>` — Coolify's name for the environment, when it isn't ours …the D-241 split, one level up from the resource. Then refuse rather than guess when the named app is not in that project + environment — the `LiveLookup` disposition: an absent target is not an empty result, it is the absence of anything to write to. **One wrinkle worth deciding deliberately.** The deprecated state-file-scoped `smoke_target` (kept in #21 so unmigrated state files keep working) has *no project to scope to* — `cast smoke --env prod` with no repo cannot be resolved this way at all. So either that path keeps the instance-wide lookup with the bug intact, or the `<org>/<repo>` positional becomes required and the deprecated key is dropped in the same pass. The second is cleaner, and the deprecation warning is already in place to lead there.
dan-claude-bot commented 2026-07-13 19:47:54 +00:00 (Migrated from github.com)

#28 moved the config; the resolution bug is still here — and #28 also handed us the fix.

smoke_target is now project-scoped (environments.<env>.projects.<slug>.smoke_target), and cast smoke takes an optional <org>/<repo>. That fixes which name smoke looks for. It does not fix where it looks: the resolution still lists /applications instance-wide and picks the first name match, so an app called core in another project — or in another environment of the same project — can still be the one that gets written to.

The declaration got scoped. The lookup did not.

The fix is already sitting in the codebase: fetchLive(client, projectName, coolifyEnv) returns exactly the resources in one project's one environment, and every other verb already goes through it. Resolving smoke_target through that instead of the instance-wide list makes the lookup as narrow as the declaration — and it inherits the absent-target refusal (#12) and --project/--environment (#17) for free.

Note what makes this worse than a normal name collision: smoke writes. It POSTs two env vars onto the app it resolves and deletes them again. Every other verb that could hit the wrong resource merely reports the wrong thing. This one mutates it — on a live box, in a different project, quite possibly one belonging to someone else. On the instance this all came from, a single team owns three projects, two of which are third-party client sites.

**#28 moved the config; the resolution bug is still here — and #28 also handed us the fix.** `smoke_target` is now project-scoped (`environments.<env>.projects.<slug>.smoke_target`), and `cast smoke` takes an optional `<org>/<repo>`. That fixes *which name* smoke looks for. It does **not** fix *where it looks*: the resolution still lists `/applications` **instance-wide** and picks the first name match, so an app called `core` in another project — or in another environment of the same project — can still be the one that gets written to. The declaration got scoped. The lookup did not. The fix is already sitting in the codebase: **`fetchLive(client, projectName, coolifyEnv)`** returns exactly the resources in one project's one environment, and every other verb already goes through it. Resolving `smoke_target` through that instead of the instance-wide list makes the lookup as narrow as the declaration — and it inherits the absent-target refusal (#12) and `--project`/`--environment` (#17) for free. Note what makes this worse than a normal name collision: **smoke *writes*.** It POSTs two env vars onto the app it resolves and deletes them again. Every other verb that could hit the wrong resource merely *reports* the wrong thing. This one mutates it — on a live box, in a different project, quite possibly one belonging to someone else. On the instance this all came from, a single team owns three projects, two of which are third-party client sites.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: heavy-duty/cast#29
No description provided.