Commit Graph

956 Commits

Author SHA1 Message Date
dependabot[bot] 737b0e1779 chore(deps): bump the all-npm-backend group in /backend with 7 updates (#2162)
Bumps the all-npm-backend group in /backend with 7 updates:

| Package | From | To |
| --- | --- | --- |
| [undici](https://github.com/nodejs/undici) | `8.11.2` | `8.12.2` |
| [zod](https://github.com/colinhacks/zod) | `4.6.6` | `4.6.7` |
| [eslint](https://github.com/eslint/eslint) | `10.12.0` | `10.13.0` |
| [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) | `8.71.1` | `8.71.2` |
| [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) | `5.0.3` | `5.0.5` |
| [@aws-sdk/client-ecr](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-ecr) | `3.1149.0` | `3.1152.0` |
| [@aws-sdk/client-s3](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-s3) | `3.1149.0` | `3.1152.0` |


Updates `undici` from 8.11.2 to 8.12.2
- [Release notes](https://github.com/nodejs/undici/releases)
- [Commits](https://github.com/nodejs/undici/compare/v8.11.2...v8.12.2)

Updates `zod` from 4.6.6 to 4.6.7
- [Release notes](https://github.com/colinhacks/zod/releases)
- [Commits](https://github.com/colinhacks/zod/compare/v4.6.6...v4.6.7)

Updates `eslint` from 10.12.0 to 10.13.0
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/compare/v10.12.0...v10.13.0)

Updates `typescript-eslint` from 8.71.1 to 8.71.2
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.71.2/packages/typescript-eslint)

Updates `vitest` from 5.0.3 to 5.0.5
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v5.0.5/packages/vitest)

Updates `@aws-sdk/client-ecr` from 3.1149.0 to 3.1152.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-ecr/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1152.0/clients/client-ecr)

Updates `@aws-sdk/client-s3` from 3.1149.0 to 3.1152.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-s3/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1152.0/clients/client-s3)

---
updated-dependencies:
- dependency-name: undici
  dependency-version: 8.12.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: all-npm-backend
- dependency-name: zod
  dependency-version: 4.6.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: all-npm-backend
- dependency-name: eslint
  dependency-version: 10.13.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: all-npm-backend
- dependency-name: typescript-eslint
  dependency-version: 8.71.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: all-npm-backend
- dependency-name: vitest
  dependency-version: 5.0.5
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: all-npm-backend
- dependency-name: "@aws-sdk/client-ecr"
  dependency-version: 3.1152.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: all-npm-backend
- dependency-name: "@aws-sdk/client-s3"
  dependency-version: 3.1152.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: all-npm-backend
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-23 11:06:17 -04:00
Anso 3f8a678a27 fix(docs): use MDX comment markers on the Git transport support page (#2166)
The generated block on the Git transport support page was fenced with
HTML comments, which MDX cannot parse, so the docs build dropped the
whole page and it returned 404 on the live site. Fence it with MDX
comments instead, in both the renderer and the page, and regenerate.
2026-10-22 19:06:01 -04:00
Anso f967a5966c fix(gitops): health-gate a Blueprint rollout under its deploy stack (#2165)
A rollout reserved its health run under the retained source stack name
until the target acked its first Blueprint rollout. The apply writes the
Blueprint's own stack, so a remote leaf answered 404 for the source stack
and a local gate judged the rollout against the wrong workload.

The run now resolves its stack from the intent that placed the deploy,
and the rollback restore resolves it from the target intent then the
application intent. The source stack is no longer a fallback for either.
2026-10-22 14:21:42 -04:00
Anso 646f60f668 fix(recovery): deploy stacks whose inputs a recovery generation cannot hold (#2148)
* fix(recovery): deploy stacks whose inputs a recovery generation cannot hold

Capturing a recovery generation refused every stack with an include or
extends that leaves the stack folder, any path that holds a variable
(env_file, volume source, include), and any managed file that is a link
out of the stack. Those stacks deployed before recovery generations
existed, so deploy, update, Save & Deploy, scheduled updates and Git apply
all failed for them, and the error called a relative path "host-absolute".

Only integrity problems refuse a capture now: no readable compose file, a
compose file that links outside the stack, case-colliding paths, an
unreadable Mesh setting, and the existing Git manifest refusals. Everything
else is recorded as not captured. The generation stores the list, one log
line names it with the generation id, and Rollback readiness shows Managed
inputs as a warning that lists what a rollback will take from the live
stack. A restore never deletes those paths, and a generation with inputs
left out no longer deletes files it cannot attribute to the stack.

Also fixed in the same capture path:
- A link on a parent directory (config -> ../shared) slipped past the
  inventory and made capture throw SYMLINK_ESCAPE.
- A compose directory that is itself a link made capture fail for every
  file of every stack. Capture and the inventory now compare against the
  real compose directory plus the stack name.
- Coverage refusals that carried a permission or parse error were
  classified by that cause instead of as a coverage refusal.
- Credentials in an include URL are redacted from the notes.

A compose file that is a link out of its stack is still refused, so one
stack cannot read or save another stack's compose file. The message now
says so and points at include:.

* fix(recovery): redact tokens in URL includes from capture notes

The notes that list inputs a recovery generation did not capture only
removed a user:password pair from a URL include. A token used as the user
info (https://token@host/base.yaml) or carried in the query string or
fragment still reached the log and the Rollback readiness detail. Remove
the user info entirely and replace the query and fragment with a marker.

* fix(recovery): report a stored generation's own coverage and keep unenumerable includes from blocking capture

Rollback readiness described the live project even when a recovery
generation was current, so editing the compose file afterwards hid what the
generation had left out, and the generation row still promised an exact
restore. Managed inputs and the generation row now come from the stored
generation: complete, missing some inputs, unreadable, or not recorded. A
generation that recorded nothing (older, or a row with no stored content) is
never called covered, because it may have skipped an outside input without a
note. The row still says when the next capture would be refused, and an
unreadable generation no longer leaves the compose file row at ready.

Capture also still refused valid Compose that the first pass did not cover.
An include or extends path with a variable, a variable project_directory, and
a project_directory outside the stack are now reported as not captured
through an opt-in parse mode that only the authored inventory uses. Git
materialization keeps refusing them. An include's own env_file is still
recorded in those cases.

The compose-file rule is documented: a compose file must be inside its stack
directory, and a link out of it is refused so one stack cannot read or save
another's configuration.

* fix(recovery): run rollback against a selected env file inside the compose root

A selected project env file that links to another location inside
COMPOSE_DIR is admitted by capture, which records the resolved real path
in the stored invocation. Recovery validated that path against the stack
directory alone and refused it, so a deploy or update could begin with a
recovery generation that rollback could not launch.

Recovery now validates against the same compose root that capture
authorizes, decides containment on the real path so a name inside the root
whose link target leaves it is still refused, mirrors in-stack files under
a recovery overlay, and keeps an authorized file outside the stack
directory on its live location. A relocated compose directory still
resolves, because both the lexical and the real root are matched. Anything
outside the compose root is refused, as before.

* fix(recovery): report Partial while managed input coverage is unproven

Rollback readiness reported an overall Ready while the managed-input row
warned that a generation leaves inputs out, recorded no coverage, or could
not be read. The headline then contradicted the rows beneath it, before an
operator acted on it.

The overall state now caps at Partial unless managed-input coverage is
proven complete. Complete coverage still reads Ready, and an unreadable
generation keeps the partial result it already had through the compose
file row.

* fix(recovery): restore the Compose project the generation captured

A partial generation could not tell which live file one of its uncaptured
inputs named, so it skipped every live-discovered delete. That also kept a
Compose root or implicit override added after capture, which then won
auto-discovery and decided what the rollback ran. Separately, a declared
optional env file that was absent at capture and later became a link out of
the stack was never removed, so the recovered service imported its
post-capture values while the generation claimed complete coverage.

A generation now records the Compose layers it selected, and recovery pins
them instead of leaving the choice to auto-discovery. A live Compose layer
the generation did not record is deleted, because Compose selects a root or
an implicit override by name and never resolves one through an
interpolatable input. Those layers are observed and removed as leaves, so a
link is unlinked without following its target, while a path being written
still resolves its target and a parent-directory escape is still refused. A
name a ${VAR} input could resolve to is recorded as an input the generation
cannot hold, so restore never deletes it. A managed path that is a link is
snapshotted as a link, so reconciling an interrupted restore puts it back.

Sharing a compose file between stacks is done with include: or extends:. A
stack whose main Compose file is a link out of its own directory stays
refused, with include: as the supported alternative.

* docs(stack-management): state the restriction on a linked compose file

A stack's compose file has to be a file inside its own directory, or a link
that stays inside it, and the operator guide now says so plainly and names
the alternative. The restore exception that removes a Compose layer added
after a partial capture is stated where rollback coverage is described.

* fix(recovery): restore the project and inputs the generation captured

A rollback could run a different Compose project than the one it captured,
and a failed restore could leave a file changed that nothing tracked.

Recovery pinned an inferred list of Compose layers. That list did not match
how docker compose resolves competing filenames, and a -f flag replaces
COMPOSE_FILE rather than adding to it, so a stack that selected its layers
through that variable lost them. It now pins the layers the capture-time run
actually loaded: the conventional discovery order docker compose itself
uses, or the COMPOSE_FILE chain, read with Compose's own precedence, dotenv
quoting and path separator. A chain that cannot be pinned, and the refusal
that names it, are redacted the same way a capture note is.

A partial generation deleted a live file whose name looked like a Compose
file, even when an input whose whole filename is interpolated could resolve
to it, so the restored project could not render. Such a generation now keeps
every file it cannot place.

A managed file that had become a link was snapshotted as a link only, while
the restore write follows that link, so a failed restore left the target's
bytes changed and no record of them. The snapshot now holds what the write
actually changes, and the revert puts the recorded link back before writing
through it.

A symlinked compose directory was accepted for capture and then refused by
restore, because containment compared physical paths against lexical roots.
Capture, restore, reads, writes and link recreation now use one real-root
rule, while a stack directory that links elsewhere stays refused.

A URL in a capture note was redacted up to its first "@", which is legal
inside a password, so the rest of the credential reached the generation
file, the capture log and readiness. The whole userinfo is now removed,
with the query and fragment.

Tests: focused coverage for each of the above, a direct test of the
COMPOSE_FILE reader, and a docker-backed test that renders the live and the
recovery invocation with the real Compose CLI and compares the models. Each
new test was checked to fail without its fix.

* fix(recovery): read a pre-restore snapshot from one file descriptor

The snapshot resolved a managed path, checked it against the real stack
root, then lstat'd and read it by name. Anything able to write inside the
stack could swap the checked file for a link out of it between the check and
the read, so the preimage could come from a path containment never approved.

The validated path is now opened once and the size check, the mode and the
bytes all come from that descriptor, so they describe the same inode. This
matches how the import preview already reads a candidate compose file.

* fix(recovery): keep uncaptured inputs and match Compose's layer selection

A pre-merge audit of this branch found three defects in the partial-capture
path, plus one inaccurate comment.

A partial restore deleted a live compose root or override whose basename
looked conventional. That removed a dependency of an uncaptured external
include: the live shared file extended a service from a local
compose.override.yml that appeared after capture, and the restored project no
longer rendered. A generation that recorded any uncaptured input now deletes
nothing from the live discovery set, and the layers it selected are pinned on
the recovery run, which keeps the rollback project exact without the deletion.

The COMPOSE_FILE reader ignored COMPOSE_ENV_FILES, truncated a quoted value
that spans lines, did not expand references, and dropped empty chain entries.
Each of those could pin a layer set the live run never loaded, or refuse a
stack that works. The reader now ports the one Compose uses, reads the same
file list, records the files COMPOSE_ENV_FILES names as not captured, and turns
anything it cannot reproduce into an explicit refusal instead of a guess.

A stack whose main compose file or implicit override is a link inside the
compose folder now opens, deploys and captures a partial generation: reads and
deploys follow the link, saves and restores never write through it, and a link
out of the compose folder or a stack folder that is itself a link stays
refused.

Review of those changes also closed a cross-stack env write (the editor env
save, template apply, Git env sync and fleet snapshot apply now check ownership
at the write) and stopped refusing a layer whose name holds a literal dollar.

* fix(recovery): read COMPOSE_ENV_FILES under the compose-root containment rule

The selection reader read a COMPOSE_ENV_FILES entry with a raw read at the path
the process environment names, which static analysis reports as an uncontrolled
path expression. The entry is now read through the same containment rule every
other env-file read uses, and a path outside the compose root makes the
selection unknown rather than being read. The refusal reason names the file, so
the operator can move it into the compose folder or select it with a project
env file instead.

* refactor(recovery): read pre-restore snapshot bytes in one named helper

The descriptor protocol (stat, then read, on the handle the caller opened after
its containment check) now lives in readSnapshotBytes, so the snapshot loop
holds the containment and blob logic. Behaviour is unchanged: a non-regular file
is still refused before any read, and the mode comes from the same descriptor
as the bytes.

* fix(recovery): match Compose when it disables or BOM-prefixes the layer env

COMPOSE_DISABLE_ENV_FILE turns off the project .env Compose falls back to
when nothing else selected an env file, but the reader that decides the
layer-selecting COMPOSE_FILE always read it. With the variable set, the live
run loaded the root compose file alone while the capture recorded the .env
chain, and a recovery pinned and ran the other project. The reader now makes
the same strconv.ParseBool check Compose makes, and only where Compose makes
it, so an ignored file cannot decide the layer set. A value Compose's parser
rejects makes the selection unknown and refuses the capture, because Compose
refuses to run in that state.

The dotenv reader also began at character zero and rejected a leading UTF-8
BOM that Compose trims before parsing, so an ordinary stack with a
BOM-prefixed .env became an unknown layer selection and a coverage refusal.
One leading BOM is now dropped, and a BOM anywhere else stays the character
Compose rejects.

The selection also reports every env file the run loads for interpolation,
named before the read so a file Compose reads but this reader's containment
refuses is still visible to a caller accounting for coverage.

* fix(recovery): record the env files a Git generation does not hold

resolveGitInventory returned an empty omission list and the inventory
returned before the authored COMPOSE_ENV_FILES accounting could run, so a
Git-managed generation persisted complete coverage while Compose loaded
operator env files the managed-project manifesto does not hold. After such a
file changed, a restore reported success and the recovered stack rendered
the new value.

The Git inventory now records every env file the run loads for interpolation
that the manifest does not hold, which holds the generation at partial
coverage and lists the files, while the manifest stays the canonical file
inventory and the layer selection. A path outside the stack folder is named
in the note without a deletion-protection entry, and a file the manifest
holds is not listed.

The operator pages now state that an env file Compose loads for
interpolation which the generation does not manage is not captured and a
rollback reads its current content.

* fix(recovery): pin the compose layers the live run loads, Mesh included

A Mesh-enabled deploy pins its own explicit `-f` list: the base compose
file, the implicit override that list would otherwise suppress, and the
generated Mesh layer. The capture predicted the layer set from its own
copy of Compose's discovery order and its own COMPOSE_FILE handling, so
two things could diverge from the run being backed up:

- The order itself. FileSystemService listed the legacy root family and
  every override the other way round from Compose, so a Mesh-enabled
  deploy of a stack holding both `docker-compose.yml` and
  `docker-compose.yaml`, or both override variants, pinned one file
  while Compose's own discovery loads the other. Recovery then pinned
  the file the deploy never loaded and reported complete coverage.
- COMPOSE_FILE. An explicit `-f` list replaces the variable, so a Mesh
  run ignores a chain in `.env` that the capture still read and pinned,
  and the rollback ran a project the deploy never started.

The discovery order now lives in one place, `utils/composeFilenames`, and
every caller that has to agree with what Compose loads resolves from it:
the file the editor reads and saves, the Mesh injection, the rollback
inventory, Git input discovery, and the project-name pin. The inventory
also treats Mesh enablement as a run that pins files, so it records the
discovery pair as the layers instead of the chain a Mesh run suppresses.

Verified against docker compose v5.6.0, which warns and names its pick
when a stack holds more than one candidate.

* fix(recovery): pin an absolute COMPOSE_FILE layer that names the stack

Compose takes an absolute chain entry as written and resolves a relative
one against its working directory, which is the stack directory for every
Sencho spawn, so `/compose/app/prod.yaml` and `prod.yaml` are the same
layer. The capture passed every entry to the relative-path validator, so
an absolute spelling was refused as "not a path inside the stack
directory" and blocked a project Compose runs.

An absolute entry that resolves inside the authorized stack is now
normalized to its stack-relative name before pinning, which is the form a
recovery run can rebuild under an overlay. The real root is matched as
well, so a relocated (linked) compose directory resolves, the same way
every other containment check in the capture treats it. An absolute path
outside the stack stays unpinnable and keeps its refusal.

* fix(recovery): read Unicode assignment names in a compose env file

Compose validates an assignment name with Go's unicode.IsLetter and
unicode.IsNumber over runes, so `UNICODE_é=value` is an ordinary line.
The reader accepted only ASCII letters and digits, so that line made the
layer selection unknown and refused the capture of a stack whose
`COMPOSE_FILE` had nothing to do with it, which is the compatibility
failure this work removes.

The assignment grammar now accepts any Unicode letter or number and is
read by code point, as Compose reads by rune, while a character Compose
rejects (`$`, `@`, `/`, `+`, `?`, `%`, a space) still refuses. The
template grammar a `${...}` reference is read with stays ASCII, which
matches Compose: it rejects a non-ASCII name inside a reference.

* docs(recovery): name the Unicode table drift in the assignment-name reader

The reader validates a name with the letter and number tables this Node
build ships, so a letter added to Unicode after the Compose binary was
built is accepted here and refused there. That direction only
over-accepts, which cannot block a stack Compose runs, so it is the
documented one.

* fix(recovery): match Go when a dotenv escape is not a valid octal

Compose unquotes each escape its regex matched with Go's
strconv.UnquoteChar, which accepts `\ooo` only as exactly three octal
digits holding a byte, and rewrites a `\0` prefix to `\` before that
call. The reader accepted any run of octal digits and any value, so
`COMPOSE_PATH_SEPARATOR="\012"` became a newline while Compose keeps the
literal `\12` and splits the chain there.

The capture then recorded one layer named after the whole value, and the
recovery run could not load it: a stack that deployed fine had a
generation whose rollback failed before any image hold or health check
could matter. A spelling that fails the unquote is now kept in the
rewritten form Compose itself keeps, so `"\012"` is `\12` and `"\0"` is a
single backslash.

Twenty-four separator spellings were compared against Compose v5.6.0, and
the incomplete, bare, out-of-range and valid byte octal shapes are
asserted in the model-equivalence suite with the literal layer list.

* fix(recovery): record every env file an authored generation does not hold

The authored inventory skipped its COMPOSE_ENV_FILES accounting whenever
the layer-selection reader could not decide, and a listed file outside the
compose folder always produces that state: Sencho's containment refuses to
read it while Compose reads it for interpolation. A Mesh-enabled stack
pins its layers, so the capture proceeded and persisted an empty omission
list with complete coverage, while the run rendered every value from a
file the generation never held. After that file changed, a restore
reported success and the recovered stack ran the new value, and the
generation was eligible for the cleanup rule that removes live files it
cannot account for.

Every named loaded env file the generation does not hold is now recorded,
including the names after the one the reader stopped at, which is the rule
the Git inventory already applies to its manifesto. The recorded omission
holds the generation at partial coverage, keeps the file out of the delete
pass, and lists it in Rollback readiness.

* fix(recovery): rebase a captured env file in the matched root's coordinates

The context-directory fallback records the compose-folder spelling of the
project `.env`, and containment is decided against both the lexical and
the real compose root. The mirror was then derived from the captured
spelling against the root that matched, so with a symlinked compose folder
the lexical name was rebased against the real stack folder and escaped the
overlay: a generation that captured successfully could not launch a
recovery that needed a decrypt overlay.

The mirror is now derived in the coordinates of the root that matched, so
the linked spelling resolves to its overlay counterpart, a path whose real
target leaves the compose root is still refused, and an authorized env
file outside the stack folder keeps its live location.

* docs(recovery): correct the type comments the coverage changes superseded

A generation captured before the layer set was recorded does get its
implicit override named on a recovery run, a compose file that links
inside the compose folder is an accepted partial input rather than a
refusal, and a Git inventory does populate its omission list.

* test(recovery): write the recovery layer the Git coverage case renders

The second case in the Git env-coverage file rendered a recovery layer that
the first case wrote, so it passed only when that case ran before it: run on
its own, or in a shuffled order, it failed on a missing compose file. It now
writes its own fixture.

* fix(recovery): refuse escaped compose layers, hold the project env file, and match Compose templates

A review of this branch found four defects in the recovery path, three
of them able to break a rollback that looked healthy.

Recovery validated a captured compose layer lexically, so a main
compose file that had been relinked outside the compose folder was
followed and rendered: the ordinary read refuses that target, but
recovery accepted it. Every captured layer recovery opens is now
checked on its real path. A target inside the compose folder stays
allowed, including a shared file outside the stack folder, and a
missing file stays allowed, because a restore writes the held files
back. A dangling or looping link, and a target outside the compose
folder, are refused before Compose runs.

The inventory did not account for the `.env` Compose loads after it
picks the project folder, so a generation could be certified complete
while a value it used came from a file it never held: the recovered
stack ran the changed value. The project folder is the explicit
project directory, else the folder of the first compose file, else the
folder of the first captured layer. An authored generation now holds
that env file, and a Git generation records it as an omission when
its manifesto does not hold it. An explicit env file, a listed
COMPOSE_ENV_FILES, and a true COMPOSE_DISABLE_ENV_FILE still replace
that default.

The pre-restore snapshot opened its source before checking that the
source was a regular file, and opening a fifo waits for a writer, so a
restore could wait forever before recording its intent. The source is
now stat'ed and refused when it is not a regular file, and the open
uses a nonblocking flag, so a fifo swapped in between the two cannot
block either.

The dotenv reader counted every literal brace as an interpolation
opening, so a valid assignment such as `LABEL=${UNSET:-foo{bar}` was
refused and the capture failed with an invalid template. The reader
now follows the same template grammar Compose does: a literal brace is
text, only `${` opens a nested template, and an unclosed template or
an empty one is still refused.

* fix(recovery): keep the compose-root barrier at the layer sink and read the snapshot source from one descriptor

The static analysis of this branch reported two shapes the audit
response introduced.

The captured layer check resolved a layer name against the working
directory and then read the result, but the containment check that
authorized it lived in the caller, so the check was not credited where
the path is read. The barrier now sits at the read, in the form the
analysis credits, and refuses a layer that resolves outside the working
directory before the path is touched. The rule is unchanged: a layer
inside the compose root is allowed, a missing layer is allowed, and a
target outside the compose root is refused.

The snapshot source was stat'ed and then opened, which leaves a race
between the two: the file that was checked is not necessarily the file
that is opened. The open is now nonblocking and the descriptor it
returns is the one that is checked, so a fifo, a device, or a directory
is refused without waiting for a writer and without reading a path that
changed underneath the check. The refusal keeps its code and its
reason, and the restore still fails before it changes a project file.

* fix(recovery): keep application data out of restore deletes, rebuild the pinned env, refuse escaped layers

A pre-merge audit of this branch reproduced three recovery defects; each
fix has a test that fails with the change reverted.

Restore treated every live input a generation could not hold as a managed
cleanup candidate, so an application-data link that appeared after a
complete capture was unlinked and the application lost its connection to
its storage. Application data and build inputs are not managed paths:
their exclusions carry no note and no path, the inventory records every
path a silent input references, including the literal tail of a dynamic
bind source, and a restore forwards neither their omissions nor their
live entries, whatever managed input also uses the same path. A managed
input that became a link after capture is still removed, and a captured
bind-mounted file is still written back.

Pinning a COMPOSE_FILE selection as an explicit -f list dropped the
stack root .env from the recovery interpolation environment while the
generation claimed complete coverage, so a root-only value fell back
after a rollback. The invocation now records the env files the live run
applied, project file first and root file last, and recovery replays them
as ordered --env-file flags, reproducing the live precedence. An env
file that links out of the compose root is left to its recorded omission
instead of naming a target recovery would refuse, and a file the capture
cannot inspect refuses the capture outright.

Capture accepted a selected COMPOSE_FILE layer linking outside the
compose root as a partial omission, while recovery refuses that same
target before Compose runs. Selected chain layers now follow the
compose-layer refusal policy the root and override candidates apply; a
shared target inside the compose root stays an accepted partial omission.

* fix(recovery): put the captured env-file barrier at the realpath read

The static analysis of the recovery env replay reported the realpath sink
of the captured env resolver as an uncontrolled path expression: the new
invocation carries the recorded interpolation env files, and the guard
that authorized them was a helper the analysis does not credit. The
barrier now sits at the read, in the form the analysis credits, and
enforces the entry contract the function already applies at its bottom:
a captured env file is a path inside the compose root, spelled
lexically or through a linked compose directory, and anything else is
refused with the same message. The realpath read and its lexical
fallback moved into the same frame so the guard and the read sit
together; behavior is unchanged for every existing caller and test.

* fix(recovery): guard the captured env-file read with one constant root

The barrier the previous commit added spans two alternative roots, one
of them the physical form of a linked compose directory, and the static
analysis credits neither a guard over two roots nor a root it cannot
resolve. A captured env file spelled through the physical root is now
rebased onto the lexical root first, so one guard over one constant
root covers both spellings in the form the analysis credits, and the
realpath read and its guard sit in the same frame. The contract is
unchanged: both spellings are accepted, everything outside the compose
root is refused with the message the refusal already used.

* fix(recovery): match Compose dotenv whitespace and refuse non-file layers

Read the interpolation env file with Go's stage-specific whitespace rules so a trailing BOM stays part of a layer name and statement separators Compose accepts do not refuse capture. Refuse a shared compose target that is not a regular file before any blocking open, including the inventory classifier and declared-input reads.

* fix(recovery): keep unresolved application data through restore

Record dynamic application-data and build-input sources, legacy Git host declarations, stack-containing binds, and directory prefixes so a restore never deletes a live path whose ownership cannot be proven. Resolve absolute, linked-root, relative, alias, and physical bind spellings to one stack ownership record, including Git manifest host sources. Protect mixed-role paths and directory descendants from manifest paths, tombstones, and live forwarding. Read authorized env files through the lexical compose-root spelling and allow COMPOSE_FILE-only stacks. Report whether a stored generation holds the compose layers it selected.

* fix(recovery): refuse a captured input that is no longer a regular file

A recovery generation omits every input it cannot hold, so a restore never snapshots or replaces those paths. A shared compose layer or selected env file could therefore become a FIFO after capture, and recovery still accepted the arguments: Compose then blocked opening it instead of naming the invalid input.

Recovery now requires each existing captured compose layer and selected env file it hands to Compose to be a regular file, on the path the run opens: the overlay mirror for an in-stack file, and the live path for an authorized input outside the stack. A missing file is still left for Compose, and containment is unchanged. The refusal carries NOT_A_FILE and lands before the execution boundary.

Tests capture a real generation for a shared main compose file and a shared selected env file, replace the omitted target with a FIFO, a directory, and a UNIX socket, and assert a prompt typed refusal with no Compose spawn. An overlay mirror is covered too, and regular shared inputs keep their supported partial recovery path.

* fix(recovery): refuse a non-regular interpolation env input before it blocks

A recovery rebuilds the live inventory before it restores anything, because
that inventory decides which live paths are safe to touch. The reader that
selects COMPOSE_FILE read the project .env and every COMPOSE_ENV_FILES file
with a blocking read, so a file that was a regular file at capture and later
became a FIFO with no writer held the rollback (and rollback readiness) inside
that read.

Interpolation env reads now check the path type before opening and the
descriptor type after a non-blocking open, keeping the compose-root
containment rule. A file that exists but is not a regular file is reported as
invalid rather than unknown: capture refuses it whether or not the layer set
is pinned, and restore throws NOT_A_FILE before any project file moves, so a
stack is never left written but unlaunchable. The refusal survives the
first-apply and refused-manifest Git merges, and the recovery spawn repeats
the check for the COMPOSE_ENV_FILES files the child inherits but the captured
argv never names. An explicit --env-file replaces that list for
interpolation, and the scan is positional so a compose layer literally named
--env-file is not mistaken for the flag.

Tests run the real capture and restore context in an externally bounded
child, so a regression fails the suite instead of holding the worker, and
cover a listed env file, the project .env, a first capture that meets the
FIFO, and regular and in-root linked files that must keep working. In-process
tests cover the typed selection, the pinned inventory refusal, the restore
refusal before any write, and the spawn guard.

* fix(security): decide env-file type on the descriptor, not a prior stat

The interpolation env readers checked the path type with a stat and then
opened the same path, which is a check-then-use pair: the file can change
between the two calls, and the check is not what the read uses. The open is
already non-blocking and the descriptor is what the read consumes, so the
type is now decided on that descriptor alone. A socket cannot be opened for
reading at all and a directory is not openable everywhere, so those open
errors map to the same NOT_A_FILE refusal instead of falling through as an
unreadable selection.

The recovery spawn guard resolved COMPOSE_ENV_FILES entries against a working
directory built directly from the stack name. It now uses the validated stack
path the rest of the recovery invocation uses, so the operator's own
environment file list is stat'ed against a path that has already passed the
stack-name containment check. Behavior is unchanged: the same entries are
checked, the same refusal is raised, and overlay working directories still
resolve relative entries the way Compose does.

Tests keep covering a FIFO, a directory, a socket, the project .env and the
spawn guard, and the bounded capture/restore scenarios are unchanged.

* fix(security): anchor the env-file guard on the contained stack path

The recovery spawn guard stats COMPOSE_ENV_FILES entries against the Compose
working directory. That directory came from the validated stack resolver, but
the containment decision happened inside a helper, so the path was not shown
to be contained at the point it reaches the filesystem call. The check now
sits inline on the working directory itself, in the same form the other path
sinks in this service use: resolve the stack name against the compose root and
require the result to stay inside it. An overlay working directory is prepared
for the operation and does not carry a request path, so it is used as is.

No behavior change: resolveValidatedStackDir still rejects an invalid stack
name, and the inline check is the same containment rule restated where the
filesystem call can see it.

* fix(security): keep the operator env-file path out of request-tainted flow

The recovery spawn guard stats COMPOSE_ENV_FILES entries. Those entries are
process configuration the operator sets for the whole Sencho process, and the
same value is inherited unchanged by every Compose child, so inspecting a
path's type in Sencho does not widen what the operator can reach. The path was
resolved inline from the stack working directory, though, so taint from the
route's stack name reached the stat call.

The resolution now goes through resolveOperatorConfiguredPath, whose contract
is operator configuration and never request data, and the path-injection model
records that boundary the same way the existing validation helpers are
declared. The containment check on the stack working directory stays inline at
the call site.

Behavior is unchanged: the same entries are resolved against the same working
directory, and the same refusal is raised.

* fix(security): drop the CodeQL model for the operator env-file path

The path-injection model added for the recovery env-file guard changed the
analysis configuration of this branch, so the required code scanning gate
could no longer compare the pull request against the base branch and reported
a skipped comparison instead of a result. The model is withdrawn.

The guard keeps the inline containment check on the stack working directory.
The paths it inspects are the operator's own COMPOSE_ENV_FILES entries, never
request data, and the alert for that sink is dismissed with that rationale.
The model file matches the base branch again, so the gate can evaluate this
pull request normally.

* fix(recovery): read the env filename Compose opens from COMPOSE_ENV_FILES

A recovery replay checks the inherited COMPOSE_ENV_FILES before spawning Compose so a FIFO cannot hang the rollback. That guard trimmed each entry, while Compose uses every non-empty entry verbatim, spaces included. A valid spaced filename was therefore refused, and a spaced FIFO or directory could pass because the trimmed sibling did not exist.

Read the entries through the same selection helper the capture uses, so the guard names exactly the file Compose opens. Cover the spaced file with a trimmed sibling directory, a real Compose model comparison through the recovery launcher, the spaced special-file refusal, and explicit --env-file precedence.

* fix(recovery): read CR and Unicode separators in env defaults

The template pattern copied from Compose used a JavaScript dot for a substitution default. Go's dot excludes only LF, while JavaScript's also excludes CR, U+2028 and U+2029, so a \r escape or a literal separator in an unused or used default failed to match. The reader then reported an invalid template, which became an unknown COMPOSE_FILE selection and a coverage refusal for a stack Compose runs.

Match Go's dot with [^\n]*. LF still prevents a match, as it does in Compose. Regression coverage compares the reader with the Compose CLI for all four spellings, checks the selection and inventory path, and keeps the LF refusal.

* fix(recovery): decide restore env refusals from the captured invocation

A restore refused a usable generation when the current project env
selection named a file the recovery run never reads. Recovery replays the
generation's own invocation, so the restore now derives the interpolation
env files that invocation opens (an explicit --env-file, then the recorded
COMPOSE_FILE interpolation list, then COMPOSE_ENV_FILES, then the project
.env) and refuses NOT_A_FILE only for a non-regular file among them.

An unusable or undecidable live selection no longer blocks the rollback:
it disables deletion, because the live project is not fully known and no
unrecorded path can be proven stale. The pre-check classifies the target
type on the path Compose opens, which follows links and never blocks, so
a FIFO behind an escaping link or a COMPOSE_ENV_FILES entry outside the
compose root is still refused before any project file moves.

Tests cover a real-context restore after the selection is replaced
(directory and FIFO without a writer), a FIFO behind a project .env link,
an outside COMPOSE_ENV_FILES FIFO, delete-none for an undecidable
selection, and a real Compose render proving the replayed model uses the
captured value.

* test(recovery): pin the captured env source precedence

* test(recovery): assert restored bytes instead of a file check

* fix(recovery): restore a link target's mode on failed-restore revert

A failed restore snapshots the preimage of every managed path before it
mutates, and reverts from that snapshot when a later step fails. When the
preimage was an in-stack symlink, the revert restored the link and the
target's bytes but skipped the target's mode, because the public chmod
path refuses a symlink leaf. It then removed the snapshot, and the caller
removed the restore intent, so the failure looked fully compensated while
a private file had become readable by other local users.

Apply the snapshotted mode to the staging file of the compensating write,
so the bytes and the mode land in one atomic promotion. A link preimage is
treated like any other: the write follows the link, and the mode it
restores is the one the snapshot read through it. If the mode cannot be
applied, the promotion does not happen, the revert throws, and the
snapshot and intent stay for the retry that startup reconciliation owns.
The file explorer's chmod still refuses a symlink leaf.

Tests cover the private link-target round trip under a permissive umask,
an injected mode failure that keeps the intent and reports an incomplete
revert, the retry through reconciliation, and the regular-file mode that
the same write now restores.

* test(recovery): read the compensation target through one descriptor

The compensation test checked the restored target's mode by path and later
read the same path again. A static analyzer models that check-then-use as a
file-system race, even though the assertions run against a private temp
directory, and the alert blocks the pull request.

Open the target once, read the bytes and the mode from that handle, and
close it in a finally. The assertions are unchanged and now describe one
consistent file.

* test(recovery): observe the retried target once

The retry test opened the restored target to assert its state after the
failed compensation and opened the same path again after reconciliation.
A static analyzer models the two opens of one path as a check followed by
a use, so the alert survived the previous descriptor change.

Keep the failure proof in the durable evidence, the incomplete-revert
signal, the pending intent and the retained snapshot, and observe the
target only once, after the retry, through a single descriptor. Coverage
for the corrected behavior is unchanged.

* fix(recovery): replay an absent default env file in the recovery invocation

A stack captured with no default .env, a dynamic data bind and an
interpolated value was recorded as complete. A restore keeps any file it
cannot prove disposable, so an .env created after capture stayed in place,
and the recovery run, which named no env input, let Compose discover it.
The recovered project then resolved ${VAR} references from a file the
generation never held.

Capture now records an empty interpolation env list when the default env
path is active and nothing exists at it: the stack root .env, and the
selected project directory's .env when the layers moved the project. The
stack root file only counts when a pinned COMPOSE_FILE chain keeps it in
the interpolation environment; an explicit -f or --project-directory makes
Compose read only the project directory's file. Recovery replays the empty
list as an explicit empty env input, which replaces default discovery and
COMPOSE_ENV_FILES. Anything present at a default env path, including a
directory, a special file or a link, is not an absence and keeps its
existing accounting. Preservation of unowned live files is unchanged.

* fix(recovery): keep an operator env file and the implicit override across a Git rollback

A Git generation treated every env file the live project loaded but the
manifest does not hold as a cleanup candidate, so a `.env` created after
capture was deleted by the restore and its bytes were not recoverable. The
inventory now separates the paths it records as omitted from the ones it owns
and may remove: a Git manifest owns none of its omissions, so only owned
omissions reach the restore. When no default env file exists at capture the
Git inventory also records an empty interpolation env list, as the authored
inventory does, so the preserved file stays out of the recovered project.

Recovery also pinned only the configured Git compose file, which dropped the
manifest's implicit override from the recovered project, most visibly when
Mesh appended its layer. The recorded layers now include a managed override
that exists on disk, after the configured file.

The partial-restore wording in the atomic deployments page now says that
files the generation recorded still return to their captured state.

* fix(recovery): evaluate the stack env disable from the run environment

A stack root .env can supply COMPOSE_DISABLE_ENV_FILE and COMPOSE_FILE together. Compose uses that file to resolve the chain but keeps its assignments out of interpolation and skips the nested project .env. Capture only consulted process.env, so it recorded and replayed both files, reintroducing values the live run never read.

Read the disable through the canonical env reader, report whether the stack env was read and whether the selected project env is disabled, and record exactly the files the run read. A self-disabled stack env now records an empty input, replayed as --env-file /dev/null. Gate a file-supplied disable to runs whose initial project directory is the stack root: with -f or --project-directory starting elsewhere Compose never opens the stack .env.

Add focused selection tests, inventory cases for the self-disabled and -f start-outside-root shapes, and a real-Compose model-equivalence regression.

* fix(recovery): refuse a chain whose project env link leaves the root

A COMPOSE_FILE chain can move the project directory into a subfolder. When
that subfolder's .env was a link leaving the compose root, the ordered
interpolation list was dropped, so recovery pinned the layer without
replaying the stack .env that had won a shared key. A real Compose run
rendered from-project after rollback versus from-root before it, with no
input changing.

Record the list when every source is namable, and let discovery read a sole
outside project env file in place. When an unnamable source coexists with
another source that must be replayed, refuse the capture before any write
with a note naming the file. Shared links inside the compose root keep the
existing ordered replay.

Covered by unit and real-Compose regression tests asserting the literal
root-winning value and the pre-mutation refusal.
2026-10-22 10:27:48 -04:00
Anso 108a47aaab fix(git): classify an untrusted-signer TLS failure as a certificate error (#2158)
Some git builds report a private-CA failure as "SSL certificate
verification failed: certificate signer not trusted" instead of the
OpenSSL wording the classifier already knew. The message fell through to
the generic "Git fetch failed" text, so the operator got no hint that the
fix is to upload the CA certificate on the git source.

Match the signer phrase in the certificate-could-not-be-verified branch.
The shared "SSL certificate verification failed" prefix is deliberately not
matched on its own, because the same prefix carries causes a CA upload does
not fix, such as an insecure certificate algorithm; those keep the generic
message that shows the underlying reason.
2026-10-21 09:20:31 -04:00
Anso 95398a182d fix(deps): bump follow-redirects to 1.16.1 (#2157)
Update the backend lockfile to follow-redirects 1.16.1, the release that fixes GHSA-q3jh-wrc6-jgq8 / CVE-2026-111756 and GHSA-8r9p-f939-6h3c / CVE-2026-111755. Both high-severity advisories cover 1.16.0, and the existing axios and http-proxy ranges already permit the patch, so no manifest change is needed. The audit gate passes with this lockfile; it failed on follow-redirects 1.16.0.
2026-10-20 15:44:16 -04:00
Anso 935158b5e6 feat(resources): bring the Resources page to the design system (#2156)
* fix(resources): load the Resources page and volume prune without the slow image df

On a host that uses the containerd image store, the image section of
`docker system df` takes about 30 seconds, and the Resources page waited
on it for its footprint figures. Pruning volumes repeated that full df
once per volume, and the prune preview ran into its 12 second timeout.

- The disk-usage figures now come from calls that answer in milliseconds:
  a shared-size image listing plus the volume and container sections of
  df. The reclaimable image figure is the bytes unused images do not
  share with another image; the exact df is used only when the daemon
  does not report shared sizes.
- External and managed volume bytes were always zero because the volume
  listing carries no usage data. They now come from the volume section.
- Prune planning and execution read only the volume section for volume
  usage, ask for container sizes only when containers are planned, take
  shared image sizes from the listing, and inspect idle networks in
  parallel. When volume usage cannot be read the plan fails with an
  error instead of reading as nothing to prune, and execution skips the
  volume with a clear reason.
- The Resources page loads the tables and the disk usage separately, so
  the tables no longer wait on the figures. Refreshing after an action
  keeps the rows on screen, and a failed load shows an error with Retry
  instead of an empty table.

On a node with 200 volumes and 85 images, the disk-usage request went
from about 31 seconds to 0.2 seconds, prune previews from a 12 second
timeout to under 0.4 seconds, and pruning 200 volumes now takes about
1.5 seconds.

* feat(resources): bring the Resources page to the design system

The Resources page had a reclaim banner behind an opt-in setting, a disk
footprint card, four equal-weight Quick Clean tiles, and per-node chrome
of its own. It now follows the same shape as the other primary pages.

- A hero masthead gives the one answer: how much a cleanup would free,
  Tidy when nothing is left, Measuring disk while usage loads, and Disk
  usage unknown when the node could not report it (never Tidy). Managed
  and external bytes sit beside it.
- Reclaim and a cleanup menu (prune unused networks, prune stopped
  containers) sit in the tab strip. Images and Volumes each have their
  own Prune unused. Every prune opens an itemized preview with a scope
  switch (Sencho-managed by default, or All Docker) that rebuilds the
  plan when it changes.
- The reclaim banner and its opt-in setting are removed, along with the
  footprint card and the Quick Clean tiles.
- Filters use the shared segmented control, scans use the shared scan
  flow, and confirm buttons show progress through the shared busy label.
- The Unmanaged tab is named External. Its rows use checkboxes and its
  project headers are quiet rows. A Rollback protected badge on a
  stack-held image opens the Rollback tab.
- The docs page, the pages that mention the old tiles, and the
  screenshots are updated.
2026-10-20 09:51:36 -04:00
Anso 880e6c6489 fix(resources): load the Resources page and volume prune without the slow image df (#2155)
On a host that uses the containerd image store, the image section of
`docker system df` takes about 30 seconds, and the Resources page waited
on it for its footprint figures. Pruning volumes repeated that full df
once per volume, and the prune preview ran into its 12 second timeout.

- The disk-usage figures now come from calls that answer in milliseconds:
  a shared-size image listing plus the volume and container sections of
  df. The reclaimable image figure is the bytes unused images do not
  share with another image; the exact df is used only when the daemon
  does not report shared sizes.
- External and managed volume bytes were always zero because the volume
  listing carries no usage data. They now come from the volume section.
- Prune planning and execution read only the volume section for volume
  usage, ask for container sizes only when containers are planned, take
  shared image sizes from the listing, and inspect idle networks in
  parallel. When volume usage cannot be read the plan fails with an
  error instead of reading as nothing to prune, and execution skips the
  volume with a clear reason.
- The Resources page loads the tables and the disk usage separately, so
  the tables no longer wait on the figures. Refreshing after an action
  keeps the rows on screen, and a failed load shows an error with Retry
  instead of an empty table.

On a node with 200 volumes and 85 images, the disk-usage request went
from about 31 seconds to 0.2 seconds, prune previews from a 12 second
timeout to under 0.4 seconds, and pruning 200 volumes now takes about
1.5 seconds.
2026-10-20 09:17:17 -04:00
Anso 48b8d698b3 feat(gitops): observe remote rollout targets over the node proxy (#2149)
* feat(gitops): observe remote rollout targets over the node proxy

A health rollout policy could not gate a rollout target on another node. The
gate runs on the instance that owns the rollout, a remote node's Docker daemon
is not reachable from there, so every observation of a remote target ended
unknown and a policy other than observe was refused before dispatch.

The node that hosts the stack now serves its own runtime facts at
GET /api/stacks/:stackName/health-observation, with the declared Compose
restart intent when asked for it, and the gate reads them over the node proxy.
Local nodes keep the in-process Docker read, and the gate keeps one observation
engine, one run row and one attribution path.

health-gate-observation-v1 is advertised by every build that serves the route,
and the placeholder gitops-health-rollout-policy is never advertised, so a hub
that predates the protocol still refuses a remote target before dispatch.

Failures stay fail-closed: a node that does not report the facts a verdict
needs ends the observation unknown at the first poll, any other unreadable poll
keeps the three-strike budget, and a clean exit whose declared intent could not
be read ends unknown rather than failed, so a rollback policy cannot restore a
workload that may have completed exactly as declared. A node row this instance
does not have is unreadable rather than local.

The node-proxy JSON read is extracted into one helper, and the leaf fetches in
the artifact-resolve path use it.

* chore(security): make the compose path containment checks analyzer-visible

The CodeQL javascript-typescript check reports js/path-injection at two sinks
that a rollout health observation reaches with a request's stack name:
FileSystemService.hasComposeFile (the fsPromises.access call) and
ComposeService.renderConfig (the spawn cwd).

Both are guarded already, but not in a form the analyzer credits: the first
called the wrapped assertWithinBase helper, and the second relied on a check
scoped to the branch that assigned the working directory rather than one that
dominates the sink. Both now carry the inline barrier the codebase already uses
elsewhere for this rule, in the same scope as the sink.

No behavior change. assertWithinBase and the inline check use the same rule, and
every caller of hasComposeFile already passes a stack directory under the
compose base. renderConfig re-checks the same root its branch check validated,
immediately above the spawn.

* chore(security): keep the hasComposeFile guard in the shape the analyzer reads

The first pass combined the prefix check with an equality disjunct, which the
path-injection query does not model as a guard. The single startsWith check is
the shape the codebase already uses at its other sinks, and it is what clears
the alert. The compose base itself is not a stack, so refusing it is the
contract every caller already relies on; the test pins that too.

* fix(health-gate): decide definite failures before an ambiguous clean exit

An exit with code 0 whose declared Compose restart intent cannot be read is
undecidable on its own, but it was ending the observation immediately. A peer
that was unhealthy or had exited non-zero later in the same poll was never
inspected, so the run ended unknown and a retry, stop, or rollback policy was
replaced with a pause.

Hold the ambiguity until the rest of the poll is inspected, in both the stack
and the service scope, and decide it only where it is the sole unsatisfied
evidence. At the window end definite failures are judged first: a missing
expected container or replica, an unhealthy container, and a replica count the
gate could not satisfy all outrank an ambiguous peer.

A service replica count can now no longer be satisfied by a replica the gate
never watched while a tracked replica exited unexplained, and an unhealthy
replica of the operated service that appears after the gate armed fails on the
poll where it is seen, since a later recovery would erase the evidence. Scale-0
services keep their previous accounting: a failed leftover is ignored, while a
running container or a declared clean one-shot is still count drift.

Adds regression coverage for both container orders, both scopes, unhealthy and
non-zero-exited peers, a missing replica at the window end, untracked
replacements, the unhealthy-recovery case, and the scale-0 boundary.
2026-10-19 21:16:13 -04:00
Anso 09d454709b fix: remove a stack's mute rules when the stack is deleted (#2152)
* fix: remove a stack's mute rules when the stack is deleted

Mute rules created from a stack's Mute actions stayed behind after the stack
was deleted, and would silently mute a later stack created under the same name.

Deleting a stack now removes the rules that target only that stack on that
node. Rules that also cover other stacks (wildcards, several stacks, a whole
node) or other nodes are kept. Removal retracts the rule from remote nodes the
same way deleting it from settings does. It runs for locally hosted stacks,
for stacks deleted on a remote node through the hub, and for stacks withdrawn
from a remote node by a Blueprint. A failure is logged and never fails a
delete that has already happened.

* fix: log mute rule cleanup failures with a constant format string

The stack name is user supplied and was interpolated into the format string
of the cleanup failure logs. Pass it as an argument instead, so a name that
contains a format directive is printed literally. The logged text is
unchanged.
2026-10-19 13:49:04 -04:00
Anso e9f824266b feat: open a published port at its configured service URL (#2124)
* feat: open a published port at its configured service URL

Store the address as the compose label sencho.service.url so a port click follows that host, and keep the generated link when the label is absent.

* fix: reject an escaping compose path before reading it

The compose filename scan checks the path stays inside the compose directory before it stats the file, so a rejected path is not treated as a missing file.

* fix: keep inherited labels and unsaved edits when a service URL is saved

A URL edit copies inherited labels onto that service, a changed compose filename keeps the draft unless the operator reloads, and a missing URL list still opens the generated link.

* fix: keep a service URL edit on the service that was opened

A shared label or service anchor is copied before the edit, clearing a merged URL does not restore the inherited address, and a running container opens only its own saved URL.

* fix: keep empty labels and dynamic ports on a service URL

A URL edit dropped empty labels and failed when a nested anchor was still used. A Docker-assigned port now opens that service's own URL, and the pencil stays unavailable when the service is not in the open compose file.

* fix: keep a service URL on the service that was edited

A shared URL value no longer changes another service. Open App does not follow a different service's dynamic address, a named port on its own bind address stays clickable, and a repeated list label takes the new address.

* fix: keep a service URL dialog on the stack it was opened for

The dialog now records the node and stack it was opened on, closes when that selection changes, and refuses the save before it can write a different stack. Clearing or replacing a list alias updates only the edited service. A URL supplied by another compose file stays on that file, and reloading the compose source refreshes the cached addresses.

* fix: edit a service URL without rewriting the rest of the compose file

A save splices the label into the original text, so octal modes, line endings, and unrelated keys stay as written. A node with no host keeps the plain port, a refresh keeps the last URL, and Open App opens its tab before the lookup.

* fix: keep service URL edits and compose saves inside the edited stack

- Refuse a compose file, or a stack directory, that resolves outside
  <compose root>/<stack>, so a read or save of one stack cannot reach a
  sibling stack's compose file. Covers saveStackContent too.
- Edit flow-style labels and services in place, copy anchored flow
  nodes without their defining key, keep source tokens such as 0440 and
  022 when labels are copied, append after the last real label (before
  trailing comments, and at an end of file with no newline), and keep
  the separator when an empty URL value is set or cleared.
- Check every patch before it can be saved: the file is read with YAML
  types and as untyped text, and anything that changes outside the URL
  label, drops a comment inside brackets, or touches a file with a
  custom YAML tag is refused with a clear message.
- Return the cached service URLs only for the node and stack they were
  read for, so a port never links to the previous selection.
- Log stacks the networking summary skips instead of dropping them
  silently.

* test: assert the networking summary skips a sibling-linked stack

A stack whose compose file resolves outside its own directory is skipped
with a warning. Assert the linked stack stays out of every bucket, the
healthy stack is still summarized, and the skip is announced.

* fix: keep service URL edits effective and prototype-safe

The effective-anatomy response now reports, per service, whether the open
main compose file controls the sencho.service.url label (serviceUrlEdit).
That is established from the authored chain: the label key presence is read
in the files merged after the open one (deploy spec order, or the resolved
main file plus the auto-discovered override). A later file that sets the key
blocks the edit whatever its value (invalid, interpolated, or equal); a later
file that uses include, extends, a custom YAML tag, or COMPOSE_FILE, or that
cannot be read, refuses the edit as unverifiable. The pencil lock, the save
guard, and a fresh re-read immediately before the write consume this
metadata, so the editor no longer infers ownership from the label value or
its absence. Post-save verification stays.

Service-keyed maps are built on null prototypes and read through own-property
lookups across the backend extractor, the response parser and resolvers, and
the local anatomy facts. Services named constructor, toString, hasOwnProperty,
or __proto__ now resolve the generated link when they have no label and keep
an own override when they do.

* fix: refuse a service URL edit the compose chain would shadow or reveal

A later bare `sencho.service.url` list item (`- sencho.service.url`) sets
the key to an empty value and shadows the open file, so the source
authority now counts it as a key contribution like the `key=value` form.

Clearing is not the mirror of setting: removing the open file's direct
label can surface an earlier chain file, the open file's own include, a
service's extends target, or its label_file. The anatomy response now
carries a per-service clear authority alongside the set authority, the
editor refuses an empty save the chain would keep winning or reveal, and
the patch helper refuses the same layouts before writing. Setting a URL
is unaffected.

The patch helper also treats a bare list item as the key when setting or
clearing, so a set no longer leaves a trailing empty entry that renders
as an empty value.

Operator docs describe the set and clear limits accurately.

* fix: refuse interpolated label keys and honor explicit dynamic ports

Compose interpolates a list label's key, so `- "${URL_LABEL}=https://app"`
sets `sencho.service.url` even though the authored source never names it.
A later file using that shape reported the open file as editable and the
write did not take effect; an earlier or later one failed to block a clear.
Such a key now marks the service unverifiable, so both guards refuse before
the save. A literal key with an interpolated value stays a known set.

`published: 0` (from `"0:80"` or `{ target: 80, published: 0 }`) asks
Docker to assign the host port, but the dynamic-owner predicate treated it
as a fixed host, so a container-port click and Open App fell back to the
generated node address. It now counts as automatic allocation, leaving UDP
binding, named-service isolation, and ambiguity refusal unchanged.

* fix: keep a saved service URL effective and a port link on its owner

- Read the open compose file's own source in the service URL set
  authority: a labels list whose key is interpolated (`- "${NAME}=..."`)
  makes that service unverifiable instead of editable, including when the
  file's other facts cannot be traced (a top-level `include` or a custom
  tag) and when the file cannot be read at all.
- Refuse a set and a clear in the editor buffer when the target service's
  label list carries an interpolated key, so an unsaved draft is caught
  before any write.
- Open a named container's own automatic binding URL on its card when
  Docker assigned it the host port, even when another service's saved
  file declares the same number, and refuse a saved-only single fixed
  declaration when another service's automatic binding could hold it.
- Carry the runtime main-port owner (`com.docker.compose.service`) from
  the bulk stack status through to Open App so it opens the address of
  the running service that owns the port.

* fix: keep a runtime container card on its own service URL

- Treat a named runtime container as direct ownership evidence: when a
  card carries both the compose service and the running container's own
  port, that service's configured URL applies even after the saved port
  declarations changed or were removed without a redeploy.
- Keep saved-model inference and its ambiguity refusals for callers
  without runtime port data, such as the anatomy footer.
- Cover the changed-port, removed-binding, hostless-remote, and rendered
  anchor cases in resolver and component tests.
- State in the stack management docs that the configured label applies
  first and the generated address is the fallback.

* fix: keep merged label text and a runtime card's generated link

Clearing a service URL from a labels map that also merges a YAML anchor
materialized the whole map from parsed values, which deleted operator
comments inside the map and respelled unrelated scalar tokens. The clear
now copies inherited entries from their own source text, leaves direct
entries untouched, and refuses layouts whose text cannot be kept.

A running container whose compose service has no URL override fell
through to saved-model inference and could turn ambiguous when other
saved services claimed the same host port. Runtime ownership now answers
with the service's own URL or the ordinary generated host and port.

Regression tests cover the preserved comments and tokens (block, flow,
CRLF, merge lists, nested merges, shared anchors) and the generated
anchor plus copy target for a runtime owner without an override.

* fix: wait for the first service URL lookup before opening a port

A first effective-anatomy lookup was read as "no override", so a
published-port link and Open App could open the generated host and port
while a configured service URL was still being fetched. The in-flight
entry now stays pending: the port renders as plain text until the outcome
is known, and Open App opens the tab in the click and navigates once the
lookup resolves. Completed outcomes (no label, a failed load, an older
node) keep the generated fallback, and a refresh that still holds the
prior model keeps rendering it.

* fix: keep a long service URL on the card and a refused path honest

A long host now truncates so the copy button and pencil stay clickable, and every port link keeps its open arrow. Clearing the only service URL removes the labels key, including when the address has a fragment. Opening a stack the server refuses shows that reason. Effective anatomy refuses a compose file or discovered override that links outside the stack. The editor shows a new URL only after the save is accepted.

* fix: keep the port link visible and drop a sole URL list

The port mapping stays on the card when the host is long, and on a phone the identity line wraps above the action buttons. Clearing a labels list that only holds the service URL removes the labels key, while a comment still keeps an empty value.

* fix: keep a cleared service URL renderable and the port controls reachable

A clear that left the labels map with no entries wrote `labels:` with no
value, which Compose rejects ("must be a mapping"), so the stack stopped
rendering and every pencil in it disappeared. The key now goes with its
last label, and a comment the removal would drop keeps the entries as an
empty map or list under the key instead. That covers a clear through a `<<`
merge, a sole label with a trailing comment, a comment line closing the
block, and a shared anchor whose other consumer keeps its address. A comment
between the removed entries is still refused before any write.

The container list now clamps to its pane, so a long host truncates and the
port link's copy button and pencil stay reachable at 1440px and below
instead of sitting past the pane edge. The copy button also gets the 44px
phone target the pencil already had.

A refused compose path answers with the condition instead of "path is not
allowed", and the parser no longer warns about the custom tags those edits
refuse.

Every layout the clear can produce renders under `docker compose config`.

* fix: keep the port line readable in a narrow container pane

The pane clamp left the action icons at full size, so the name and port link collapsed. The card now wraps those icons once the name column would drop below 12rem. A refused URL edit stays in the dialog until the address or the compose buffer changes.

* fix: keep the service URL editable after a failed list load

A failed first load keeps the generated link, explains that the list is unavailable, and offers Retry. A later failed refresh keeps the last address and the pencil. The pencil tooltip matches the copy control.
2026-10-17 18:33:13 -04:00
Anso 25c272eef1 fix: classify a synced sibling .env as a SOPS secret (#2131)
* fix: classify a synced sibling .env as a SOPS secret

A sibling .env fetched with sync was stored as plaintext, so deploy
injected the ciphertext into the container. Classify it with the same
detector used for env files.

* fix: refuse a stale SOPS capability for a synced .env

A same-commit pull reused a candidate that still recorded the synced
.env as plaintext, so apply could publish ciphertext and a manual
deploy was allowed. Replace that candidate, refuse apply and dispatch
when the stored recipients are short, and validate both through the
decrypt overlay.

* fix: guard applied SOPS files and keep decrypted values out of validation errors

Manual deploy followed the last deployed generation after an apply.
Validation errors echoed decrypted env values, and a configured .env
was read from the live stack instead of the candidate.

* fix: withhold decrypted compose diagnostics and pass overlay env files

Validation returned short, partial, and rewritten secret values. A context
directory deploy also omitted the decrypted root .env that validation had
already approved. That env file is accepted only at the path its name claims.

* fix: contain overlay env files against the area root at the filesystem call

The shared helper resolved a directory argument that could be the candidate
itself, and the override branch never reached the compose-root check.

* fix: keep project env-file selection consistent for nested and relocated roots

An authorized git-secrets or git-managed area override is recognized when
the data directory sits inside the compose root. Candidate validation
already accepted that area file while deployment refused it as escaping
the stack directory, so a source that validated could not deploy.

Configured project env files compare real paths with real roots, so a
relocated data or compose directory (a symlink to another volume) is
accepted while a link that leaves the authorized tree is still refused.

Regression tests cover both areas with sibling and nested data roots,
configured env files under a symlinked data or compose directory, and
the escaping-link refusals.

* fix: withhold compose diagnostics for any decrypted candidate

Candidate validation decided whether to withhold the Compose diagnostic
by scanning the decrypted env file for `KEY=value` assignments. Compose
also accepts YAML-style `KEY: value` env files, so a decrypted YAML
SOPS `.env` could disclose its values through the returned validation
result, the pull and webhook logs, and the recorded outcome.

Withhold the diagnostic whenever validation ran through a decrypt
overlay, without reading or interpreting the decrypted content, and
return the same fixed message for a thrown validation error. A failed
overlay decryption still reports its own classified error.

Regressions: a real YAML SOPS sibling `.env` with a real Compose failure
asserting the fixed message, retained ciphertext, and overlay cleanup;
and a Git apply deploy that consumes the decrypted env from the overlay,
keeps the live and staged copies encrypted, and removes the overlay
after a successful and after a failed Compose run.

* fix: withhold Compose output when a deploy runs with decrypted secrets

A SOPS decrypt overlay feeds plaintext into Compose, and Compose quotes
interpolated values back in its own diagnostics (`unable to get image
'<value>?': invalid reference format`). Validation already withheld its
diagnostic for that reason; deployment forwarded it, so a decrypted value
from a synced `.env` could reach the returned deploy error, the server log,
and the durable GitOps settlement.

Carry the same boundary to the operation that runs Compose: an operation
scope keyed on the overlay, entered by deployStack and
composeUpWithRecoveryOverride, read once by the shared subprocess executor,
which drops raw chunks (one notice replaces the stream) and rejects with a
fixed message instead of the collected output. The scope covers the
compensation and rollback compose runs nested inside a failed deploy.

Regression: an encrypted sync-env pull and apply/deploy through a stand-in
docker that fails with the decrypted value in its diagnostic. The value is
absent from the result, the logs, and the stored rows, while the stored
inputs stay ciphertext and the overlay is removed. A control pins that a
deploy without an overlay still reports Compose's own words.

* fix(gitops): render the deploy preflight from the decrypt overlay

A Git deploy runs Compose out of the decrypt overlay while the live stack's
.env stays ciphertext, but the mandatory external-network gate rendered the
live project. An encrypted value used where Compose needs a typed value was
resolved from ciphertext and refused the deploy before up: a published port
failed the render outright, and an external network name classified as an
invalid name. Candidate validation passed on the overlay, so a candidate that
validated could not be deployed through the path its refusal points at.

The gate now renders the project the deployment will use: the overlay working
directory, the authored compose files, the env-file arguments, and the same
project-name pin, all selected for the overlay and contained against the
git-secrets root at the spawn. A decrypted render withholds its Compose
diagnostic, which is where interpolated values are quoted back, and reports a
fixed message instead; the model itself stays in-process for the classifier to
read. The Pilot bind-mapping guard keeps rendering the live project, because
what it checks is where relative binds land on the host.

Tests cover an encrypted published port and an encrypted external-network name
through a real apply and deploy with the real gate, the resolver and render
plumbing, the withheld diagnostic, the containment refusal, and the unchanged
live path.

* fix(gitops): withhold the required-variable diagnostic over a decrypt overlay

The missing-variable guard ran its own `docker compose config --quiet` against a
GitOps decrypt overlay and parsed the full stderr for variable names. A Compose
`:?` message is itself interpolated, so
`${MISSING:?required variable ${SECRET} is missing}` makes Compose print the
resolved value of SECRET inside the very phrase that identifies the missing
variable, and the parser published that value as though it were a name. On a
decrypt overlay that value is a decrypted secret, and the refusal it produced
reached the returned result, the server log, and the durable GitOps settlement
on the deploy retry path, which resumes from an existing manifest with no
candidate validation in front of it.

The overlay branch now collects no output at all and decides on the child's exit
code: a resolved render passes, anything else refuses with a fixed withheld
message spliced between the guard's existing prefix and remedy. The check is
also bounded (20s, matching the preflight render) and spawned without pipes,
since an exit code is now its only signal. The live-project path is unchanged
and still names the missing variables.

A decrypt-overlay deploy whose model cannot be rendered was already refused by
the external-network gate on the same failure, so the outcome does not change;
only the wording and the ordering of the refusal move.

* fix(gitops): read the pilot bind check from the deploy's own project

The Pilot host-path guard rendered the live project and returned without a
decision when that render failed. A Git deploy runs Compose out of the decrypt
overlay, so a typed value held in an encrypted env file (a published port, an
external network name) failed the live render and the guard let the deploy
through. The external-network gate in the same change had started rendering the
overlay instead of the live project, which removed the refusal that used to
follow on the same failure: the deploy ran `up` with the overlay as its working
directory and resolved a relative bind under the temporary overlay instead of
the host path the mapping points at. A short bind can create an empty host
directory and replace a healthy workload against the wrong data.

The guard now reads the render that describes the project Compose will actually
run: the overlay when the deploy runs in one, the live project otherwise. A
mismapped pilot clears a bind only when the overlay's own model clears it, and
the trees a bind must stay out of are the overlay's stack area and the compose
tree, the same reach the live check has always had. An interpolated source is
why the live project cannot stand in for the overlay: ciphertext that Compose
accepts as a relative path resolves inside the live stack directory, while the
decrypted value beside it can name an ordinary external path, so reading live
would refuse a deploy the overlay carries out correctly.

When the render cannot be read the guard still makes no decision, and the
external-network gate refuses the deploy on that same overlay, so the failure
mode is unchanged. The four callers that deploy without an overlay are
untouched, and the refusal now names the paths that disagree rather than
quoting a bind source, which an overlay can interpolate.

Tests cover the refusal before any cleanup or `up`, the interpolated absolute
bind only the overlay resolves, a parent-segment escape, a bind naming the
compose tree, and a relative bind an included file resolves there.

* test(gitops): pin the 1:1 mapping control on the pilot bind guard

The refusal tells the operator to use a 1:1 mount with the same absolute path
on the host and in the Pilot Agent, so that configuration has to clear the
guard. With the same path on both sides there is no mapping to disagree with
and the guard decides on the mapping alone, before it renders anything.

The project in this regression carries a relative bind on purpose: if the guard
ever stopped deciding on the mapping alone it would render that project, find
the bind inside the compose tree, and refuse, so the pass is the mapping
decision rather than an unreadable render. A control asserts the rendered bind
really does resolve inside the compose tree.

* test(gitops): render the include fixture on Compose 2.x too

The included-file regression declared the same service name in the included
file and the including file, and left that service without an image, which
relied on Compose 5.x merging the two declarations. Compose 2.x refuses both
shapes: `services.<name> conflicts with imported resource`, and `service
"<name>" has neither an image nor a build context specified`. The unit-test
runner ships Compose 2.38.2 while Sencho builds 5.1.3, so the regression failed
on CI and passed locally.

The included service is now named apart from the including file's service and
carries its own image, so the model renders on either version. The bind still
resolves against the included file's own directory, which is the routing the
regression pins, so it still fails when the compose tree is dropped from the
guard's trees.

* fix(gitops): keep relative data binds on the live stack

A decrypt overlay was the Compose project directory, so an ordinary relative
data bind was created inside that temporary tree and deleted when the overlay
was removed. The deploy now rewrites those binds onto the live stack, leaves a
directly mounted secret file on the overlay, and refuses a mount that would
have to carry both before the stack starts.

* fix(gitops): check the stack path beside the directory lookup

The compose-file pin looked up the stack directory through a helper, so the
path check was not next to the filesystem call. The lookup now rejects a path
that leaves the compose root in the same function.

* fix(gitops): keep persistent storage on the live stack through a decrypt overlay

A named volume whose local driver binds a host directory keeps its device path
in `volumes.*.driver_opts.device`, not in a service mount, so the rewrite layer
never moved it and the volume was created against the overlay directory that
cleanup removes. The layer now rewrites that device onto the live stack too.
Which devices Compose relocates is answered by Compose: the model is read a
second time with path resolution on, and only a device that comes back absolute
while the authored one is project-relative moved with the project. A remote
driver export (`type: nfs`, `cifs`) or an `o: bind,ro` form keeps the value both
runs pass to the daemon. A device that resolves into the decrypt area is
refused. The Pilot host-path guard reads the same device form, so a rewritten
device cannot reach the host as the container path.

The bind decision now reads the overlay model without path resolution. A
relative source arrives in its authored form, so a mount that climbs two or more
parents keeps the live path instead of a directory beside the overlay, and a
relocated data directory no longer depends on how Compose resolved its own
working directory through a symlink. A relative source that resolves outside
Sencho's secrets area and outside the live compose tree is refused rather than
mounted where the stack never named it. The one-parent and context-directory
cases keep the paths they had.

* fix(gitops): decrypt a restored env that records no recipients

Recovery returned no decrypt overlay when a generation's stored capability
named no recipients, which is the state a generation stored before the synced
sibling `.env` was classified carries. Rolling back to it ran Compose over the
ciphertext, so the container received `ENC[...]` values.

The restored files are now the authority when the capability names no
recipients. The restored `.env`, the configured project env files, and the paths
the generation recorded are classified, an age document supplies its own
recipients, and the overlay is built from them. A file that no identity on the
node can decrypt refuses the rollback with `missing_key` instead. A genuinely
plaintext restored env stays on the plain path.

* fix(gitops): keep live storage, host paths and plaintext through SOPS runs

An authored compose file whose name ends with the generated storage layer's
name no longer disables the persistent-mount rewrite, so a relative bind or a
named volume's local device keeps the live stack as its host path instead of
the overlay directory the operation deletes afterwards. The layer is
identified by the path it owns rather than by an argv token, it is appended
once per argument assembly (the assembly that layers a recovery override no
longer adds it twice), and a compose file that already holds the reserved name
is refused instead of being replaced by it.

Two path forms the rewrite compares are now read in filesystem terms: Compose
renders a literal dollar as `$$`, so a secret file whose name holds one keeps
the decrypted overlay copy as its source instead of being mistaken for
ordinary data and pointed at the live ciphertext.

A rollback runs the same Pilot host-path guard as a deploy, over the invocation
it is about to execute: the captured file set, the env selection, the recovery
layer, and the decrypt overlay. A mount that resolves into a tree this node
manages while the host mapping is not 1:1 is refused before containers are
replaced, so a recovery point whose storage moved cannot attach the wrong host
directory.

Recovering a generation decrypts every restored env input that is still age
ciphertext, not only the files its stored capability recorded, so a generation
that recorded a recognized encrypted env file while its synced sibling `.env`
was classified as plaintext no longer reaches Compose as ciphertext. A file
both sets name keeps the role and path the generation recorded. Readiness is
per file: a file encrypted to several recipients is decrypted when any one of
those identities is held, and only a file no identity here can open refuses
with `missing_key`.
2026-10-17 18:13:54 -04:00
dependabot[bot] 07dff3fea7 chore(deps): bump the all-npm-backend group in /backend with 10 updates (#2136)
Bumps the all-npm-backend group in /backend with 10 updates:

| Package | From | To |
| --- | --- | --- |
| [cron-parser](https://github.com/harrisiirak/cron-parser) | `5.10.1` | `5.10.2` |
| [express](https://github.com/expressjs/express) | `5.2.1` | `5.3.0` |
| [express-rate-limit](https://github.com/express-rate-limit/express-rate-limit) | `8.7.0` | `8.7.1` |
| [zod](https://github.com/colinhacks/zod) | `4.6.5` | `4.6.6` |
| [@types/dockerode](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/dockerode) | `4.0.1` | `4.0.2` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `26.6.4` | `26.6.5` |
| [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) | `8.71.0` | `8.71.1` |
| [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) | `8.3.2` | `8.3.4` |
| [@aws-sdk/client-ecr](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-ecr) | `3.1146.0` | `3.1149.0` |
| [@aws-sdk/client-s3](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-s3) | `3.1146.0` | `3.1149.0` |


Updates `cron-parser` from 5.10.1 to 5.10.2
- [Release notes](https://github.com/harrisiirak/cron-parser/releases)
- [Changelog](https://github.com/harrisiirak/cron-parser/blob/master/CHANGELOG.md)
- [Commits](https://github.com/harrisiirak/cron-parser/compare/v5.10.1...v5.10.2)

Updates `express` from 5.2.1 to 5.3.0
- [Release notes](https://github.com/expressjs/express/releases)
- [Changelog](https://github.com/expressjs/express/blob/master/History.md)
- [Commits](https://github.com/expressjs/express/compare/v5.2.1...v5.3.0)

Updates `express-rate-limit` from 8.7.0 to 8.7.1
- [Release notes](https://github.com/express-rate-limit/express-rate-limit/releases)
- [Commits](https://github.com/express-rate-limit/express-rate-limit/compare/v8.7.0...v8.7.1)

Updates `zod` from 4.6.5 to 4.6.6
- [Release notes](https://github.com/colinhacks/zod/releases)
- [Commits](https://github.com/colinhacks/zod/compare/v4.6.5...v4.6.6)

Updates `@types/dockerode` from 4.0.1 to 4.0.2
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/dockerode)

Updates `@types/node` from 26.6.4 to 26.6.5
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `typescript-eslint` from 8.71.0 to 8.71.1
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.71.1/packages/typescript-eslint)

Updates `vite` from 8.3.2 to 8.3.4
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v8.3.4/packages/vite)

Updates `@aws-sdk/client-ecr` from 3.1146.0 to 3.1149.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-ecr/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1149.0/clients/client-ecr)

Updates `@aws-sdk/client-s3` from 3.1146.0 to 3.1149.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-s3/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1149.0/clients/client-s3)

---
updated-dependencies:
- dependency-name: cron-parser
  dependency-version: 5.10.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: all-npm-backend
- dependency-name: express
  dependency-version: 5.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: all-npm-backend
- dependency-name: express-rate-limit
  dependency-version: 8.7.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: all-npm-backend
- dependency-name: zod
  dependency-version: 4.6.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: all-npm-backend
- dependency-name: "@types/dockerode"
  dependency-version: 4.0.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: all-npm-backend
- dependency-name: "@types/node"
  dependency-version: 26.6.5
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: all-npm-backend
- dependency-name: typescript-eslint
  dependency-version: 8.71.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: all-npm-backend
- dependency-name: vite
  dependency-version: 8.3.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: all-npm-backend
- dependency-name: "@aws-sdk/client-ecr"
  dependency-version: 3.1149.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: all-npm-backend
- dependency-name: "@aws-sdk/client-s3"
  dependency-version: 3.1149.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: all-npm-backend
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-17 13:46:16 -04:00
Anso 5ca0f6a38a fix(gitops): keep an accepted Blueprint commit on the next poll (#2126)
A review-policy fetch was staging that commit again and posting another pull-ready notice. Reuse the accepted generation when the fetch still matches, and skip the notice when nothing new is staged.
2026-10-17 13:32:15 -04:00
Anso 903419814c fix: serve the UI typefaces from the instance (#2132)
* fix: serve the UI typefaces from the instance

The page loaded its faces from a third-party host on every visit, including sign-in. Bundle those faces and stop allowing remote font and style sources.

* fix: keep the full subset coverage of the served typefaces

Bundling the faces left only the latin and latin-ext subsets declared, so
Cyrillic, Cyrillic Extended, Greek, Greek Extended, Vietnamese and the
symbols2 block rendered in a system face instead of the selected one.

Declare every subset the installed packages publish for the faces the
Appearance preference offers, with the range the package subsets each face
to, and hold the declarations to those ranges and to representative
characters per script in the font test.
2026-10-17 12:16:39 -04:00
Anso daed461f93 fix(blueprints): detect a remote name conflict from the leaf stack list (#2129)
The ownership probe's remote arm read GET /api/stacks as an array of objects
carrying a name, but the endpoint answers with the stack names themselves. The
match therefore matched nothing, so every remote name read as clear: Check again
released a name_conflict row while the conflicting stack was still there, the
preview never flagged a remote create as blocked, and the deploy pre-flight
never ran. Only the leaf's own apply-local check held, which is why nothing was
ever written over.

Read the names the leaf sends, and fail closed when the answer is not readable:
a name found among the readable entries is still answered from the marker, while
a name absent from a list holding entries this version cannot read is an
unverified probe rather than a clear name. A non-array body counts as unreadable
rather than as an empty node. The local arm is unchanged.
2026-10-16 09:41:16 -04:00
Anso 7a6682ed3f fix: pull the next commit on a manual Git-managed Blueprint (#2130)
* fix: pull the next commit on a manual Git-managed Blueprint

A claimed manual source had no operator fetch: polling skips it, and stack Pull now stays refused. Pull source stages a candidate and leaves acceptance to Accept source, except an automatic policy, which accepts the same way a poll does.

* test: supply a node list to the GitOps sheet mocks

The authority row reads the default node from the node context. These mocks omitted nodes, so the sheet threw before it rendered.

* test: type the Git source permission mock without unused parameters

The default can implementation always returns false. Naming the permission arguments made lint fail.

* fix: send the Blueprint id and report what Pull source did

The claimed sheet posted the internal application id, so the pull never reached the Blueprint. An automatic pull could accept and start a rollout while the result only said the commit was fetched.

* fix: report a continuing automatic pull instead of a failed one

Acceptance can commit before preparation and rollout finish. Answer once that wait reaches its deadline, keep the source locked until the work finishes, and do not describe the deadline as a failed pull.

* fix: report why an automatic pull cannot prepare a commit

A completed preparation reason was replaced by the generic unresolved-artifact skip. Keep that reason, and describe the claimed sheet by the source actions it still offers.

* fix: refuse a pull that would replace an interrupted apply's candidate

Apply recovery stays eligible only while the interrupted generation is still the current candidate. Pull source waits in that case. An interrupted fetch still pulls, and a pull stays available once the candidate has moved on.

* refactor: share the bounded follow-up after a Git-managed acceptance

Accept source and an operator pull each had their own copy of the deadline, the continuation notes, and the slot hold. One follow-up now answers both.

* fix: declare the post-accept deadline timer as const

The deadline id is assigned once. A later assignment made the lint fail.

* fix: confirm Accept source before it can start a rollout

Accept source recorded the fetched commit immediately, including when stored rollout authorization can deploy it, and those actions were not on the sheet Overview. The confirmation names the commit, the approved targets, and whether a rollout can start, and confirm posts the generation that was on screen.

* fix: read placement approval only on a Blueprint projection

The accept preview read approvals on every projection, and the one with nothing to show has no approvals. The Blueprint check comes first so the build can read the placement ref.

* fix: name the approved rollout scope and report settled failures

Two confirmations could describe a destination the action would not use, and
one result reported work as starting when it had already failed.

Accept source named every observed target row as approved placement. Those rows
are where an application runs, not where a rollout goes, and they disagree
whenever placement moved since the last rollout ran: an approved node has no row
until the rollout that places one, and a withdrawn node keeps one. A projection
now carries the scope the placement approval froze, read from the rollout
candidate and accepted only once the approval resolves for that set, which is
the evidence the dispatch boundary already uses. Both confirmations read it
through one reader and name the approved nodes, or say the scope cannot be
read rather than naming a set they did not.

The bounded follow-up used its continuing-work answer for a deadline and for a
rejection alike, so a dispatch that had already failed was reported as
starting, with nothing pending. The three outcomes are now distinct: only a
deadline holds work still running, and a rejection reports the rollout as held
with the failure named.

* fix: bind a source confirmation to the request that carries it

Pull source and Accept source asked the operator to confirm a destination and
then sent nothing that named it. Fetch posted an empty body and acceptance
posted only a generation, so execution read whatever the placement and the
rollout policy happened to say at the moment it dispatched. An operator could
confirm a placement for one node and deploy to another, and a dialog promising
a waiting rollout could start one after another session armed the policy.

Both bodies now carry what the dialog read: the placement approval the scope was
resolved from, and whether the confirmation said a rollout could start. The
description and the claim are frozen together when the overlay opens, so the
words the operator agreed to and the state the server checks are one read of one
projection.

The claim is checked at every consequential boundary rather than only at the
route, because each one is preceded by asynchronous work: at the route and at
the controller entry, again before an automatic acceptance after the reconcile,
and again in the handoff after preparation. A refusal that lands after the
acceptance committed is held with the reason instead of answered, since the
reconciler re-drives any accepted generation with nothing to check it against
and a refusal that recorded nothing would be undone by the next tick. The hold
carries an operator origin, so only a person clears it, and the recovery is the
existing one: review the placement, then resume the rollout.

The destination is compared exactly, because the dialog named specific nodes.
The rollout promise is compared one way: a confirmation that allowed a rollout
and finds the policy waiting for an operator does less than it said, and the
dispatch gate already reports that, while the reverse would start something the
operator was told would wait.

A manual or review pull only fetches, so it asks for nothing, and the poll and
resume paths send no confirmation at all because a cadence is configured rather
than confirmed.

* fix: keep the confirmation hold inside the mode that can clear it

Three ways the refusal did not survive contact with the rest of the model.

The hold was recorded below the rollout authorization policy gate, so a manual
source paused the rollout over a confirmation the handoff could never have acted
on. A manual policy never dispatches from here, and its Authorize is already the
operator's own decision against the placement as it stands, so all that bought
was an extra resume step before an authorize the pause also blocks. The gate is
back first and the confirmation is checked under it, so only an automatic policy,
the one that would have dispatched on its own, is held.

The hold outlived the Blueprint. Every resume route resolves a Git-managed
Blueprint and answers NOT_GIT_MANAGED for the row afterwards, so a pause carried
through Retire or Detach read as a paused rollout nothing could clear. Both
demotion paths now clear it, which is what the Blueprint demotion already does
with every other column it invalidates.

And the module claimed the check ran at every consequential boundary, which is
only true of the boundaries an operator action reaches. A cadence carries no
claim to check, so the poll and the reconciler's re-drive are not among them, and
the hold is what keeps them from undoing a refusal.

Each correction is covered by a test that fails without it: a manual-policy
acceptance whose placement moves during preparation is not held, an operator hold
survives a later acceptance on the policy's own behalf, and retiring a Blueprint
releases a held rollout.

* fix: make a confirmed acceptance keep its placement past the request

A confirmation carried only in a request body stops existing when the request
answers, and two paths outlive it while both still dispatch. Preparation can run
past its deadline and hand the handoff to the reconciler, and a restart re-drives
any accepted generation with no request in the picture. The guard therefore
protected the fast path and nothing else, which is worse than none: the operator
was told to review a change that then deployed on its own.

The acceptance now records the precondition on its own approval row, in the
column that already existed there and was unused for this kind. A null recording
is a real answer rather than missing evidence: an acceptance made by the poll
records none, because a cadence is configured rather than confirmed.

It is enforced in ensureRolloutAuthorization, the single funnel every mint
passes and the only place an unattended dispatch reaches, so it covers the
reconciler's re-drive and the startup preflight backfill as well as the request
that carried the claim. Only a policy mint is bound: an operator mint is the
decision the confirmation deferred, taken against the placement as it stands. A
stale precondition holds rather than merely refuses, because a refusal with
nothing behind it is re-attempted every reconcile and leaves a rollout queued
with no reason attached. The acceptance itself stands, so the content is
recorded and only its destination waits for a person. An application-scoped
resume releases the recording; a target-scoped one does not.

The same function now re-resolves ingredients after the registry preflight
await and refuses when the placement approval or the frozen node set moved. The
mint re-reads ingredients inside its write transaction, so a placement
re-approved mid-preflight produced a rollout naming the replacement nodes with
readiness evidence that described the old ones. That gap predates this work, but
a request-local guard cannot see past an await, and a rollout generation is
immutable once written.

* fix: compare the whole ingredient record, and let an operator mint decide

Two gaps in the durable guard, both found by reading it against the paths that
reach it.

The post-await comparison watched the placement approval and the frozen node set
and nothing else. A source acceptance landing during the same preflight window
changes the generation, the artifact set and the acceptance without moving
either of those, so the check passed and the mint named the new generation with
readiness evidence evaluated for the old one, which is the same defect the
comparison was added to close. Every field the mint copies onto an immutable
rollout generation is compared now.

An operator mint is exempt from the guard, because asking to authorize the
rollout is the decision the confirmation deferred. Nothing moved the recording
on that path, so once the authorization lapsed the automatic policy held a
rollout asking the operator to resume a placement they had deliberately
authorized. An operator mint now re-points the recording at what was decided,
inside the mint's own transaction, which also keeps the guard working for the
next replacement.

* fix: keep the whole source confirmation after the request ends

A stored placement pointer let a later re-drive start a rollout the operator was told would wait, or deploy to a destination the confirmation never named. The acceptance now stores both facts, and unattended authorization reads that record rather than the request that already finished.

* fix: show source confirmations and start a ready automatic resume

Operators were asked to confirm a sentence they could not read, and Resume reported that an automatic policy would start a rollout it never dispatched.
2026-10-15 18:35:56 -04:00
Anso d48daa08ae feat: raise the Compact launcher quick-link cap to 10 (#2120)
Operators can pin ten destinations on the bar. The recommended starting set stays at six, and the server still accepts up to sixteen so a ten-pin list keeps syncing.
2026-10-12 00:35:33 -04:00
Anso 7c99a65085 feat(settings): change fresh-install defaults for charts, log chips, progress style and stack guardrails (#2119)
* feat(settings): change fresh-install defaults for charts, log chips, progress style and stack guardrails

Fresh installs now start with the Signature chart palette, per-service
log chip colors, inline deploy progress, one retained rollback
generation per stack, and the missing-required-env deploy guard on.
Existing stored values are unchanged.

* fix(settings): make appearance reset match the fresh-install chart default

Reset to default was still applying the Calm preset, which put the muted
chart palette back. It now restores upright headings, reduced effects, and
the Signature chart palette, and the appearance screenshots match that look.
2026-10-12 00:32:36 -04:00
Anso 23f87354c4 feat(stacks): rescan from the scan banner; recheck Blueprint name conflicts (#2118)
The post-deploy scan banner in the stack editor gains a Rescan verb. It
starts the same scan on the node that owns the stack, follows it to the new
result, and says so when the rescan finishes clean, since the banner then
goes away. A second click while a scan is running starts nothing, and the
scan-status read now reports whether one is running. Dismiss stays separate
and still lifts when a new attempt lands.

A Blueprint deployment stuck in Name conflict now says what to do and offers
Open conflicting stack and Check again. Check again only re-evaluates the
conflict: when the name is free the row returns to Pending and the next
reconcile deploys it, with the usual state review for stateful Blueprints.
It never deploys by itself, Git-managed Blueprints are left to their
rollout, and an ownership check that cannot tell keeps the row in conflict.

The two banner dismiss hooks are merged into one that keeps the existing
storage keys and events.
2026-10-11 23:05:39 -04:00
Anso 63220a7d92 feat(notifications): mark alerts read and mute them where they appear (#2117)
* feat(notifications): mark alerts read and mute them where they appear

Add POST /api/notifications/:id/read, a node-scoped single-row read
that answers 404 for an id that is not on that node. Recent alerts and
the bell now carry a per-row Mark read, and Recent alerts gains the same
admin-only Mute menu the bell has. Both run in place: neither navigates,
and the bell stays open.

* fix(notifications): keep a confirmed read from being undone and mute on the hub

Refetch the feed after a confirmed mark-read so an older in-flight response
cannot restore the unread row. Send mute-rule creation to the hub regardless
of the active node, so Mute from Home works while a remote node is selected.
Cover the Home wiring, the shared mute menu, and both behaviors with tests.

* docs(notifications): describe mark read and mute on Recent alerts and the bell
2026-10-11 22:57:22 -04:00
Anso 87db3f121e feat(drift): say how to resolve drift and dismiss it (#2116)
* feat(drift): let a team dismiss a drift finding on the ledger

Drift findings can now be dismissed until they change, for a set number of
days, or permanently. The dismissal lives on the finding's own row in the
drift ledger on the node that owns the stack, so the ledger's detected to
resolved lifecycle retires it: once the finding clears its row is closed, and
a recurrence is a new finding with no dismissal.

Each live finding is published with a stable id, a fingerprint of its
structured facts (kind, service, compose value, running value, never the
message), a count, and a per-kind policy. The Git managed-path conflict is
not dismissable. Dismissing and restoring need exact deploy authority on the
stack, the dismiss route re-reads the stack and refuses a finding that
changed or cleared since it was seen, and the drift status and finding count
keep counting dismissed findings.

Image mismatch findings also carry whether the running image was built after
the one compose names, read from the images' build times. Anything that
cannot be proven reads as unknown.

* feat(drift): resolve and dismiss drift findings from the Drift tab

Each drift finding now names its fix and carries the verb that runs it:
redeploy to match compose (confirmed first, through the editor's own deploy
handler), start a missing service, or open the stack editor or Git source
where the change is a decision about compose rather than the runtime. When the
running image is provably newer than the one compose names, the editor leads
and the redeploy confirm says it moves the service back to an older image.
Verbs the account cannot run are dropped instead of left to answer 403.

Findings can be dismissed until they change, for 7 days, or permanently, and
restored from the dismissed list. The status keeps its verdict and counts the
dismissed findings. A report from an older remote node, which cannot record a
dismissal, offers no Dismiss.

* docs(drift): document resolving and dismissing drift findings

Describe the verb each finding kind offers, the redeploy confirm, dismissing
and restoring, and who can run what. Rework the drift tutorial around the
redeploy button on the finding, with fresh screenshots of the finding, the
overflow menu, the confirm, the resolved state, and the Activity timeline.

* fix(drift): scope dismissals and late answers to the stack they belong to

Dismissing a finding now writes only that finding's own ledger row instead of
reconciling the whole stack from a snapshot that may be behind a concurrent
check, so it can no longer resolve or insert sibling findings or stamp the
check time. The Drift tab ignores a re-check, dismissal, or restore that
finishes after the operator moved to another stack or node, and closes an open
redeploy confirm on a switch.

Open stack editor needs permission to edit the stack, so a deployer is not
shown a button that does nothing. The dismissal hook takes either a hub surface
or a node endpoint, a restore that finds nothing re-reads the list, a dismissal's
mode and expiry are one type, and the drift dismiss route bounds the count like
the other dismissal routes. Adds tests for the dismissal lifecycle, the race,
the failure paths, and the handler wiring.

* docs(drift): say which buttons need permission to edit or deploy
2026-10-11 22:50:30 -04:00
Anso 184f2ceacb feat(security): act on posture findings from the overview (#2113)
* feat(security): give posture reasons a key and fingerprint, and a dismissal surface

Each posture reason now carries a stable key, a fingerprint of its structured
targets, and a policy for how a team may set it aside. Blockers cannot be
dismissed; evidence that is missing or old can only be dismissed for a set
time. A confirmed image update names the stack and service behind it so it can
be applied from the overview.

* feat(security): act on posture reasons from the overview and dismiss review items

Each reason in the review queue carries the verb that resolves it: review and
apply a confirmed image update per stack, set exposure intent per service,
check update availability again (and refetch), rescan the node, open the scan
on its secrets or misconfigurations tab, or go to Triage or History. The
masthead action runs the same verb. Review and informational reasons can be
dismissed until they change; blockers never can.

* test(security): isolate the SecurityView refresh test from the new reason hooks

* docs(security): describe in-place verbs and dismissing review items

* fix(security): tighten posture actions from review

Scan failures are reported and only a finished scan refreshes the overview,
an overview read for another node never drives actions on this one, a service
behind several findings is listed once, and the per-key severity is one
exhaustive record.

* fix(security): match verbs to route guards and keep tab shortcuts

Rescan node follows the node scan route's global permission, secrets and
Compose risks keep their tab shortcut instead of opening an arbitrary scan,
a verb that runs in place keeps the tab shortcut in a menu, accounts that
cannot read dismissals skip the read, and failed scans are dismissed for a
set time only.

* fix(security): keep refreshing the overview after a failed node scan

* fix(security): keep the overview overflow menu after Dismiss

The review queue built its shortcut menu inside the verb slot, so Dismiss
landed after the overflow icon. Pass that shortcut through the shared
finding-row menu, which already keeps the menu at the trailing edge.
2026-10-11 22:41:47 -04:00
Anso f935fa7b1d feat(networking): resolve and dismiss networking findings in place (#2106)
* feat(networking): give networking findings a structural key and dismissals

Networking finding ids were hashes of the message, so rewording a message
moved every id. They are now `kind|stack|service|network|subject`, and each
finding carries a fingerprint of its severity and structured targets, a
target count, and the dismissal policy of its kind.

Adds the networking dismissal surface: a per-kind policy record, a key
parser that derives node, stack and kind, and routes to dismiss, list and
restore. Doctor-only findings stay with Compose Doctor: acknowledged ones are
kept and marked instead of dropped, and left out of the counts.

* feat(networking): resolve and dismiss networking findings in place

Each networking finding now carries its own verb and Dismiss, in the Findings
tab and in the Overview "Operator attention" block alike. Exposure intent is
set in a popover over the row, a Doctor-only finding is acknowledged through a
confirm that says it changes update readiness, copy actions move to an
overflow menu, and Compose Doctor's remediation line is finally shown.
Dismissed and Doctor-acknowledged findings sit under a quiet "N dismissed"
row with Restore, and the posture word still counts them.

* docs(networking): describe in-place verbs and dismissing findings

* fix(networking): tighten dismissals and Doctor acknowledgement from review

Setting an exposure intent now drops the networking memo so the finding is
re-checked at once. Acknowledge in Doctor is offered only when every open
occurrence names a service, because a service-less rule is acknowledged for
the whole stack. Service-less Doctor findings (such as port conflicts) stay
separate cards, finding keys are length-bounded, a failed dismissals read
keeps the last list and says so, and a partial acknowledge or clear reports
how far it got and re-reads the list. Adds tests for the dialog and the hook.

* fix(ui): order finding row actions as verb, Dismiss, overflow

Enforce outline primary and split Dismiss chrome, move networking overflow
into a trailing slot, and drop the mobile-only dismiss kebab.

* fix(ui): match finding row comments to the controls

The verb class does not set outline, and the overflow slot only renders what the caller passes.

* revert: restore the original finding row action layout

The outline split and trailing overflow order read worse than the previous row.

* Revert "fix(ui): order finding row actions as verb, Dismiss, overflow"

This reverts commit 6948a084960ab00cb639b3e1c6cf6d5d1e50dfb5.

* fix(ui): keep finding row actions in one order with the overflow menu last

Every finding row now lays out its controls as the resolving verb, then
Dismiss, then the overflow menu at the trailing edge, whichever of them a
row lacks. Networking used to place its overflow menu between the verb and
Dismiss, so the order changed from row to row and Dismiss shifted whenever
a row had secondary verbs.

FindingRowActions takes the secondary verbs as `more` and owns the menu. A
surface that has an overflow menu keeps its slot reserved on rows with
nothing to put in it, so Dismiss lines up across rows. Fleet Readiness has
no overflow menu and is unchanged.

Below the md breakpoint the secondary verbs and the Dismiss options fold
into one trailing menu instead of two adjacent ones. A dismiss in flight
locks only the Dismiss items there, as it does on desktop.
2026-10-11 22:33:34 -04:00
Anso 2fedeafbf7 feat(readiness): resolve readiness findings in place (#2101)
* feat(readiness): resolve readiness findings in place

Each Readiness finding now carries the verb that fixes it where it is
listed, instead of a link to another page:

- Test connection, Start stack or stopped services, Check again,
  Capture recovery point, Take fleet snapshot and Scan node run on one
  click against the finding's own node.
- Review update, Install scanner and Re-anchor go through an overlay
  first. The update itself runs through the same request the editor
  uses, so the policy gate and the progress panel still apply.
- A verb is offered only when the account passes the route's own check;
  otherwise the row keeps its page link. Findings from a peer that
  predates the new fields fall back to the page link too.

Update and recovery findings now carry the signal behind their reason
and whether an update is known, and both feed the finding fingerprint
so a new image or a changed blocker brings a dismissed finding back.
Capturing a recovery point drops the cached rollup so the finding it
clears leaves the list at once.

The stack update request and its error parsing move out of the editor
hook into one shared module used by the editor, Auto-updates Apply and
the new verb.

* fix(readiness): report update failures and verb errors accurately

- A stack update that never got an answer (dropped connection) now
  toasts the error instead of leaving only the deploy panel.
- A failed update says when the stack was rolled back and shows the
  cause and next step; a recheck warning replaces the success line
  rather than stacking under it.
- A failed connection test reports its own reason instead of "could not
  reach"; a node scan with failures warns instead of reading as clean.
- The re-anchor confirm picks the paused sync row, ignores a late
  lookup for another finding, and says when the anchor could not be
  read.
- A refused or failed update still re-reads the check.
- Reason facts of the wrong type from a peer are ignored.
- Add tests for the shared update request, its hook, the lifecycle
  helpers and the verb failure paths; correct stale comments.

* refactor(readiness): flatten the verb confirm description

* fix(readiness): label the partial-stack verb for what it does

The start route starts every container of the stack, so both the stopped
and the partly-down finding now offer "Start stack".
2026-10-11 22:25:10 -04:00
Anso 11312d84d3 feat(readiness): dismiss readiness findings until they change (#2100)
Findings on Fleet > Readiness can now be set aside for the whole team. One
click on Dismiss moves a finding out of the list with an Undo, and it comes
back by itself if the finding gets worse or changes. A chevron offers a
7-day or permanent hold. Each row also says in one line what to do about it.

Dismissed findings are never hidden: the status line counts them and a quiet
"N dismissed" row lists who dismissed each one, when, and for how long, with
Restore. Dismissing never changes the matrix cells or the fleet state.

Dismissals are held on the hub. The finding id names the node and stack, and
the server derives permissions from it (deploy on the stack, manage on the
node, admin for Control). The server reads the finding itself and refuses a
dismissal if it changed after the operator saw it. A dismissal is retired
only when a complete live read shows the finding gone, so an outage never
drops one. Findings that restate the Security posture cannot be dismissed
here, and findings about evidence that could not be read can only be
dismissed for a set time.
2026-10-11 22:14:36 -04:00
Anso 20740804b1 fix(gitops): stop asking a detached Blueprint for a rollout authorization (#2114)
* fix(gitops): stop asking a detached Blueprint for a rollout authorization

A Blueprint detached from Git, then Applied, reported its placement as
`rollout_authorization_pending` and offered no way to resolve it. The
authorize route refuses with 409 `NOT_GIT_MANAGED` for any target mode
that is not `blueprint`, switching the policy to manual did not clear it,
and every authority action was hidden for a non-`blueprint` mode, so the
row read as waiting for a decision that no route, policy, or button
could supply. It persisted across ticks, and the only way out was a
compose edit followed by Apply, which changes the artifact set and
invalidates the stored evidence by accident.

Two facts met to produce it.

`derivePlacementAuthority` ran the Git-managed rollout authorization
chain for every target mode, although only `target_mode === 'blueprint'`
has that surface. Detach leaves `intent_revision_id`,
`rollout_candidate_id`, `accepted_generation_id`, `artifact_set_id` and
`source_acceptance_ref` in place, because those are history rather than
authority, so a detached application still resolved
`authorizationIngredients`. The first placement granted afterwards put a
resolvable `placement_approval_ref` back on the row and the chain ran
against an application that could never be authorized.

Second, `latest_preflight_evidence_json`, with its `preflight_fingerprint`,
had no writer that ever cleared it on a mode change.
`recordPreflightEvaluation` is its only writer and nothing in the apply,
invalidation, or retarget paths touched it, so the Git-era evidence
outlived the authorization it had been evaluated for.

Two changes address one concern: an Inline application has no rollout
authorization to wait for.

The chain is scoped to `target_mode === 'blueprint'`. A non-`blueprint`
mode returns `blueprint_bound` before `authorizationIngredients`, which
is what an Inline application with no ingredients already returns.
`direct` returns `unbound_direct` earlier still, and the legacy-combined,
source-acceptance and `placement_review_pending` branches above the guard
are untouched, so the only behavior that changes is the chain running
for an Inline row.

Demotion now also nulls `latest_preflight_evidence_json` and
`preflight_fingerprint` alongside the three pointers it already cleared,
extends its early-return condition to cover all five columns, and records
every clear in the `application_retargeted` history row. `inline_blueprint`
is a terminal mode: the only transition into it is this demotion, and
`loadConvertBinding` refuses anything that is not `direct`, so the row a
Detach produced is never converted back and binding a fresh Git source to
the same Blueprint tombstones it first. The evidence column is never
refreshed for that row again, so leaving it would be a permanent stale
claim about content the row will never place.

The clear does not cover rows detached before it existed.
`commitTargetBinding` short-circuits a row that already matches its
destination and returns before `onRetarget` runs, so replaying the
demotion never reaches the clear and no backfill sweeps what the earlier
code left behind. The derive guard is what covers that population, and
the new test that writes the evidence onto an already-demoted row pins
the guard rather than the clear as that protection.

* fix(gitops): close the preflight evidence race and pin the demotion guards

Follow-ups from the pre-merge audit, in the same concern as the parent
commit.

A preflight evaluation that completes after a Detach could write its
evidence onto the inline row. Neither caller can reach such a row
legitimately: the Direct apply path resolves its application through
`getLiveDirectApplication`, and the Git-managed authorization path is
refused for a mode that is not `blueprint` before it evaluates. Nothing
reads the column for an inline row either, so the write only contradicts
the mode the row now runs. `recordPreflightEvaluation` now skips an
`inline_blueprint` row. It is skipped rather than refused because an
exception there would turn a benign race into a failed authorize
request, and the mint the caller reaches next already reports the
refusal.

Two tests pin the coverage the audit found missing. The demotion clear
now has a case for the one shape where every no-op condition except the
evidence one reads clean, which is what an invalidated placement on a
manual-policy row produces. And a late evaluation can no longer write
the evidence back.

Two comments are corrected. The demotion comment named only the
Git-managed caller of `recordPreflightEvaluation`, which the Direct
apply path also calls. The derive comment read as choosing the guard
over the demotion clear, when the two are complementary: the clear
cannot fix the symptom for a row that still resolves its other Git-era
pointers, nor for a row detached before the clear existed.
2026-10-11 15:13:57 -04:00
Anso ca90588e4e fix(gitops): bound GitOps history with a retention window (#2105)
* fix(gitops): bound GitOps history with a retention window

`gitops_history` is insert-only and the reconcile tick appends to it
continuously, so it grew for the life of an installation. A new
`gitops_history_retention_days` setting (default 30, range 1 to 365)
seeds with the other GitOps settings and prunes history from the
monitor's cleanup pass. Drained outbox rows are pruned with the history
they announced; an undrained row gets a grace period past the window
because the boot repair can still drain it from its payload, and a row
still undrained after the grace is counted, logged, and dropped so it
cannot accumulate for ever. The window is instance-local, like the
artifact retry interval beside it: the history being pruned is the one
this instance holds, and the monitor that prunes it runs here.

* fix(gitops): keep each live application's newest history row through the prune

`cleanupOldGitOpsHistory` deleted `gitops_history` purely by age, but
`latestTransitionByApplication` reads `MAX(created_at)` per application and
feeds the "last activity" fact the portfolio and application sheet render. An
application with no history inside the window, because polling is off, a source
is suspended, a node is detached, or a Blueprint is idle, lost every row and
its activity column silently went empty. The prune now exempts each live
application's newest row, so the claim the JSDoc, the PR body and the settings
helper text already make is true.

Scoped to live applications rather than to every application_id: a tombstone in
`gitops_applications` is permanent, so an unscoped exemption would retain one
history row per stack ever retired and leave exactly the growth this setting
exists to stop. A retired application's rows are now pruned in full.

Also folds in three follow-ups from the audit:

- The prune is chunked to GITOPS_PRUNE_CHUNK_ROWS rows per table per pass, so
  a first prune on a long-lived install clears its backlog over several janitor
  ticks instead of holding SQLite's single writer for the whole delete. The
  method reports historyRemaining and outboxRemaining so the debug log says
  whether a pass is still catching up.
- The outbox OR-delete is two statements, so the undrained branch uses
  idx_gitops_settled_outbox_undrained instead of scanning for the drained one.
- The retention tests assert exact counts and exact surviving row ids rather
  than toBeGreaterThanOrEqual over a shared database.

New tests pin each guard: a quiet application's newest row survives every
window, its older rows are pruned, a tombstoned application clears entirely,
an unsettled attempt is still recoverable after a prune both inside the window
and outside it, and a backlog clears in bounded passes with the remainder
reported.

Removing the newest-row exemption turns 10 of the 11 tests red; removing only
the liveness clause turns the tombstone test red.

Docs: the GitOps portfolio page describes the window and its setting, and now
states that the newest entry per application is kept, which is what the code
does.

* test(gitops): drop the unused db handles from the recovery assertions

* fix(gitops): keep a settled history row ahead of its started row

A reconcile attempt stamps both rows with the same time, and the prune broke that tie on a random id, so it could delete the settlement and leave startup recovery to reopen the attempt. Recency now follows insertion order, in the exemption and in each chunk.

Remaining counts run only while developer mode is on, drained outbox deletes use an age index, and the settings copy says the kept row is the newest one for each live application.

* fix(gitops): log the undrained rows a history prune actually deletes

A pass is capped, so the warning was reporting the whole backlog. The
history-window tests now fail if the permission, the page save, the monitor
window, or the getter fallback stops matching the code.

* fix(gitops): prune a superseded unsettled attempt past the window

An unsettled reservation survives past the retention window only while it is that live application's newest row. Creating applications keep that row; detached applications do not.

* fix(gitops): skip the history prune when the retention setting cannot be read

A failed settings read used to fall back to 30 days and delete rows the operator had chosen to keep. The getter now throws, and that pass deletes nothing. A missing or unusable stored value still uses 30.

* fix(gitops): restore the history window when the retention spec fails

The retention spec restored the seeded 30 days as its last statement, not
in a finally, so a failed assertion left the shared instance on 14. The
CI retry then read 14 and failed the fresh-install assertion, turning one
failure into a persistent red.

Everything after the first save now runs inside a try whose finally puts
the window back, so the spec leaves the instance as it found it whether it
passes or fails.

* docs(settings): list the GitOps history window in the retention reference

The Data Retention table listed every other retention window and not the
GitOps one, so an operator checking retention windows did not find it. The
portfolio page described the setting in a paragraph, which is not where
retention windows are looked up.

Adds the row with its default and range, and a line under the table saying
the window is set per Sencho instance and where the control lives.

* fix(gitops): report an unmeasured prune remainder as unknown

The prune reported a remainder of 0 whenever developer mode was off, so
the diagnostic log read "none left" for a table it had never counted. It
now reports null, and the log line says the remainder was not measured.

Three smaller corrections from the same review:

- The diagnostics allowlist omitted the history window, so a support
  bundle could not explain a timeline that had already been pruned.
- The GitOps registry entry described every control as belonging to this
  node, while the retry interval and the history window are instance
  local. The sentence now names no scope it does not have.

* fix(gitops): correct what the history window is documented to govern

The copy said the window governs notifications. It does not: the prune
deletes history rows and their outbox rows, and the bell entries those
outbox rows announce age out under the Notification log window instead. An
operator setting 7 days would have expected their bell to follow.

It also promised to keep the database small, which nothing delivers. The
prune stops old rows from being written; it never reclaims the file.

- The helper now names the record behind the GitOps timeline, points bell
  entries at the Notification log, and says the window stops history from
  growing.
- The settings reference gains the GitOps section it never had, with the
  window under it rather than under a per-node table that ends in a Save
  button the control does not use.
- The portfolio page stops claiming a shorter window never changes what an
  application is doing: a webhook redelivery reads this same record, which
  the same paragraph already said.
- A fraction below one day now clamps to a day instead of flooring to zero,
  which as a cutoff is now and would delete everything not exempt. The only
  caller passes an integer, so this is a guard, not a reachable fix.

* fix(settings): snap a typed number chip to its own step on commit

The chip clamped to min and max but never rounded, so typing 1.5 wrote
"1.5" and every integer setting rejected it at the route with a bare
"Validation failed" toast. Nothing was saved, so no data was at risk, but
the control accepted input it could not store.

Rounding to the control's step fixes that and leaves a genuinely
fractional chip alone: the Docker janitor asks for 0.5 GiB and keeps it.
Clamping still runs after rounding, so a fraction below the floor cannot
round down under it.

The settings reference also listed GitOps last under Infrastructure
while the sidebar renders it third.

* docs(gitops): replace the unsourced window figure with what the code does

The helper and both docs said a window under about 3 days can let a Git
host retry a webhook. Nothing in the code picks three days, and the
dedupe the sentence describes only applies to the direct webhook path.

The text now states the condition instead of a number: a redelivery can
run the work again once the record of its first delivery is pruned. A
provider endpoint keeps its own delivery record, pruned to a fixed depth,
so the window does not affect it, and the sentence says so.

* docs(settings): say where a remote instance's window is set

The reference explained that the window belongs to an instance rather
than a node, but not how to change the window of a remote one, which is
on that instance.
2026-10-11 14:02:03 -04:00
Anso 22df038b5d fix(filesystem): require lstat to report a link before refusing a path (#2115)
The walk that confirms a path stays inside the compose root probed realpath
and then lstat. Any successful lstat was read as proof of a dangling symlink,
so a plain file that a concurrent writer created in the gap between the two
syscalls made an in-base path look like a symlink escape.

Two racing creates of one stack env file hit this. The loser was refused with
409 "env file path is not usable" instead of the 412 conflict carrying the
current content, which is what the editor needs in order to show the operator
what the file now holds. Read lstat for whether it is a link before refusing,
and let a plain file walk up to the nearest real ancestor as before.

Containment is unaffected. A symlink that resolves outside the base still
fails the walk, a genuine dangling symlink still lstat's as a link and is
still refused, and walking up still confirms the ancestor is under the base.

Add coverage for both directions across that probe gap: a plain file created
in it is read as inside the base, and a real dangling symlink is still
refused.
2026-10-11 00:47:43 -04:00
Anso f208a1f9da fix(gitops): announce one pull result per attempt, and only when it changes (#2104)
* fix(gitops): announce one pull result per attempt, and only when it changes

Two writers announced one source attempt: the fetch path recorded its
result immediately, and the settled outbox announced the same outcome
after the transition committed. A pull therefore produced two bell
entries, and a candidate awaiting review re-notified on every poll.

Both writers now carry one dedupe key per attempt and event family, so
the second write is a no-op. A ready event that names a staged candidate
is keyed to the candidate instead, so the poll that staged it announces
it and a later poll for the same commit stays silent. Settled outcomes
that carry no news (no source change, candidate already fetched,
suspended, superseded, retry scheduled, unknown) write no outbox row, and
the boot repair applies the same rule to rows older builds left behind.
The category and level each outcome renders are unchanged.

* fix(gitops): announce one pull result per attempt, and only when it changes

Two defects on the GitOps notification path.

Every attempt wrote two bell entries for one event. The fetch path announces its
result when it stages or fails, and the settled outbox announces the same
attempt's normalized outcome after the transitions commit. Both now carry one
dedupe key per attempt, so the second write is a no-op. The key is per attempt
rather than per attempt and outcome family: the two writers classify one event
independently and disagree by construction (a rolled-back apply records
`blocked` while the `applyFailed` settle that confirms it carries
`failed_previous_intact`), so a family-scoped key would let both through.

A staged candidate re-notified on every poll, because each poll reserved a
fresh attempt id. A ready event naming a staged candidate is keyed to the
candidate generation instead, so the poll that staged it announces it, later
polls of that same candidate stay silent, and a candidate staged afresh
announces again. Keying on the commit rather than the generation would keep a
commit that is legitimately staged again later silent for the whole
notification retention window.

A pull whose staged project does not validate now writes no live entry at all:
it staged no candidate, so its failed settle is the single entry. Announcing
"ready" there both doubled the entries for the attempt and consumed the
candidate key a real candidate for that commit needs once the operator fixes
the cause and pulls the same commit again.

Settled outcomes that carry no news (no source change, candidate already
fetched, suspended, superseded, retry scheduled, and the pre-existing unknown)
write no outbox row, and boot repair applies the same rule to rows older builds
left behind. Category and level per notified outcome are unchanged. The
candidate-generation key is used only for a ready outcome, so a blocked or
failed attempt stays on the attempt key even though it can also carry a
candidate generation.

The settled payload gains the candidate generation, decoded tolerantly so rows
written before the field existed still drain.

New coverage runs real pulls and applies and counts notification rows per
attempt rather than by recomputing a key: a ready pull, an invalid-validation
pull, a re-pull after the cause is fixed, a commit staged again after a
different commit, a rolled-back apply, a converted Blueprint source staging a
candidate, and a pre-upgrade outbox row that predates the candidate field.

* fix(gitops): announce the failure when a staged result cannot be recorded

A pull whose staging transition was rejected announced "ready" and then
suppressed its own failure entry. The rejected transition left no durably
staged candidate, but the pull carried on to the live write, which took the
attempt key; the abandon that closed the operation stamped the attempt failed,
so the settle that carried `failed_previous_intact` found the key already taken
and wrote nothing. The only bell entry said ready for an attempt that failed.

The live write is now skipped whenever the attempt has no staged result to
announce, which is the case both when the staged project does not validate and
when the staging transition was rejected. The settle is the single entry either
way.

The notification documentation now claims only what is delivered. It previously
promised that a suspended source or an armed retry writes no entry at all, but
the live failure write fires on every thrown fetch regardless of the settle, so
those attempts do report. It also promised that later polls of a waiting
candidate stay silent without saying what makes them silent.

The candidate key is enforced by a unique index against rows that still exist,
so clearing the bell or letting the retention pass prune the entry frees the
key and the next poll announces the candidate once more. That is pinned rather
than engineered around: the live write runs on every poll and relies on the key
to collapse it, which is what lets a failed write recover on a later poll.
Gating it to one write per staged generation would turn a swallowed write error
on an automatic source, whose settle carries no news of its own, into a
permanently lost notification.

* fix(gitops): key a blocked candidate to the candidate, and announce a hold

A plan-blocked candidate still notified on every poll. It settles `blocked`,
which is news, under a fresh attempt key each interval, so a local conflict
left standing filled the bell with the same warning. That is the per-poll
repeat this work set out to stop, and the earlier docs sentence claimed it away
as out of scope. A blocked candidate is now keyed to the candidate the same way
a reviewable one is, and announces once for as long as the conflict stands.

The candidate's identity gained its state, read from the live application row
at the moment the entry is written: `awaiting_review`, `blocked`, or `held`. The
first two are standing states that re-settle every poll. The third is a change,
not a state to sit in: a safety refusal on an automatic source turns a candidate
the system was going to accept into one needing review, and because that hold
settles `pending_review` exactly as the waiting candidate did, it was colliding
with the entry the candidate wrote when it was staged and the operator never
learned the candidate stopped being automatic. It now announces under its own
key, once.

The family and the candidate's state must agree before the key is the
candidate's, which keeps a blocked attempt on the attempt key: a dispatch
refused while a reviewable candidate is live settles `blocked`, and that refusal
is about the run, not the candidate.

The row-counting test helper matched only rows carrying an attempt id, so a live
write that lost its attempt metadata was invisible to every assertion. It now
matches by stack name as well, and a second helper asserts no attempt-owned row
for the stack is unkeyed. New cases cover a thrown fetch, a pre-mutation apply
failure, and a plan that blocks between the pull and the apply; the unattended
invocation-drift refusal asserts the same. Each of those sites was checked by
dropping its attempt metadata and confirming a test fails.

The rejected-staging corner the audit raised is already covered: the staging
transition runs inside one transaction and `markSettled` runs inside the same
call, so a rejected transition leaves the fetch open, the abandon records the
failure, and the settle announces it. The existing test proves the end state.
2026-10-10 13:53:13 -04:00
Anso 0ce9754766 fix(gitops): make automatic placement approvals reach execution (#2103)
* fix(gitops): make automatic placement approvals reach execution

Two defects on the bounded automatic placement path.

A removal was never auto-approved: the decision read
`gitops_targets.connectivity`, a column seeded to null that no producer ever
wrote, so a withdrawal resolved to `unknown_connectivity` even though the
target projection already derived reachability from the recorded observation.
The decision now reads the projection's own `targetConnectivity`, requires the
node registry to have the node online (the same gate an addition passes), and
carries an observation that cannot be decoded as a limitation, so the refusal
names malformed evidence instead of sending the operator to check a node that
answered.

An Inline Blueprint's policy placement approval was recorded and then never
read: an operational edit clears the combined approval, and the reconciler
returned at the legacy gate before consulting the policy's approval. The
reconciler now executes a plan a current policy placement approval covers,
under seven conditions: the application is inline, the placement policy is
still the one that could have written the approval, the approval is a policy's,
the rollout authorization policy is automatic, at least one node is already
placed at the current revision, every retained node runs the current revision,
and the approval covers every action in the plan. An uncovered plan waits for
the operator rather than partly executing, a first placement always waits for
Apply, and a retained node is observed but never repaired under this approval,
because the placement policy decided the node set and not the content.

A node-driven roster change no longer hides a policy approval behind a current
combined one: a label or cordon change moves the node set without moving the
combined approval's fingerprint, so the reconciler tries the policy approval
first and falls back to the combined approval when the policy cannot run the
whole plan. That fallback narrows the operator's blast to the placement's
frozen target set instead of invalidating it, so a removal a policy approved no
longer stops drift observation on the nodes that stay; the preview and the
lightweight list and detail evaluators read the same narrowing, so no surface
reports an approval the executor withholds or withholds one it will run. An
operator's approval is never discarded by a policy decision. Demoting an
application back to Inline takes the Git-era authority with it: the placement
pointer is cleared and the rollout authorization policy returns to operator
authorization, because neither was an operator's choice about Inline
placement. An Inline Blueprint can reach the automatic rollout authorization the
policy path requires from the control beside the placement policy.

* fix(gitops): say when automatic placement waits for Apply

A selector edit that moves one stateless node runs on the next tick, and Enforce holds a policy-approved plan until Apply. The docs described both as waiting, and the preview showed a pending plan with no reason.

* fix(gitops): keep a cleared pin from automatic placement

A pin clear left the new intent unpinned, so bounded auto could place the freed node without Confirm Apply. The decision now treats a clear as pin-driven when the standing approval was recorded on a pinned intent, or when a pin was written after an older approval and never confirmed.

* fix(gitops): keep a detached snapshot off automatic placement

Git-managed deploys stamp the Blueprint revision, and Git content never bumps it, so after Detach a retained node still looked current. Recording a new revision on Detach makes that stamp stale until Apply.

* fix(gitops): hold Enforce plans that drop a mid-deploy repair

A retained node that is correcting or deploying leaves the plan with no repair, so a policy approval could place an added node in Enforce. The mode itself now holds that plan until Apply, and the preview says so only when Enforce is the reason it is waiting.

* fix(gitops): keep a pin chain and a re-armed policy from placing on their own

Three authority gaps, all on the automatic placement path, found by the
pre-merge audit.

A pin that was set and cleared with no Apply between them read as no pin at
all once a third intent followed: the intent before the current one was the
clear, so the pin that moved the work was out of view and the freed node was
placed without Apply. With no decomposed placement approval to anchor on there
is no record of any confirmation, so the whole intent history now counts. A
Confirm Apply mints the approval the decision anchors on from, and the next
change is judged against that, so the wait ends at the next operator decision
rather than never.

Re-enabling a policy revived a decision the operator had taken back: execution
stopped the moment the policy went, but the approval the policy wrote stayed on
the row, so setting it back handed the next tick a decision made days earlier on
evidence nobody had read since. Arming either policy again now withdraws what
that policy decided, with the rollout authorization that only resolves through
it. An operator's approval is untouched, the approval rows stay as the record of
what was decided, the rollout generation stays so a fresh approval re-drives it,
and the history row records the pointers that went with the policy.

The refusals a withdrawal produces now read as what the backend checks: a node
that has not reported, or that is not a node this Sencho knows about, is a
different answer from a node that is not answering, and a withdrawal needs the
node to have reported the workload it was running.

* fix(gitops): clear the attention a policy placement left behind

A Blueprint the placement policy placed a node for sat at Needs attention
with a stale acknowledgement and a runtime drift, and neither Confirm Apply
nor an API apply cleared it: the retained nodes' plan is observation-only, so
nothing ever re-acknowledged them.

Two causes, both fixed. The Inline freeze is now keyed on the compose digest
rather than the intent revision, so a roster change no longer drops the
accepted generation and re-points every placed target at a new generation id
for the content it already runs. And the projection's identity test yields when
both intent revisions resolve with the same compose digest and the current
candidate still requires that node: a node the candidate dropped keeps its
signal, because a withdrawal the plan is asking for is not a superseded
acknowledgement.

Two roster changes inside one tick also left both additions unplaced with no
refusal recorded. The reconciler runs one approval and refuses a plan it does
not cover whole, so the second approval named only its own addition while the
plan still wanted the first. The blast now names every node the fleet does not
hold, carrying the earlier decision's unlanded placement forward, while the
decision still reads the approved set for its size and safety reasons and the
evidence is read for every node the blast names. Removals stay on the approved
set, because a withdrawal is judged from the observation of the workload that
ran there.

The Confirm rollout dialog no longer claims the fleet cannot change until it is
confirmed when a configured policy has already approved the plan.

* fix(gitops): write the placement blast in canonical order and name a withdrawn node again

Two findings from the QA run of this PR, both now pinned by failing-first tests.

The first: a roster move that adds a higher node id while withdrawing a lower one left the approval unwritable and the operator looking in the wrong place. The blast effect was assembled places-then-removals, while the decoder requires the node ids strictly increasing, so the write was refused as out of canonical order. The refusal reason then reported that refusal as `conflicting_operation`, so the operator was told an operation was in flight when none was, the plan read `reapproval_required`, and nothing executed. The effect is now sorted by node id, which is the order the decoder wants and carries no meaning of its own: the reconciler reads the effect as a map keyed by node id and the transition re-encodes it to canonical form anyway. Blast decode failures are also recorded as `malformed_evidence` rather than falling through to the conflict default, because a blast that did not decode is a data fault.

The roster window that reaches it: the reconciler executes one approval per tick, so an addition approved but not yet landed is carried into the next approval's blast, which then names a placed node alongside the withdrawal.

The second: a withdrawn node that the selector wants again produced no row in the plan at all. The tombstone holds deliberately, since only an explicit deploy re-opens a severed placement, but the node stayed online and labelled with nothing to say about why it was skipped, so the plan read as nothing to do. A new informational `skip_withdrawn` row now names what the operator has to do. It is excluded from the confirmable set and from both executor groups, so it authorizes nothing and cannot be confirmed into a mutate, and it yields to the status-precedence pass so an in-flight or guard row for the same node wins rather than leaving two explanations stacked on one node.

Follow-up, not addressed here: the replay-guard refusal (`placement approval already recorded for this intent and candidate`) still maps to `conflicting_operation`, which is the same class of untruthful reason on the crash-retry path. It predates this change and is left for its own pass.

Tests: a new case drives the add-then-withdraw window end to end and asserts the approval lands with `stateless_removal` and no refusal recorded; a second drives a severed target the selector wants again and asserts the informational row; a third pins that a severed node carrying a `withdrawing` row reports once, as the in-flight withdrawal.

Backend: `npx tsc --noEmit`, `npm run lint` (0 errors), `npm test` (11013 passed; the single file-level failure is `git-transport-ssh.integration.test.ts`, which requires loopback port 22 free and fails identically on an unmodified tree because this host runs sshd on 22, freed in CI by `scripts/ci-free-loopback-ssh-port.sh`).
Frontend: `npx tsc -b --noEmit`, `npm run lint` (0 errors).
2026-10-09 22:28:41 -04:00
Anso b8de90d13c fix(gitops): match webhook deliveries on canonical repository identity (#2102)
Provider webhook deliveries were matched to the owning source by raw host
and pathname equality, so a stored `...repo.git` clone URL never matched the
`html_url` a provider sends, and ssh-configured sources, trailing slashes,
host case, ssh ports and path case were dropped the same way. The delivery
was recorded `source_mismatch` and the source kept polling as if no webhook
existed, with nothing surfacing the mismatch.

Compare canonical repository keys instead, folding path case before the key
is built so a differently-cased `.git` suffix normalizes too. The fold is
local to this signature-verified check: an endpoint binds exactly one source,
so it reads a signed statement rather than looking a repository up, and the
pull that follows uses the configured URL, never the delivered one. Folding
in the Blueprint claim guard would add refusals, so the guard keeps its
case-sensitive path: a case-sensitive host can hold two repositories whose
paths differ only in case.
2026-10-08 22:09:47 -04:00
Hambrook a6eaea0f54 feat(editor): create the stack .env from the editor on first save (#2097)
* feat(composeEditor): create .env file on demand when saved instead of relying on doing so via Files

* fix(editor): scope .env creation to the editor and adopt the resolved path

- PUT /:stackName/env creates the default .env only when the request carries
  create=1 and the stack exists; every other caller keeps the handled 404, so a
  Fleet Secrets push to a stack with no env file never creates one.
- The create response returns the resolved path; the editor adopts it, so the
  next save targets the file directly instead of failing the allowed-file check.
- Deep links to /env open the env tab on a stack with no env file.
- Mobile always shows the compose/.env toggle and the creation notice.
- Backend and frontend tests pin the create path, the canonical second save,
  the no-flag 404, the deep-link behavior, and the notice; docs, the guardrails
  tutorial, and the mobile screenshot are updated.

* fix(editor): make the first env save exclusive and pin the confirmed overwrite

Audit follow-up on the create-on-first-save path:

- PUT /:stackName/env?create=1 now creates exclusively with open(..., 'wx').
  An existing target answers the same 412 conflict the optimistic-concurrency
  path uses, with its current content and path, so a stale editor that still
  believes no env file exists can never overwrite one it has not read.
- The editor adopts the conflict path, and its confirmed overwrite retries with
  create=1&force=1&file=<path>, pinned to the file the dialog named and
  re-validated against the allowlist.
- With project env files configured, the create targets the first configured
  file (the one Compose reads), derived from the shared env resolver instead of
  a second copy of the fallback; a configured path that resolves outside the
  stack directory is refused with 409 rather than written.
- The conflict read repeats the containment check and refuses to follow a link,
  so the conflict payload cannot be used to read outside the compose root.
- A directory target maps to 409 instead of an opaque 500.
- Tests cover the conflict instead of an overwrite, the forced retry, the
  pinned retry, the configured target with a stray .env untouched, the escaping
  config refused, force ignored on the non-create path, and the frontend
  conflict, decline, and fallback flows.
- Docs restore the compose row's "Always available." note, describe the
  creation target and the no-overwrite guarantee, and make the notices generic.

* fix(editor): guard the env create against older nodes and unknown state

Audit follow-up on the create-on-first-save path:

- The non-forced create request now carries If-Match: W/"0". This build's
  exclusive create ignores it; a node that predates create-on-save treats it as
  a normal conditional write, so an existing env file answers 412 with its
  content instead of being overwritten, and a missing file keeps the handled
  404.
- A failed stack load clears the previous stack's env state instead of leaving
  a stale "no env file" assertion that the next save could act on.
- A failed env inventory or content read is tracked as unknown, shows a
  failure notice, locks the buffer, and refuses the save; it is no longer
  presented as an empty file.
- Declining the create conflict on a node whose 412 names no path keeps the
  conflicted content and refreshes the inventory under the current load's
  signal; a failed refresh reports the failure instead of a false reload.
- Save & Deploy and Save & Pull on an untouched empty env tab no longer create
  an empty file; the deploy proceeds.
- The exclusive create opens with O_NOFOLLOW so a planted symlink cannot be
  followed, and a failed write's leftover is removed only when the path still
  names that same file (inode and size match).
- Tests cover the older-node precondition, concurrent creates, a directory
  target, escaping and dangling symlinks, the write-failure cleanups, the
  unknown-state refusal, the conflict-retry copy, and the deploy skip.

* fix(editor): leave create mode on older nodes and return 409 for path conflicts

Audit follow-up:

- A create-mode 200 without envPath is the normal response of a node that
  predates the envPath field. The editor now refreshes the env inventory in
  that case, so it leaves create mode and later saves are normal conditional
  writes with a fresh ETag instead of prompting for an overwrite every time.
- The recovery refresh uses the load signal captured before the PUT, so a
  stack switch during the request aborts it and the new load's state is not
  repainted.
- PUT /:stackName/env maps INVALID_PATH and SYMLINK_ESCAPE from the create
  path to 409, so a symlink at the target is a recoverable conflict instead of
  an opaque 500.
- Docs: deep-links no longer says the created file is always the default .env.
- Tests: the older-node create, confirm and overwrite sequence asserts the
  editor leaves create mode and the next save is a conditional write; a route
  test covers an in-root symlinked .env returning 409 without leaking content.
- Comment cleanups from the audit.

* fix(editor): do not paint a create response that lands after a stack switch

Audit follow-up:

- saveFile checks the load signal captured before the PUT (opLoadSignal) in
  the 412 and success paths: a response landing after the editor switched
  stacks now returns without adopting the old stack's envPath, ETag, or
  buffer state and without prompting, so the new stack's editor state stays
  intact.
- Tests: two hook tests switch stacks mid-PUT, for both the success and the
  conflict response, and assert no adoption and no prompt.
- Docs: node-compatibility no longer claims every older node detects the
  conflict (nodes before 0.87 cannot) and notes that the confirmed-overwrite
  prompt does not repeat.
- Comment: createTarget wording matches the resolver's containment filter.

* fix(editor): create missing parent directories for a configured env file

Audit follow-up:

- A configured project env file can point into a subdirectory that does not
  exist yet; the exclusive open failed with ENOENT and the route answered an
  opaque 500. The create now makes the missing parents inside the checked,
  stack-bounded path and retries the exclusive open once, so the first save
  creates the file Compose reads at its configured path.
- The retry re-runs the containment check and still loses to EEXIST if another
  actor created the file meanwhile.
- Tests: a service test for a nested missing-parent target and a route test
  that configures config/prod.env with no config directory, creates it, and
  saves again through the returned path.

* fix(editor): report a vanished stack on env create instead of resurrecting it

Audit follow-up:

- Drop openExclusiveCreatingParents and its recursive mkdir. The state it
  served (a configured project env file in a missing subdirectory) cannot be
  produced by the API: PUT /project-env-files rejects names with "/" or "\"
  and requires the file to exist, so no product path reaches that branch.
  The only reachable ENOENT is a stack directory deleted between
  requireStackExists and the exclusive create; recreating it left the deleted
  stack visible only as a hidden folder holding the env content and blocked
  the stack name.
- Restore the direct exclusive open and map ENOENT in the route catch to the
  handled 404 Stack not found, so the race is reported and nothing is written
  or recreated.
- Replace the two tests for the unreachable nested target with regression
  tests: the service refuses a target under a missing stack directory and
  creates nothing; the route answers 404 Stack not found when the directory
  vanishes before the create (real service ENOENT path) and does not recreate
  it.

* fix(editor): recover when the create conflict target is deleted mid-dialog

QA follow-up:

- The confirmed create retry pins the file the conflict named. When that file
  is deleted before the operator confirms, the route rejected the pinned path
  with a 400 because it was no longer a present env source, and the editor
  (which had already adopted the path) failed every later save the same way.
  The create branch now accepts the pinned path when it is the resolved create
  target, so the retry recreates the file; any other pinned path keeps the
  scoped rejection.
- Declining a create conflict now reloads the env state instead of trusting
  the 412 payload: if the file was deleted meanwhile, the editor returns to
  create mode instead of adopting a path that no longer exists.
- Save failures toast the server's error text (404 vanished stack, 409
  unusable path, 413 too large) instead of the raw JSON envelope.
- The save-refusal toast for an unreadable env state uses the same wording as
  the editor notice; the file list may have loaded while the content did not.
- A viewer with no env file sees "This stack has no environment file yet."
  instead of an unexplained empty read-only tab; editors keep the create
  promise.
- The route test for a vanished stack restores its spy in finally.

---------

Co-authored-by: Anso <dev@anso.codes>
2026-10-08 12:12:26 -04:00
Anso a77eb1b690 fix(exec): send Tty on exec start so the stream is not multiplex-framed (#2096)
* fix(exec): send Tty on exec start so the stream is not multiplex-framed

Docker decides the hijacked exec stream's framing from the start
request's Tty, not the exec-create Tty, so every chunk of the
interactive terminal carried an 8-byte multiplex header
(01 00 00 00 <len>) before its payload.

Pass Tty: true to dockerExec.start so the terminal receives raw shell
output. Covered by a unit assertion on the start options and a
Docker-backed integration test asserting no frame headers and clean
output.

* test(exec): assert evaluated shell output and close the socket in finally

The framing regression test waited for the marker text, which the PTY
echo of the typed command already contains, so the positive assertion
could pass before the command output arrived. Type a command whose
output differs from the echo, wait for the evaluated form, wrap the
session teardown in try/finally, and give the scratch container a
process-scoped name.

Verified: passes with the Tty: true start option and fails with Tty
forced false, where the frame header assertion fires.
2026-10-07 21:18:17 -04:00
dependabot[bot] 60a4e54b89 chore(deps): bump the all-npm-backend group in /backend with 12 updates (#2093)
Bumps the all-npm-backend group in /backend with 12 updates:

| Package | From | To |
| --- | --- | --- |
| [systeminformation](https://github.com/sebhildebrandt/systeminformation) | `5.33.13` | `5.33.15` |
| [tar-stream](https://github.com/mafintosh/tar-stream) | `3.2.1` | `3.2.2` |
| [@types/multer](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/multer) | `2.2.0` | `2.3.0` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `26.6.3` | `26.6.4` |
| [@types/ws](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/ws) | `8.18.1` | `8.18.2` |
| [eslint](https://github.com/eslint/eslint) | `10.11.0` | `10.12.0` |
| [supertest](https://github.com/ladjs/supertest) | `7.3.0` | `7.3.1` |
| [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) | `8.70.1` | `8.71.0` |
| [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) | `8.3.1` | `8.3.2` |
| [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) | `5.0.2` | `5.0.3` |
| [@aws-sdk/client-ecr](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-ecr) | `3.1141.0` | `3.1146.0` |
| [@aws-sdk/client-s3](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-s3) | `3.1141.0` | `3.1146.0` |


Updates `systeminformation` from 5.33.13 to 5.33.15
- [Release notes](https://github.com/sebhildebrandt/systeminformation/releases)
- [Changelog](https://github.com/sebhildebrandt/systeminformation/blob/master/CHANGELOG.md)
- [Commits](https://github.com/sebhildebrandt/systeminformation/compare/v5.33.13...v5.33.15)

Updates `tar-stream` from 3.2.1 to 3.2.2
- [Commits](https://github.com/mafintosh/tar-stream/compare/v3.2.1...v3.2.2)

Updates `@types/multer` from 2.2.0 to 2.3.0
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/multer)

Updates `@types/node` from 26.6.3 to 26.6.4
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `@types/ws` from 8.18.1 to 8.18.2
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/ws)

Updates `eslint` from 10.11.0 to 10.12.0
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/compare/v10.11.0...v10.12.0)

Updates `supertest` from 7.3.0 to 7.3.1
- [Release notes](https://github.com/ladjs/supertest/releases)
- [Commits](https://github.com/ladjs/supertest/compare/v7.3.0...v7.3.1)

Updates `typescript-eslint` from 8.70.1 to 8.71.0
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.71.0/packages/typescript-eslint)

Updates `vite` from 8.3.1 to 8.3.2
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v8.3.2/packages/vite)

Updates `vitest` from 5.0.2 to 5.0.3
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v5.0.3/packages/vitest)

Updates `@aws-sdk/client-ecr` from 3.1141.0 to 3.1146.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-ecr/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1146.0/clients/client-ecr)

Updates `@aws-sdk/client-s3` from 3.1141.0 to 3.1146.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-s3/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1146.0/clients/client-s3)

---
updated-dependencies:
- dependency-name: systeminformation
  dependency-version: 5.33.15
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: all-npm-backend
- dependency-name: tar-stream
  dependency-version: 3.2.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: all-npm-backend
- dependency-name: "@types/multer"
  dependency-version: 2.3.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: all-npm-backend
- dependency-name: "@types/node"
  dependency-version: 26.6.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: all-npm-backend
- dependency-name: "@types/ws"
  dependency-version: 8.18.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: all-npm-backend
- dependency-name: eslint
  dependency-version: 10.12.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: all-npm-backend
- dependency-name: supertest
  dependency-version: 7.3.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: all-npm-backend
- dependency-name: typescript-eslint
  dependency-version: 8.71.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: all-npm-backend
- dependency-name: vite
  dependency-version: 8.3.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: all-npm-backend
- dependency-name: vitest
  dependency-version: 5.0.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: all-npm-backend
- dependency-name: "@aws-sdk/client-ecr"
  dependency-version: 3.1146.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: all-npm-backend
- dependency-name: "@aws-sdk/client-s3"
  dependency-version: 3.1146.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: all-npm-backend
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-07 21:16:16 -04:00
Anso cee2ec83a6 fix(gitops): capture Pilot recovery and stop refused rollbacks from parking the application (#2087)
* fix(blueprints): capture recovery on Pilot apply-local hops

The leaf's apply-local route honored allowGitManagedContent and
captureRecovery only under the node_proxy machine scope, but a request
forwarded through the Pilot tunnel arrives with the agent's pilot_tunnel
loopback token. The auth middleware treats the two scopes as the same
hub-to-leaf credential and every sibling route already accepts both, so
the gate silently dropped the capture and a later rollout rollback on a
Pilot target failed with no recovery point to restore.

Accept either machine scope for both flags. Browser sessions and API
tokens hold neither and are still refused.

* fix(gitops): keep a refused rollback from parking the application

A rollback a node refused before its restore touched anything was
recorded application-wide as a failed recovery. The source facet then
read recovery_failed, offered no action, withheld deploys, and stopped
polling, while the only exit, a successful restore, was impossible on the
refused target. Recovering it took manual row edits.

Classify each failed restore by whether it can have mutated the target.
The restoring node's own pre/post verdict, attached to the compensation
error and carried over the rollback response, wins; otherwise only codes
that prove the restore moved nothing are pre-mutation. A pre-mutation
refusal is recorded on the target and settles the application restoring
stamp in the same transaction, keeping a same-ref completion receipt when
another target came back. A 2xx that does not echo the requested
generation is its own unconfirmed code and still holds, because the node
did restore something unproven.

A later successful deploy acknowledgement now retires a stale recovery
failure on the acknowledging target and, when no target still reports
one, on the application.

* docs(blueprints): describe how a refused rollout rollback resolves

A target refused before its restore changes anything keeps its visible
failure, but it no longer holds the application. The source keeps
converging and the next successful deploy to that target retires the
stale failure; only a rollback that may have changed a target holds
automated work.

* fix(gitops): keep rollback holds conservative across retries and refusals

A later pre-mutation refusal could erase the application hold an earlier
target still needed when that target may have been half-restored: the
settle cleared the stamp without checking the other targets, and a retry
refused during an unresolved failure overwrote the target's moved class.
The settle now restores the hold from a surviving mutated failure, keeps
the stamp while any restore is still moving, excludes the refusing
targets themselves, and only a pre-mutation refusal defers to an earlier
failure's class. A refusal whose own record write throws is settled by
the route for every missed target, not just the first.

Also carried through the same path: the node's own pre/post
classification rides the compensation result and every uncoded refusal (a
restore-not-sent outcome for a request that never left the hub, a coded
stack-not-found, the self-stack refusal), stack-busy answers hold instead
of settling because another operation is moving that stack, a failed
mid-write restore whose own revert failed is classified post-mutation, and
Enforce runtime repair follows the same pre-mutation release rule.

* fix(gitops): derive rollback holds from targets and separate recovery claims

The settle no longer keys on one operation's recovery ref: it derives the
application phase from the live targets, so two overlapping rollbacks can
no longer pin the application in restoring, and the other operation's
refusal settles it. The open writes the target's active-operation marker,
so a crashed open is selected by boot and reclassified instead of staying
restoring, and a missed refusal record is retried for its target rather
than only settling the application. The health executor settles a proven
refusal whose record throws, the same as the manual route.

A recovery hold is now claimed by one shared predicate,
targetRecoveryFailureMoved: a failure stage that has moved on no longer
claims a recovery, and a pre-mutation refusal never did. The settle, the
acknowledgement clearing, and the repair binding use it, and the failure
writers clear a superseded recovery phase, so a deploy failure on a
refused target cannot re-park the application or re-hold repair.

* fix(gitops): retire replaced recovery claims and keep repair behind a retry

A deploy failure that replaced a moved recovery failure cleared the
target's claim but left the application hold with no claimant, and the
acknowledgement's clearing branch then never ran. Claims are now retired
through one helper that re-derives the application hold in the same
transaction; a successful bind, an acknowledgement and a revived
placement retire the claim too, and every rollback terminal retires the
recovery_started interruption a crashed open left.

Repair checks a restore in flight before the failure claim, so a retry
over a refused target holds repair again. A terminal rollback whose
recovery ref no longer matches the target changes nothing, a newer
rollback open takes over the active marker, and a failed record is
retried with the class the attempt was proving for either class.

* fix(gitops): keep a moved recovery claim through later failures

A recovery claim that may have moved a target was retired whenever a
deploy or withdraw failure replaced it, releasing the application hold
with nothing proving the target consistent. The claim now keeps its own
class in a dedicated column, so a later failure records itself beside the
claim instead of overwriting it. Only success evidence (an
acknowledgement or a bind) retires a moved claim; a claim that moved
nothing is still retired by a later failure.

A restore in flight is never rewritten: retirement returns early on
capturing, restoring and compensating, and a deploy, withdrawal or
acknowledgement terminal that clears its own marker hands it back to the
restore so a crash remains boot-recoverable.

Superseded terminals are scoped by class: a refusal that moved nothing is
a true no-op with no row write, history row or application stamp, while a
failure that may have moved the target records its class without touching
the newer attempt's phase, ref or marker. rollbackCompleted carries the
same ref guard, and the settle re-reads each row after its retry and drops
one a newer rollback re-opened.

The application hold counts a restoring sibling as a claimant, and a
tombstoned target stops answering for the hold it claimed.

* fix(gitops): serialize rollbacks per target

A second rollback could open over a target whose restore was still
running, take over its recovery slot and ref, and leave the application
hold released while the first restore was still moving files. One restore
per target is now the rule: rollbackInProgress and recoveryStarted refuse
a target that is already restoring, the route answers with 409
ROLLBACK_IN_PROGRESS before any restore starts, and the health executor
skips its rollback on a target that is already restoring.

With overlap impossible, the machinery that arbitrated it is gone: the
ref and marker takeover, the skipped-terminal plumbing and the ref
guards. A refusal that defers to a moved claim leaves the shared failure
slot alone, so a later deploy failure is not relabelled a recovery
failure, and the claim keeps its own class and time in dedicated columns.

rollbackCompleted, recoverySucceeded and a moved rollbackPartialFailed
derive the application stamp from the live targets instead of writing it
outright, so one target's outcome cannot release or overwrite a sibling's
hold. A restore marker is handed back on every terminal that clears it,
including through a tombstone, and both revival paths share one helper.

* test(gitops): restore the boot recovery selection test

The superseded-terminal tests removed with the rollback serialization
also took the boot test that drove the real startup selection, leaving
its import unused. Restore it against the current semantics: the open's
marker makes the application selectable, boot reclassifies the restore as
interrupted, and a later terminal rollback retires the interruption.

* fix(gitops): record a terminal when a health rollback hold throws

The health executor opened a rollback and then ran the hold and two
store reads outside the block that records a terminal. A throw there
left the target restoring, and with rollbacks now serialized per target
that state refused every later rollback in-process. Everything after the
open now lives inside the recording try, so every exit records a
terminal; a regression test forces the hold to throw and checks that a
later manual rollback is accepted.

Boot also selects applications whose own or any target's recovery phase
is in flight without an active-operation marker, and records the
interruption as recovery_started, so a row an older build left restoring
is reclassified instead of being refused for ever.

The docs now say every Git-managed rollout deploy captures the node's
previous project on every target type, and that a target can have one
rollback at a time. Tests removed with the rollback serialization were
restored where the behavior still exists.

* fix(gitops): stop stamping an application interruption for a recovery

Boot reclassification wrote recovery_started into the application's
interruption_stage for a marker-less in-flight restore. No recovery
terminal clears an application interruption stage, so after any crashed
rollback the value stayed for ever, and the drift collectors read a set
application stage as a standing suppression while a Direct source
deferred its retry. The application row records the interrupted recovery
in its recovery phase; the target keeps the operation stage. A regression
test reclassifies a crashed rollback and asserts the application stays
clearable through a later successful rollback.

The route's 409 test now asserts the live rollout authorization and
generation are untouched, so a regression that supersedes the rollout
before refusing fails the test. The repair test for a refused recovery
replaced by a deploy failure now drives the real writers, and the
rollback docs no longer claim the source keeps converging.

* fix(gitops): keep the poll cadence through a recovery hold

A held application settles every evaluation into recovery_required, which
armed neither a retry cursor nor a fresh poll. The fetch that ran while
the hold stood consumed the poll cursor, so once success evidence cleared
the hold the source sat idle: a pushed commit was never fetched and
next_poll_at stayed null until the poll interval was saved again. The
controller now re-arms the poll for a recovery wait, an in-flight restore
or a hold that success evidence retires. Automatic acceptance stays gated
on success-shaped outcomes, so the hold still withholds progression. A
regression test holds an application through the real rollback writers,
consumes the cursor through the real fetch transitions, and asserts the
cursor is re-armed.

The rollback section of the Blueprint docs again states that every
Git-managed rollout deploy captures the node's previous project on every
target type.
2026-10-07 20:05:41 -04:00
Anso a54ebcc7e2 feat(fleet): separate the Fleet tabs into two sections, with a layout setting (#2092)
* feat(fleet): group the Fleet tabs into Observe and Operate, with a layout setting

The Fleet tab strip was ten tabs in one row, wider than a 1280px window.

The strip now sets the tabs under two labelled groups on one strip: Observe
(Overview, Readiness, Map, Docker Labels) and Operate (Snapshots, Blueprints,
Routing, Federation, Actions, Secrets). It scrolls sideways when the window is
too narrow and keeps the tab you are on in view. Every tab still opens with one
click and every tab keeps its value, so deep links and URL sync are unchanged.

A new account preference, Settings, Appearance, Fleet, Fleet tab layout, picks
how the strip is laid out: Grouped (default), Flat (the previous single row), or
Compact (Overview, Readiness, Map, Blueprints and Actions, with the rest under a
More menu that names the active hidden tab). It is saved to the account and
synced like the other appearance preferences, defaults to Grouped for a document
that lacks it, and is cleared with the rest of the preference cache when another
account signs in.

* refactor(fleet): drop the Observe and Operate labels and the old flat strip

The group labels only lengthened the tab row, and the previous single-row
layout is no longer needed. The Fleet tab strip is now one row: Overview,
Readiness, Map and Docker Labels, a vertical separator, then Snapshots,
Blueprints, Routing, Federation, Actions and Secrets. It still scrolls sideways
when the window is too narrow and keeps the active tab in view.

The Fleet tab layout setting now offers Flat (the default, this row) and
Compact (five tabs with the rest under More). The retired grouped value reads
as Flat.

* test(fleet): raise the cross-tab storage event without the StorageEvent constructor

The code quality scan flagged the two-argument StorageEvent calls as
superfluous trailing arguments. The tests now build the event from a
plain Event carrying the key and the new value, which is all the hook
reads.
2026-10-07 09:14:25 -04:00
Anso d7e0ebc4e5 feat(fleet): draw the Map one node at a time as each answers (#2091)
* feat(fleet): draw the Map one node at a time as each answers

The Map tab asked for the whole fleet in one request, so every node waited on
the slowest one and a single unreachable node held the tab until its timeout.

GET /api/fleet/dependency-map now takes an optional nodeId (positive integer;
400 if malformed, 404 if unknown) and answers for that one node in the same
shape. The Map tab reads each node on its own, four at a time, and draws each
slice as soon as it arrives, with "n of N nodes" beside Refresh while the rest
are pending. A node that fails keeps its last slice and is named in the banner
with its reason, and Retry in the banner reloads only the failed nodes. A node
that never answers is given up on after 20 seconds so Refresh and Retry never
stay locked behind it, and an answer that is not a map is reported as a failure
instead of drawing nothing. When no node could be read the tab says so instead
of calling the fleet empty.

* test(fleet): compare the request origin, not a URL prefix, in the map filter tests

CodeQL flagged two startsWith checks on a base URL as incomplete URL
substring sanitization. The assertions now parse the request URL and
compare its whole origin.
2026-10-07 08:56:14 -04:00
Anso f38d4886d5 fix(fleet): report a gone Pilot as offline and bound the Fleet map reads (#2088)
A Pilot whose agent stopped kept reading "online" on Fleet forever with no
stats, because pilot_last_seen was written only when a tunnel connected. The
tunnel close now stamps it, and the overview treats a Pilot with no tunnel as
online only for a 30s reconnect grace window.

The dependency map and networking summary waited up to 15s per remote, so a
half-open Pilot tunnel (agent frozen, not yet reaped, up to about 90s) held the
Map tab for that long. Both now share an 8s per-remote budget, say "Timed out
after 8s" instead of the runtime's abort text, and tell a body-read timeout
apart from a malformed payload. A failed tunnel-down write no longer blocks the
tunnel-down event.

On the Fleet screen, an aborted overview request no longer clears the loading
state of the request that replaced it (which flashed "No nodes configured"),
the networking and GitOps requests have their own controllers so a poll does
not cancel them, and polling pauses while the tab is hidden. The Overview
skeleton waits for the busy delay, and the Map tab keeps its last result,
revalidates when reopened, shows a layout-matched skeleton, names each
unreachable node with its reason, and offers Retry.
2026-10-07 08:32:50 -04:00
Anso 612c25a670 feat(gitops): complete the Git-managed Blueprint rollout path (#2084)
* fix(gitops): materialize frozen rollout targets on dispatch

A Git-managed Blueprint rollout dispatched against its authorized frozen
target set failed on every node except the source stack's own node with
"Could not open deploy for node N: target not found". Only the Inline
producer created a target row on first contact, and the Git adapter sent
to nodes that had never received a Direct deploy.

Create an empty target row for each frozen node that has none before the
deploy queue runs, mirroring the Inline producer's deploy_start write. A
node that no longer exists is skipped and still refused by name. An
existing row is left untouched, so a tombstone, fence, or spent retry
budget survives row creation.

* feat(gitops): materialize and freeze the accepted generation for Git-managed rollouts

A converted source keeps polling, but the Direct pipeline is refused for a
claimed source, so nothing promoted a new generation into its applied
directory and nothing resolved its artifact set. The rollout dispatch reads
the applied materialization and authorization refuses an unresolved set,
so a second commit could never be authorized.

Promote the accepted generation's candidate into its applied directory on
the hub (no writes to the live source stack) and resolve the artifact set
from that materialization through the shared approved-content parser. The
reconciler's artifact retry now re-runs both halves for a Git-managed
Blueprint, so a transient failure heals. A generation that materializes
more than one compose file is refused rather than resolved from a subset,
and candidate and applied paths get the same shape validation the Direct
promotion uses.

* fix(gitops): rebind the rollout candidate to a newly accepted generation

After a source-only change, sourceAccepted cleared the rollout
authorization but kept the placement approval, while the candidate still
named the previous generation and artifact. authorizationIngredients
refuses a candidate whose binding disagrees with the application, so a
second commit could never be authorized.

Move the candidate's generation binding to the newly accepted generation
and clear its artifact binding in the same transaction. The artifact
binding is cleared rather than pointed at the acceptance's seed set
because the freeze advances the application's set to the resolved one;
authorization re-stamps both from that resolved set. Both acceptance
entry points share the rebind, and the store method checks ownership and
is idempotent.

* fix(gitops): hold an authorized rollout whose dispatch is refused

A refused dispatch after a successful authorization returned only a note
on a 200 and left the projection reading rollout_queued with nothing an
operator could act on. Hold the rollout through the same application pause
the health executor uses, so the Answer carries the reason and Resume is
the resolving verb.

The hold is a no-op when no authorization is live and never overwrites an
existing hold. It is placed by the authorize route, the automatic
acceptance path, the resume path when the retry is refused again, and the
startup reconstruction, with a fresh operation id each time so a repeated
hold still writes history and announces itself. A hold that cannot be
recorded is logged, never thrown into a dispatch that already answered.

* test(gitops): cover a second source commit end to end

Drive the audit's repro at the service level: a Git-managed Blueprint
rollout to two frozen nodes, a second source commit accepted and prepared
through the real materialize and freeze producers, re-authorization
through the configured policy, and a second dispatch that deploys the new
compose to both nodes. The test asserts the promoted materialization, the
resolved artifact set, the minted authorization, the rebound candidate,
the deployed compose bytes, and the per-node applied generation.

* docs: document the Git-managed source update path

Describe what a new commit does on a Git-managed Blueprint: acceptance
materializes the generation and attempts to resolve its artifact identity
(the reconciler retries an incomplete resolve), placement stays approved
across a content-only change, and authorization starts the sequential
rollout again, on its own under the automatic policy or held with the
reason when preflight or artifact evidence is not ready. The Resume
bullet records that a refused retry re-holds. The What's New blurb now
describes the working chain instead of a blocked state.

* fix(gitops): retry Git-managed preparation and classify dispatch holds

A newly accepted generation whose preparation failed (a registry outage,
or a crash between the acceptance commit and the materialize call) was
stranded: re-accepting is refused, and the per-target artifact retry only
looks at the generation a target already holds. The reconciler now runs
an application-driven retry for every live Git-managed application,
re-materializing and re-resolving the accepted generation and completing
the automatic handoff once it resolves. A compose shape the authored-text
parser cannot model is refused and remembered, and its reason is
persisted as an evidence limitation the projection can show.

Blocked dispatches now carry whether they are holdable. Lock contention,
an already-recorded pause, a per-target pause, a superseded generation,
and a suspended source are not: pausing on them stopped a healthy rollout
another dispatch was advancing, and the per-target case needed two
Resumes to undo one decision. The health rollout executor shares the
classification. A converged Git-managed Blueprint no longer projects the
Direct manifest cache as managed-project drift.

* fix(gitops): clear system holds on acceptance and record their origin

A system hold (a refused dispatch, a health fence) belonged to the rollout
it was holding, but acceptance never cleared it: the next generation
dispatched into "The rollout is paused." with a reason describing the
superseded one, and before this change the next commit dispatched on its
own. Holds now carry an origin. A system hold is cleared when a source
acceptance supersedes its rollout, for every target mode, with a
rollout_unpaused history row; an operator pause survives until the
operator resumes. Health-executor and fence holds record the system
origin too, so the same rule covers them.

The docs and the accept-route note now describe the retry, the automatic
handoff, and the unmodellable-compose refusal the way the code behaves.

* fix(gitops): close the re-audit findings on holds and the retry

The health executor now forwards the pause-already-on-record marker and the
adapter marks the paused refusals with it, so a per-target pause is not
escalated to an application hold on that path. Every blocked result
declares whether it is holdable, and the Direct pipeline's exact-shape
tests follow. The reconciler's preparation pass runs before the enabled
check for a disabled-only fleet, uses the same durable retry gate as the
per-target path, and is detached from the tick; the tests prove the
isolation, the permanent-failure gate, and the non-blocking tick. The
accept route's dispatch wait is bounded, the resolved-artifact gate lives
in the one handoff, and the unmodellable-compose reason now reaches the
operator in the caveat and the accept note.

* test(gitops): use the fixture in the non-blocking tick test

The test declared a fixture without reading it, which failed the lint gate
on CI. The fixture is now authorized and the test asserts that the
released durable refusal held the application, which also proves the
detached preparation pass reached the dispatch.

* fix(gitops): re-drive the automatic handoff and keep hold origins honest

The reconciler's content pass now has a handoff arm: a resolved generation
under the automatic policy with no live authorization (a slow accept, a
re-enabled Blueprint) or with unacked targets (a transiently refused
dispatch) is dispatched on the next pass. The warranted predicate keeps it
off a settled, progressing, withdrawn, or not-yet-placed rollout, and an
in-memory floor bounds a failure that records no evidence row to the
artifact retry interval.

The pause origin now comes from the refusal's kind: executor dispatch
refusals are system holds that a new commit clears, and only real health
decisions (a stop, pause, or rollback) carry the health origin. The accept
route gates its dispatch half on stack:deploy while the acceptance stays
stack:create, the inventory and the UI match, the unmodellable-compose copy
no longer prescribes a fix for the wrong refusal, and its cause is capped.

* fix(gitops): tighten the redrive guards and report both accept halves

The warranted predicate now leaves a disabled Blueprint, a target with an
operation in flight, a fenced target, and a stale open run under an observe
policy alone, so the reconciler does not race a progressing rollout or
re-drive a no-op. A refusal that did not hold the rollout is diagnostic
rather than a per-tick warning. The accept route reports the preparation
note and the withheld-deploy note together, the UI fails closed when the
rollout policy is unreadable, and the rollback-fence clear is scoped to
the live rollout generation. Per-application memory is pruned.

* fix(gitops): detach a superseded rollout on acceptance and bound the redrive

A source acceptance now always detaches the named rollout generation,
marking it superseded only when it was not already, so an earlier Supersede
or Rollback cannot leave the frozen strategy reading as moved on and disable
the automatic handoff for every later commit. The warranted predicate is
generation-aware and skips per-target pauses, and the handoff arm has an
in-memory floor at the artifact retry interval and a concurrency cap, so a
steady blocked preflight or a persistent refusal is not re-evaluated on
every tick. A rollback fence blocks the hold clear whatever generation wrote
it, matching the dispatch queue's behaviour.

The accept route drops the deploy split: the configured Automatic policy is
the authority for starting the rollout, as it is for the automatic source
acceptance, so the acceptance stays `stack:create`, the UI gate returns to
`stack:create`, and the inventory row says so.

* fix(gitops): guard the preflight-drift supersede and pin the review tests

The preflight-drift invalidation only records a supersede when the
generation is not already marked, so a repeat drift does not append a
duplicate history row. The accept-route test seeds the automatic policy it
names, and the paused-target test asserts the predicate's decision rather
than only that it ran.

* chore: re-trigger CI after the Actions runner delay

* fix(gitops): make the withdrawal check durable and refuse over an unfinished rollback

The automatic re-drive predicate no longer trusts the rollout generation
pointer for a withdrawal: it refuses while the latest rollout authorization
recorded for the accepted source is superseded, so a placement re-approval
cannot revive a rollout the operator withdrew. It also refuses to mint when
a live target still carries a rollback_pending fence, whichever generation
wrote it. The supersede recorder is idempotent, so repeat invalidations and
re-approvals stop appending duplicate history rows; the hold clear and the
predicate both ignore retired rows, which can never finish a rollback. The
accept route joins the preparation note and the dispatch refusal again.

* chore: refresh CI after the Actions outage

* fix(gitops): mark an operator withdrawal explicitly and share the rollback gate

A dedicated withdrawn_at on the rollout generation is written only by the
operator's Supersede and Rollback, so the automatic policy can tell a
deliberate withdrawal from a system supersede (a preflight drift, a
placement invalidation, a re-authorization) and still re-drives after
those. Legacy superseded rows backfill as withdrawn, the safe direction.
The unfinished-rollback refusal moved into the shared handoff, so the
accept route and the SourceController honour it too, with the reason
logged; the predicate no longer carries its own copy. The accept note
joins its parts as sentences.

* fix(gitops): withdraw the latest authorization and hold over an unfinished rollback

The operator's Supersede and Rollback now mark the latest rollout
authorization recorded for the accepted source as withdrawn, not only the
generation the pointer names, so a placement re-approval after a system
supersede cannot leave the automatic policy free to redeploy what was
rolled back. The unfinished-rollback refusal moved into the shared handoff
as a holdable blocked refusal: it writes the reason on the application and
the accept note shows it, instead of a silent skip. The rollback target
set falls back to the fenced target's own generation, so the rollback can
still be finished after an acceptance detached the pointer. The migration
backfill is flag-tracked and transactional, the marker lookup is ordered by
rowid, and the accept note joins its parts as sentences.

* test(gitops): pin the rollback route, the withdrawal migration and the tick tests

The rollback route gained its own tests: a placement pointer with an older
authorization is withdrawn, and a fenced target keeps a resolvable target
set after the pointer detached. The withdrawal backfill has a migration
test covering the four states (fresh boot, column without flag, later
system supersede, dropped column), and the latest-authorization lookup has
a skewed-clock ordering test. The re-drive tests now use the settled
tick-until helper instead of a single tick, so the handoff concurrency cap
cannot defer the application under test. The rollback hold reason, the
feature docs and the Resume note now say "finish it, then resume".

* chore(deps): bump proxy-addr to 2.0.8 and source-map-js to 1.2.2

New advisories (GHSA-jqcg-44mw-7w3h, critical, and GHSA-68fv-2mgg-jv7q,
high) require either a patched version or a VEX statement. Both patches are
inside the existing dependency ranges, so the lockfiles move without any
application change.

* test(gitops): pin every withdrawal-migration state, including the flag gate

The migration test now boots one database state per case: the column
dropped without the flag, the column present without the flag (an earlier
head, or a crash between the ALTER and the backfill), a fresh boot with no
rows, and a later system supersede left untouched on the next boot.
Reverting the flag logic to a column-presence check now fails three of the
four, which is what the flag exists for.

* fix(gitops): report the handoff outcome on accept and scope the docs claims

The accept route now returns a skipped handoff's reason, so the operator
can tell a started rollout from one waiting for authorization, paused, or
held, and the UI prefixes the acceptance to the warning toast. The Resume
reply under the Automatic policy no longer tells the operator to authorize
when the reconciler will start the rollout itself. The feature docs scope
the recovery-capture claim to Local and proxy nodes (Pilot capture is a
tracked follow-up) and state that a Git-managed Blueprint skips the
managed-project drift comparison by design.

* fix(gitops): stop the accept note repeating the artifact reason

When the preparation did not resolve the artifact identity, the accept
note carried the preparation note and the handoff's unresolved-artifact
skip, which say the same thing. The skip reason is now a shared constant
and is suppressed when the preparation note already covers it; every other
skip reason still reaches the operator.
2026-10-06 13:35:29 -04:00
Anso 661412a7cb fix(blueprints): announce a Suggest-mode drift once per episode (#2086)
* fix(blueprints): announce a Suggest-mode drift once per episode

A Blueprint in Suggest drift mode raised one identical
blueprint_drift_detected notification per reconciler tick for as long as the
drift lasted. A drifted row is re-checked every tick so a drift that resolves
is noticed, and every check re-observes the same drift, so the suggest branch
dispatched on every pass and filled the bell and any routed channel.

The check now passes whether it observed a new episode into the mode's
response. An episode starts when the row first reads a drift state and ends
when a check finds the workload converged, so a repeat is silent while a
drift that clears and returns announces again. A held target belongs to the
same episode: the hold is a decision about the repair, not a resolution of
the drift, and the episode keeps its original start time through it.

Enforce and Observe behavior is unchanged.

Verified: 10687 backend tests pass; the only failure is the SSH transport
integration test, which needs loopback port 22 and fails the same way on main
because this host runs an SSH daemon. Typecheck and lint are clean. The new
tests drive the real reconciler tick on both the Inline and Git-managed
paths: three ticks on a persistent drift produce one alert, a drift that
clears and returns produces two, and a held target produces none while
keeping its drift age.

* fix(blueprints): stamp the drift start when a hold began the episode

An episode that began as an Enforce hold carried no drift start: the hold
write records no last_drift_at, and the drift write that followed the hold
treated the row as already drifted, so it skipped the stamp. The row then
read drifted with a null start time, and the deployment table fell back to
last_deployed_at for "Last activity", showing a stale deploy time for a
current drift.

The drift write now stamps the episode start when the row has none and keeps
the existing start otherwise. Both the Inline and Git-managed observation
paths read the episode through one helper, so the episode definition and its
start time have a single home.

Verified: 108 tests pass in the touched files, including a new test that
drives a real Enforce tick from active into a hold, switches the Blueprint to
Suggest with a fresh approval, clears the hold, and asserts the drift start
is recorded with no new alert. Typecheck and lint are clean.
2026-10-05 19:53:41 -04:00
Anso fc96137788 fix(gitops): authenticate SOPS values with their key path (#2081)
* fix(gitops): authenticate SOPS values with their key path

SOPS authenticates every encrypted value with its key path as AES-GCM
additional authenticated data. The decoder never passed it, so values in a
real sops file failed authentication and the pull surfaced a generic error
while the poller retried.

Thread the key path through YAML/JSON decryption (colon-joined with a
trailing colon, array elements keep the parent path) and derive it for
dotenv and INI stores, where a [sops] section marks INI and values outside a
named section belong to DEFAULT. Authentication failures now classify as
decrypt_failed instead of being reported as a wrong identity.

The regression test decrypts files generated by sops 3.13.3 with a test-only
age key, so fixtures no longer share the decoder's assumptions.

* fix(gitops): keep dotenv newlines and nested sops keys in SOPS output

A dotenv value containing a newline was emitted across two lines, which
truncated the secret and turned the tail into a separate env_file line.
Escape newlines back to \n for dotenv output, matching sops.

A nested map key named sops was dropped because the metadata skip applied at
every depth. Strip sops from the root record only; nested keys are data.

Add real-sops fixtures and exact assertions for both, and pin the full INI
output instead of substring checks.

* fix(gitops): quote dotenv output Compose would otherwise rewrite

sops escapes newlines as \n, but Compose expands escapes only inside
double-quoted env_file values. Unquoted output delivered a literal \n, an
interpolated $, or a comment-truncated value to the container. Quote and
escape exactly the values Compose rewrites: newline, carriage return, tab,
backslash, double quote, $, #, a leading single quote, and edge whitespace.

Add a real-sops metacharacter fixture, an exact-output unit test, and a
docker-integration test that feeds the decrypted file to docker compose and
asserts the container receives the original bytes.

* fix(gitops): format decrypted dotenv for the consumer role

Compose-style quoting applies only to inputs Compose parses as env files.
A dotenv-format file consumed verbatim as a file-backed secret kept
different bytes than the plaintext: $ was escaped, # quoted, and edge
whitespace wrapped. Pass the consumer role through the overlay and default
the decoder to the sops-faithful output, so only env_file, include-env, and
interpolation-env inputs get Compose formatting.

Add role-split coverage at the overlay level and a fixture test pinning the
sops-faithful output.

* test(gitops): cover the secret role in the dotenv consumer split

The overlay role test covered env and config; add a secret input so a
broadened Compose-formatting condition fails the suite.

* fix(gitops): validate and deploy SOPS stacks from the decrypt overlay

Two pre-existing defects kept a SOPS-managed stack from ever validating or
deploying:

- The compose runner's working-directory guard allowed only the managed area
  and the temp root, so candidate validation rejected the decrypt overlay
  under $DATA_DIR/git-secrets with "Invalid working directory". Allow that
  root, shared with the overlay's own path resolution.

- A single-file stack carries no project flag in its live or captured
  prefix, because the Compose project name normally comes from the working
  directory basename. Running from the overlay made Compose create a stray
  project named after the operation id and leave the real stack untouched,
  while the API reported success. Pin -p <stack> whenever Compose runs from
  an override directory that is not the canonical stack directory.

Add regression tests for the guard and for live and captured prefixes, and
cover the no-double-pin case.

* fix(gitops): correct SOPS readiness, JSON output, and failure reporting

Three QA follow-ups in one pass:

- Multi-recipient readiness: a file encrypted to several age recipients is
  decryptable by any one of them. Readiness, LKG, recovery, rollback
  readiness, rotation impact, and the readiness route now evaluate per input
  instead of requiring every recipient on the node. Legacy capabilities
  without per-input recipients keep the flat-list check. require_encrypted
  now governs managed repository content only, so a missing unmanaged project
  .env no longer refuses every stack.

- JSON output: a JSON SOPS document is detected and re-emitted as JSON with
  sops's tab indentation instead of being converted to YAML.

- Decrypt failure reporting: prepareOverlay preserves SopsDecryptError, a
  shared asGitSourceFailure maps it to a new SOPS_DECRYPT_FAILED code for
  fetch, apply, and reconcile, the route returns 400 with the message, and
  backoff treats it as operator action instead of a transient retry.

* fix(gitops): enforce the encrypted-source policy on a synced .env

A plaintext repository .env with sync_env on was accepted under
require_encrypted because discovery records it as unmanaged and the policy
judged only managed inputs. The synthesized sync entry now carries its
encryption kind and is merged into the capability inputs, so the file Sencho
writes to the stack root is judged by the policy.

The SOPS project-name pin overrode a compose-declared name:, starting a
second project beside the running stack. The pin now stands down when the
compose layers declare a top-level name.

* fix(gitops): contain the compose name probe to the stack tree

The compose-name check read every -f layer, including generated overlay
files outside the stack tree, with no containment barrier and a
stat-then-read pair. Resolve the stack directory once, skip any layer that
resolves outside it with an inline barrier at the read sink, and read
directly with a content-length bound so the path-injection and
file-system-race findings are cleared. Generated overlay layers cannot
declare a project name, so behavior is unchanged.
2026-10-05 09:18:05 -04:00
Anso d3f6783457 fix(gitops): make native provider webhooks work on remote nodes and for GitLab (#2078)
* fix(gitops): forward provider webhooks with one content type

A provider hook forwarded to the node that owns the stack carried two
Content-Type headers: the forwarder seeded one and copied the incoming
lower-case one, and the HTTP client merged them into
"application/json, application/json". The owning node's JSON parser
skipped the unparseable type, so the raw body was empty and the
signature check rejected every remote delivery.

Seed a single lower-case content-type, skip the incoming one, and
forward every other header under its lower-case name. The integration
test drives the real parser and internal hook route on a loopback
owning node and verifies the signed body and the reconcile handoff.

* fix(gitops): read the GitLab event type from the payload

GitLab's X-Gitlab-Event header is a display label ("Push Hook",
"Merge Request Hook"), not an identifier, so comparing it to "push" or
"merge_request" classified every GitLab delivery as unsupported.

Prefer the payload's object_kind, with the label normalized as a
fallback when object_kind is absent. Tests use the real header values
and cover push, merge request, and the label fallback.

* test(gitops): cover provider webhook forwarding and GitLab labels

Add coverage for a charset content-type on the inbound request, a
Pilot-shaped owning node with no API token, a GitLab tag push outside
the configured ref, an unknown GitLab label, and a payload object_kind
that disagrees with the header label.

* fix(gitops): deduplicate GitLab deliveries by event UUID

GitLab sends X-Gitlab-Event-UUID on every delivery (14.8 and later)
alongside Idempotency-Key and webhook-id. Read it as a delivery id so
a redelivery of the same event is recognised as a duplicate instead of
queuing a second reconcile.
2026-10-04 13:02:06 -04:00
Anso fd95c8e19b fix(stacks): make remote create-from-git honor deploy keys and private CAs (#2076)
* fix(stacks): make remote create-from-git honor deploy keys and private CAs

The remote create-from-git flow discovers the repository on the target
before the create request is forwarded. That discovery payload carried
the repository URL, branch, compose paths, auth type, and token, but not
the deploy key, known_hosts entry, host-key fingerprint, or CA bundle,
and the target never mapped the missing fields into the fetch. SSH and
private-CA repositories therefore failed during discovery on a proxy or
Pilot node. Discovery failures were also reported as a generic 500,
hiding the classified repository, ref, auth, redirect, and TLS errors
the direct create route already returns.

Forward the four fields, build the same fetch auth the direct create
path uses, and map classified git failures to the same status, message,
and code, never 401. Raise the discovery request cap so a route-legal
deploy key plus CA bundle fits.

* fix(registry-delivery): relay classified git failures through the hub

The target classifies a git discovery failure and answers with the same
status and code the direct create route uses, but the hub kept only the
status and message and replaced every code with REGISTRY_DELIVERY_FAILED.
Webhook and scheduler consumers and durable failure records could not tell
rejected credentials from a missing ref, and a classifier timeout lost its
message to the generic text.

Carry a known git code from a 4xx or 504 discover response through the
result, re-derive the status from the code so a target can never answer 401
and sign the operator out, map an unclassified target 401 to an upstream
failure, and keep every other 5xx generic. A git code counts as a delivery
refusal only when its status is the one that code maps to, so an unrelated
git error is not relabeled. The runtime git code list and the
GitSourceErrorCode union now share one source.

* test(git): read the git error code list from its single source

The support-matrix guard parsed GitSourceErrorCode out of the type alias,
which no longer carries string-literal members now that the union derives
from GIT_SOURCE_ERROR_CODE_VALUES. Read the const array instead, unwrap
its as-const, parenthesized, and satisfies forms, and require a top-level
declaration so a nested local cannot satisfy the guard. The partition
assertions are unchanged.

* fix(git): keep the git error code set out of the service import cycle

gitSourceHttp built its closed code set at module load from a value
exported by GitSourceService. That module sits on an import cycle
completed by the registry delivery path, so under some load orders the
array was still undefined when the set was built. The set came out
empty, isGitSourceErrorCode rejected every code, and the classified
error relay silently fell back to the generic failure.

Move the code list and its derived type into a leaf module with no
imports and have both the service and the HTTP helper import from there.
The service re-exports both names, so existing importers are unchanged.
Add a module-order test that loads GitSourceService first and a route
that reaches it first, asserting the predicate still accepts a known
code; both cases fail when the helper reads the array through the
service again.

* fix(proxy): authorize remote create-from-git before the discovery fetch

Hop-1 discovery runs a full git fetch on the target with the caller's
credentials, before the target's own permission check. A viewer, deployer
or auditor could therefore trigger a fetch on a remote node, and could
probe an internal host's SSH port through the failure timing. The same
ordering already applied to token auth; forwarding deploy keys and
private CAs widened it.

Check the caller on the hub before the discover call, mirroring the
direct route: stack:create globally, then stack:deploy when the request
asks to deploy. The check runs before the body is inspected, so an
unauthorized caller is refused whatever the body shape. The target still
enforces both. The trailing-slash form now classifies like its sibling
gates so the same protection applies, and a loopback integration test
covers the viewer and authorized paths.
2026-10-04 12:59:44 -04:00
Anso 4b060ea216 fix(registries): key delivered Docker Hub credentials under the v1 auths key (#2075)
* fix(registries): key delivered Docker Hub credentials under the v1 auths key

Delivered credentials were keyed by the normalized image host, which folds
Docker Hub aliases to `index.docker.io`. The Docker CLI only matches Hub
credentials under `https://index.docker.io/v1/`, so remote pulls of private
Hub images failed with "pull access denied" after the hub reported a clean
delivery. GHCR and custom hosts match their bare hostname and were fine.

The delivery seam now keys its output as the Docker CLI reads `auths`, and
the single-host resolver uses the same mapping. Target-local credentials
still win over delivered ones.

* test(registries): cover Docker Hub alias refs at the delivery seam

The seam test used the explicit index.docker.io spelling only. Point its
Hub services at docker.io and registry-1.docker.io so discovery's alias
folding is exercised through the delivery path, keeping the same v1 key
assertions. The explicit spelling stays covered by the local-wins case.
2026-10-03 17:33:23 -04:00
Anso 6178bc6c4b fix(gitops): normalize Docker architecture names to OCI platform names (#2074)
* fix(gitops): normalize Docker architecture names to OCI platform names

Docker reports the daemon host's `uname -m` spelling in `docker info`
(`x86_64`, `aarch64`, `armv7l`), while registry index descriptors name
platforms with OCI names (`amd64`, `arm64`, `arm`). The digest resolver
compares the two by exact string, so GitOps artifact resolution failed on
every Docker host with "no linux/x86_64 variant", and a Direct Git source
apply with deploy stayed applied_not_deployed with "Private registry
readiness could not be proven".

Translate the architecture and OS at the read boundary, so the local and
remote platform reads return OCI names. Tests cover the docker-info
spellings, the Windows path, and the resolver's exact-match contract.

* fix(gitops): tolerate legacy platform labels and drop the ARM guess

An artifact set stored before the read translated docker-info architecture
spellings keeps platform labels like linux/x86_64. The identity comparator
and the evidence decoder now canonicalize both sides, so digest repair and
the stale-probe membership check still find the approved child, including
when the platform label comes from a leaf that has not been upgraded yet.

The armv6l/armv7l/armv8l rows are removed: OCI distinguishes the arm
variants and this code does not model the variant, so mapping them to `arm`
could qualify a child built for a different ABI. Those hosts fail closed
again. loongarch64 now maps to loong64.

* fix(gitops): match a containerd node's index-only observation

The containerd image store exposes only the image index digest locally
(image Id, RepoDigests and descriptor all carry it), so the platform
child digest is never visible on the node. The identity comparison
required the child and reported every multi-arch deploy as runtime
artifact drift even though the running image was the approved child.

An observation that names no child of the expectation now proves the
child through the frozen index digest and the platform the running
image declares. An observation that names a recorded child must still
name the approved one, and any digest that is neither the expected
index nor a recorded child keeps the comparison fail-closed.

The hub also translates the platform a leaf reports, so a leaf running
before the normalization no longer fails every resolve, and a stored
artifact row that cannot be decoded is logged instead of dropped
silently.
2026-10-03 16:30:43 -04:00
Anso ef2bd00576 fix(security): forward only validated client addresses to remote nodes (#2071)
The hub relayed the caller's X-Forwarded-For and X-Forwarded-Proto to a
remote node unchanged. A remote that lists the hub as a trusted proxy
therefore recorded an address the caller chose: a caller could send
X-Forwarded-For from an untrusted peer and that value appeared in the
remote's audit row.

Strip every x-forwarded-* header at the hop, then set x-forwarded-for to
req.ip and x-forwarded-proto to req.protocol, the values Express
resolved under the hub's own trusted-proxy policy. Remove x-real-ip and
forwarded, which nothing at this layer vouches for. Apply the same rule
to the WebSocket forwarder with resolveUpgradeClientIp and the new
resolveUpgradeProtocol, because Express middleware does not run on an
upgrade. The pilot tunnel keeps stripping these headers: the agent's
loopback listener cannot verify a relayed client address.
2026-10-03 10:36:26 -04:00
Anso 966cf7255b fix(security): make ignored forwarded headers loud (#2069)
* fix(security): make ignored forwarded headers loud

Behind a reverse proxy, forwarding headers are accepted only from peers
listed in SENCHO_TRUSTED_PROXY_CIDRS. When the list is unset or invalid,
that failure was silent: client addresses, secure-cookie detection, SSO
callback URLs, Pilot enrollment URLs, and rate-limit keys all fell back
to the proxy, and an invalid list (for example a bare IP) was ignored
without a word.

Log the effective policy at boot, naming every rejected entry and the
reason. Warn once per untrusted peer when a request carries forwarding
headers the policy is ignoring, with the exact CIDR to set. Document the
behavior in the reverse-proxy and troubleshooting pages. The fail-closed
semantics of the allowlist are unchanged.

The login limiter test now drives the real createApp pipeline instead of
setting trust proxy by hand, which is what hid this regression.

* fix(security): keep forwarding headers out of the pilot tunnel

The tunnel bridge framed the hub's incoming request headers as-is, so a
reverse proxy in front of the hub had its X-Forwarded-* headers relayed
to the agent. The agent replays them to its loopback listener, which saw
them from 127.0.0.1 and warned about an untrusted proxy, suggesting the
operator trust loopback. Strip forwarding headers when framing http_req
and ws_open so the tunnel carries no unverifiable client address; the
hub's own pipeline has already applied its trusted-proxy policy to the
original request. Forwarded (RFC 7239) is stripped defensively.

Also narrow the reverse-proxy docs to the limiters that actually share a
bucket (sign-in, SSO, and other unauthenticated requests; signed-in
requests stay per user or per token), assert the boot log wiring, and
word the runtime warning as "add" when the list is configured, or point
at the rejected entries when it is invalid.

* docs(security): qualify trusted-proxy attribution and warning scope

QA follow-ups on the previous commit. The reverse-proxy paragraph now
says the proxy transport is the one where a remote can name the
operator's address if it trusts the forwarding instance; Pilot agents
receive tunneled requests without forwarding headers, so their local
audit trail records the tunnel ingress and the hub's audit log names the
operator. The runtime warning is documented as HTTP-only, since
WebSocket upgrades bypass Express middleware. The warning itself now
says "a reverse proxy or another Sencho instance" because a proxy-mode
remote's peer is the hub.
2026-10-03 10:35:43 -04:00
Anso d2d2a5d347 fix(gitops): match Blueprint repo claims on repository identity (#2054)
* fix(gitops): match Blueprint repo claims on repository identity

The guard that keeps one live Blueprint-mode application per repository
compared configured_repo_url as an exact string. A repository has an unbounded
number of valid spellings, so the HTTPS URL and the SSH URL for the same clone
each bound their own Blueprint, with or without a .git suffix, with a trailing
slash, and with host casing either way.

Compare a canonical repository key instead: host lowercased, trailing slashes
dropped, and one trailing .git stripped from the last path segment, with the
path left case-sensitive because Git hosts treat it that way. The key is
derived through the same RepoIdentity shape already stored per application, so
the stored identity and a recomputed key agree by construction.

Fail-closed. A URL naming no repository cannot be compared against anything, so
it refuses rather than passes, which means one unidentifiable live claimant
blocks every later conversion until it is retired, detached, or tombstoned.
Ingress now refuses such a URL outright so the state cannot be created, and the
refusal names the Blueprint that holds the claim so the operator can act on it.

GitOpsTransitionError carries an optional code, because the binding service was
telling two refusals apart by testing a message for the word "repo".

* fix(gitops): drop the port from the repo claim key and align ingress

Two ways a Blueprint could still bind a repository another Blueprint already
claimed, both found reviewing the claim guard.

RepoIdentity folds an SSH port into the host it serializes, so a forge serving
HTTPS on 443 and SSH on 2222 gave the HTTPS and SSH spellings of one repository
two different keys, and both converted. The port is now not part of the key.
Two repositories on two ports of one host therefore collide, which costs a
refusal rather than a second claim, which is the only direction this key is
allowed to be wrong in.

Ingress judged the submitted URL while the guard judges the stored one, and
those can disagree. An scp-style URL carries `?` and `#` through verbatim, so
it keyed fine as submitted while the ssh:// form actually persisted could not be
parsed back at all, and a host holding a slash reparsed as a different
repository. Those rows were accepted on the way in and refused at conversion
for a URL the operator never sees. Ingress now requires the stored form to name
the same repository the submitted one did, so a source is never accepted and
then unclaimable.

The transition fixture now stores the derived ssh:// form, which is what every
production writer persists and what the guard reads, so it no longer asserts
against a row shape the code cannot create.
2026-10-02 17:18:43 -04:00
Anso ae22a222ab perf(gitops): bound the Blueprint detail reachability probe with a short-lived verdict window (#2052)
* perf(gitops): bound the Blueprint detail reachability probe with a short-lived verdict window

Opening a Blueprint application detail asks the application's remote target
nodes whether they answered, so the detail panel agrees with the portfolio row
instead of contradicting it. The panel is not polled on an interval, but it is
read once per mount and again for every published GitOps transition while it is
held open, the stack drift panel reads the same endpoint through a second hook,
and the list read does its own fleet fan-out. A node that does not answer cost
the full probe budget on each of those reads.

A "did not answer" verdict is now reused per node id for a window strictly
shorter than the probe budget, in the existing process cache. Only that
direction is reused, because serving it can withhold a settled claim but never
assert one, so a cached entry cannot report a node as reachable after it went
dark. The portfolio fan-out records and retires the verdict and never serves
it: it needs the rows themselves, and a cached verdict must not override a live
answer. Both writers retire the entry on an answer, including when the other
writer recorded dark while a leg was in flight, so the row and its own detail
panel cannot disagree about a node the hub has just reached.

The probe timeout, what a verdict means, the projection, and the response shape
are unchanged.

* docs(gitops): pin the reachability reuse window to the shared probe ceiling

The reuse window was pinned to this read model's own probe budget, which left
one drift open: raising that budget on its own would let a widened window still
look compliant while the fleet overview and fleet readiness surfaces keep
treating 3 s as the bound they assume. The test now also asserts the window
against that shared ceiling as a literal, and the constant's docblock names the
two budgets it mirrors.

No behaviour change. The window is unchanged at 1500 ms.

* fix(gitops): share one reachability probe per node and stop a stale dark write

Opening a Blueprint application detail, the stack drift panel and the portfolio
list all ask about the same node, and they overlap: a panel opened over the list
probes twice in the same instant, and a held-open panel re-probes on every
published GitOps transition. The verdict is only written once a probe settles,
so every reader arriving during a probe started its own, and against a node
that really does time out that is a full probe budget each time. The reuse window
did nothing for the overlapping case, which is the case the reads that prompted
it actually produce.

One probe leg per node is now in flight at a time, so a reader arriving mid-probe
joins it. The list fan-out joins the same leg, so opening a panel over the list
no longer probes the same node twice.

That sharing is also what orders the two writers. Only one leg per node exists,
so a probe that saw a node answer settles and writes before the next probe of
that node can start, and a dark verdict landing after a newer answer would need
two overlapping probes of one node. The alternative, comparing probe timestamps,
would have been state that can never fire.

A thrown leg is now evidence in neither direction for both writers: it records no
verdict and retires none, because a throw says this build could not read the node
rather than that the node is down. Retirement moved to the moment a payload
arrives, before the payload is walked, so a node that answered is never left
reading unreachable because its payload could not be parsed.

The probe timeout, the reuse window, what a verdict means, and the response shape
are unchanged.

* refactor(gitops): let the probe leg own its verdict write

The reachability probe's leg now records the verdict instead of each reader
recording it after its own await. The ordering guarantee no longer depends on
both writers writing synchronously in the settling continuation, which was a rule
a later edit could break silently: three readers sharing one leg recorded the same
verdict three times, and a reader arriving late could re-arm the reuse window once
per read rather than once per probe.

`probeNodeOnce` also releases its slot in a finally, so a verdict write that ever
throws cannot wedge every later read of that node on a settled promise, and the
test-only reset attaches a rejection sink to the legs it drops rather than
creating an unhandled rejection. A dropped leg cannot be cancelled, so one that
settles after a reset still records its single verdict; flushing the cache after
the reset clears that.

Docs and comments follow the code: `probeSilentNodeIds` and the fan-out are
readers now, and say so, so a later editor does not re-add reader-side writes. The
shared-rows contract is stated with its fragility rather than as settled, and the
rejection comment no longer reads as one log line per joiner.

* test(gitops): make the shared-leg test order-independent and honest about what it shows

The shared-leg filtering test depended on other tests having run first. It waited
for a probe count derived from the whole node roster, so with only its own node
the wait passed immediately, the second read started after the leg was released,
and the test hung until the timeout. It now waits on a marker node the test
creates itself, which says when a read has reached its fan-out regardless of what
else is in the database.

That test also only caught an in-place filter when the unrestricted read went
first, because the restrictive read has nothing to remove and an admin sees
everything. The viewer now goes first, so a filter that spliced the shared array
would cost the admin a row the test asserts it must see. Verified: an in-place
filter mutation now fails the test.

The late-reader test is renamed to what it shows, which is that a settled leg
frees its slot so a later probe can retire the verdict. It never tested that a
reader writes nothing late, because under a leg-owned write there is no reader
left to intercept; the per-probe spy test carries that guarantee alone, and says
so. The duplicate overlap test, whose only distinction was one microtask wait,
is gone.

Comment fixes from the same review: a garbled sentence in the detail-probe
docblock, a wrong claim that flushing after the test reset clears a dropped leg's
late write, a worst-case example that named a node going dark rather than one
recovering just after its probe started, and two-writer phrasing left in the test
file header. Tests now build their cache key from the exported namespace rather
than repeating the string.
2026-10-02 15:28:48 -04:00