Commit Graph

315 Commits

Author SHA1 Message Date
Anso d8b6f8cf3b feat(stack-files): force-text override for misidentified binary files (#1215)
* feat(stack-files): force-text override for misidentified binary files

The binary-detection heuristic (30% non-printable / NUL in the first
8 KB) sometimes flags UTF-8 files that happen to carry an embedded NUL
or a high non-printable ratio, locking the user out of inline editing
with only a Download fallback.

readStackFile now accepts an optional { forceText: true } that bypasses
isBinaryBuffer on the small-file path and returns the bytes as UTF-8
content. The route exposes this as ?force=text on GET /files/content.
The oversized branch deliberately stays untouched: returning a multi-MB
file as JSON-encoded text is wasteful regardless of the heuristic.

SpecialFilePanel grows an optional extraAction slot. The viewer's
binary branch wires Open as text anyway, which refetches with the new
flag, clears isBinary, and routes the content through the existing
Monaco editor path. A failed override surfaces both an inline error
panel and a toast so the user knows why the click did nothing.

Backend tests pin the heuristic-vs-override behaviour pair on a file
with a literal NUL byte. The frontend test asserts that the second
readStackFile call carries forceText: true and that Monaco mounts.
Troubleshooting accordion entry updated to mention the new affordance.

* fix(stack-files): guard the binary-override path against oversized files

The override on the binary panel could open Monaco against an empty
content buffer if the backend's oversized branch ran (files past the
2 MB inline-preview cap intentionally carry no content even when
force=text is set). Saving that empty buffer would wipe the file on
disk.

Two reinforcing changes:

- Initial load now checks result.oversized before result.binary, so a
  file that is both oversized and has binary bytes in the 8 KB probe
  shows the Download panel rather than the binary panel. The size
  signal stays in front of the operator and the override button never
  surfaces for a file that cannot be safely opened inline.

- The handleForceText handler now respects result.oversized on the
  refetch and transitions to the Download panel instead of clearing
  isBinary and copying result.content ?? '' into Monaco.

Same handler also gains a stale-request guard via a selectedPathRef:
a slow override for file A no longer stomps on file B's state if the
user navigated away while the request was in flight.

Two regression tests pin the new behaviour: oversized+binary surfaces
the Download panel on initial load, and an oversized refetch from the
binary panel routes to the Download panel rather than Monaco.
2026-05-25 01:38:04 -04:00
Anso c2357ec534 fix(stack-files): symlink-aware delete and chmod (#1214)
deleteStackPath now lstats the leaf and unlinks the link entry itself
when it is a symbolic link, so the file the user clicked on in the tree
is what gets removed (the linked target stays intact). chmodStackPath
rejects with LINK_CHMOD_UNSUPPORTED on a symlink rather than silently
mutating the target's permissions; Node's lchmod is macOS-only and
following the link is the bug being fixed here.

Path-component symlinks are still resolved via the existing
resolveSafeStackPath, so a symlinked parent that escapes the stack dir
still surfaces SYMLINK_ESCAPE before the leaf is inspected.

Service-level tests cover delete on internal-target / external-target /
broken / dir-target symlinks, chmod rejection on symlinks (including
the broken case), and non-symlink regression checks. Route-level tests
pin the 409 LINK_CHMOD_UNSUPPORTED mapping and the link-only-delete
behaviour. The describe blocks are platform-gated; Windows symlink
creation needs admin/developer-mode and is skipped along with the
existing SYMLINK_ESCAPE test.

Docs updated to describe both behaviours in plain product terms.
2026-05-25 01:30:00 -04:00
Anso fcf2222604 feat(stack-files): cap directory listings at 1000 + add file-tree filter (#1208)
* feat(stack-files): cap directory listings at 1000 + add file-tree filter

The file-tree route returned every entry in a directory unbounded.
A logs/ or data/ subfolder with rotated artifacts could produce a
multi-megabyte response and a frontend cap at 500 entries silently
hid the rest with no way for the user to find a specific file.

The list route now caps the response at 1000 entries (the audit's
recommended bound), advertises the unfiltered total via
X-Total-Count, and sets X-Truncated when truncation happened. The
service exposes both a bare-array listStackDirectory (unchanged
contract for callers that just want the array) and a paginated
listStackDirectoryPage that returns {entries, total, truncated}.

The FileTree now offers a search input above the scroll area that
filters loaded entries by name (case-insensitive substring). Clearing
the filter restores the full listing. A non-matching filter shows a
short hint instead of an empty pane. The client-side MAX_ENTRIES
matches the server cap so a perfectly-sized directory never shows
the truncation hint.

* fix(stack-files): filter keeps parent dirs when loaded descendants match

The original filter applied per-render-level inside renderEntries, so a
parent directory whose name did not match was filtered out even when one
of its already-loaded children did. The match was then unreachable: the
parent had been removed from the visible list and its children never got
a chance to render.

Compute matching-descendant once per directory by walking the loaded
dirContents map (no extra fetch, bounded by what the user already
expanded). Keep ancestors of any match in the visible list. Auto-expand
those ancestors for the duration of the filter so the match comes into
view without a manual click on every parent.

Filter scope is still 'what is already loaded'; unexpanded subtrees do
not contribute to ancestor-keep until the user expands them. Two new
tests pin both behaviours.
2026-05-25 00:03:09 -04:00
Anso 4964320f50 fix(stack-files): optimistic concurrency on file-tab writes via mtime ETag (#1206)
* fix(stack-files): optimistic concurrency on file-tab writes via mtime ETag

PUT /api/stacks/:name/files/content previously did a blind write; two
operators editing the same script lost one of the saves with no
warning. The compose-file editor already had mtime optimistic
concurrency (PR #1183); this brings the file-explorer write path to
the same shape.

GET /files/content now also returns mtimeMs and sets a weak ETag header
derived from the stat. The matching PUT reads If-Match, asks
FileSystemService.writeStackFileIfUnchanged to compare against the
live mtime, and returns 412 PRECONDITION_FAILED with the current
content and mtime when the stale-write check fails. Successful writes
echo a fresh ETag so the client can pin the next save without re-GET.

readStackFile and writeStackFileIfUnchanged each open the file once
and stat+read through the same handle so the mtime returned to the
client matches the bytes that were sent, even if the file is replaced
between the two operations.

PUT without If-Match still succeeds (backward compatibility with
scripted clients that do not roundtrip the ETag). FileViewer now sends
the loaded mtime on save, updates its local mtime from the success
response, and on FileConflictError adopts the server snapshot as the
new baseline so the user's follow-up edit-and-save does not loop on
the same precondition.

* fix(stack-files): treat deleted-target as conflict; preserve user buffer on conflict

Two follow-ups from code review on the prior commit:

- writeStackFileIfUnchanged now returns ok:false when expectedMtimeMs is
  set and the target has been deleted. The caller was editing a file
  that no longer exists; silently writing the buffer to the void is
  wrong. The client adopts the empty snapshot as 'file is gone, start
  over' and the user keeps control of what to save next.

- The FileViewer conflict handler no longer overwrites the user's
  typed buffer with the server snapshot. It updates the baseline so
  the next save sends the fresh mtime, then leaves the editor content
  alone. The user sees their edits, the Save button stays enabled,
  and a follow-up click applies their changes on top of the new
  server version without silently destroying what they typed.

* fix(api): preserve default headers when caller supplies a headers field

apiFetch built defaultOptions.headers by merging Content-Type, x-node-id,
and the caller's headers, but then spread the unmodified fetchOptions
over defaultOptions at the outer level. The spread overwrote the merged
headers with the caller's bare headers, silently dropping Content-Type
on every request that supplied any custom header.

This was latent until the file-explorer save path started sending an
If-Match header. The Express body parser refused the PUT without
Content-Type, the route returned 400, the editor showed an error
toast instead of the success toast, and the Playwright save assertion
timed out.

Destructure headers out of fetchOptions before the outer spread so the
already-merged defaultOptions.headers survives. Add api.test.ts with
four regression cases pinning Content-Type, the If-Match merge,
x-node-id presence when active, and localOnly skip.
2026-05-24 23:44:24 -04:00
Anso 668eda6cc8 fix(stack-files): atomic write via tmp+rename with optional exclusive mode (#1205)
* fix(stack-files): atomic write via tmp+rename with optional exclusive mode

writeStackFile and writeStackFileBuffer previously called fs.writeFile
directly, which truncates the target then streams the new bytes. A
crash, disk-full event, or process kill between the truncate and the
write left the target with partial content and no easy way to detect
the half-write at read time.

A private writeStackFileAtomic helper stages every write into a
sibling .sencho-tmp-<suffix> file in the same directory, fsyncs, then
promotes via fs.rename. A crash now leaves either the original target
intact or a leftover .sencho-tmp file (cleaned up on the next failure
path); a torn target file is no longer reachable through this path.

The helper accepts an optional `exclusive: true` flag that swaps the
final promote step from rename to link+unlink. link is atomic against
EEXIST so a caller that needs "create only if not present" gets a
race-free FILE_EXISTS error instead of a clobber. The upload route's
overwrite-confirm flow (PR #1204) will wire this through in a
follow-up so the existence check becomes authoritative.

Behaviour for current callers (writeStackFile, writeStackFileBuffer,
BlueprintService deploy) is unchanged: the non-exclusive default
matches the prior fs.writeFile semantics from the caller's perspective.

* fix(stack-files): tighten atomic write entropy + concurrent / failure tests

Tmp suffix now uses crypto.randomBytes(6) so the per-process collision
window is a true 48-bit space (Math.random().toString(36).slice(2,6)
could drop leading zeros and narrow entropy unpredictably). Adds a
short comment on the Windows link path noting NTFS / same-FS POSIX is
required, both guaranteed by tmp+target being siblings.

Two new tests close coverage gaps the first round missed:
- a write step that throws (writeFile rejected) leaves no tmp leak and
  no partial target;
- concurrent non-exclusive writers settle with at least one success
  and the final file is exactly one of the inputs (POSIX silently
  overwrites; Windows EPERMs the loser, both consistent).
2026-05-24 23:34:26 -04:00
Anso c8b095b887 fix(stack-files): confirm before overwriting an existing upload target (#1204)
* fix(stack-files): confirm before overwriting an existing upload target

Same-name uploads previously truncated the existing file silently. A
user dragging a file with a name that matched an in-place file
destroyed the original with no warning and no undo.

The upload route now reads ?overwrite=0|1. When the flag is not set
and the target already exists, the server returns 409 FILE_EXISTS
and the original file is untouched. The frontend opens a confirm
dialog and retries with overwrite=1 on the user's approval; cancel
keeps the original.

A new pathExists helper on FileSystemService performs the existence
check through the same path-resolution barrier as the write so a
malicious relPath cannot bypass the conflict check. UploadConflictError
is exported so callers can distinguish the conflict case from generic
upload failures without parsing error strings.

* fix(stack-files): distinct DIR_EXISTS code, drop INVALID_PATH swallow in existence check
2026-05-24 23:17:22 -04:00
Anso 37b12379c1 fix(stacks): refuse file-explorer delete/rename/chmod on protected stack files (#1202)
* fix(stacks): refuse file-explorer delete/rename/chmod on protected stack files

Previously the per-stack file explorer treated PROTECTED_STACK_FILES
(compose.yaml, compose.yml, docker-compose.yaml/.yml, .env) as a UI
hint only. A direct API call from any user with stack:edit could
delete or rename compose.yaml and break the stack irrecoverably
because the next deploy would fail to find a compose file and the
write was unrecoverable without a DB backup. The frontend
DeleteFileConfirm enforced a type-to-confirm gate but a stale UI or a
scripted client bypassed it.

FileSystemService now refuses the destructive ops at the service layer
with a new PROTECTED_FILE error code that the route layer surfaces as
409. The compose-editor save path (PUT /files/content) and the
upload-overwrite path (POST /files/upload writing a same-named file)
remain unblocked because both are legitimate ways to update
compose.yaml. Tests pin the allowed paths so a future tightening can
not silently regress them.

The protection is scoped to entries at the stack root; subdirectory
files happen to share a protected name (e.g., a snapshot under
backups/compose.yaml) are not blocked because compose CLI only reads
the root file. A trailing-slash bypass is closed by stripping trailing
separators before the basename check.

Frontend DeleteFileConfirm needs no change. Its existing toast.error
surface renders the friendly server message.

* fix(stack-files): drop polynomial regex from protected-file helpers

CodeQL js/polynomial-redos flagged the /\/+$/ pattern used to strip
trailing slashes from relPath in isProtectedRelPath and
protectedFileError. The regex is bounded in practice (the upstream
validator rejects '//' anywhere in the path) but the static analyzer
cannot follow that dataflow guarantee and would have flagged any
future caller that skips the validator.

Replace the two callsites with a small stripTrailingSlash helper that
uses endsWith + slice. Bounded O(1), no regex, no analyzer alert. The
inline comment documents the upstream invariant so a future reader
does not reintroduce the /+ quantifier.
2026-05-24 23:06:05 -04:00
Anso 7ec6fe05bb feat(stacks): in-process per-(nodeId, action) metrics + admin endpoint (#1196)
Sencho exports no telemetry by design (privacy-first posture). That left
operators with no answer for "why is this remote node slow today?"
except scrolling logs. The audit log records mutations but has no
latency information.

Adds StackOpMetricsService - a tiny singleton holding per-(nodeId, action)
counters and a 1000-sample ring buffer of latencies for p50/p95.
Exposed through GET /api/stack-metrics (admin-only) so operators can
pull the snapshot when debugging without touching disk or scrolling
journalctl. No external export; the data never leaves the process.

Wiring: deploy, down, restart, stop, start, update routes each capture
t0 at entry, set ok=true after the success path, and record() in a
finally block so failures count too. The record() call is cheap (one
Map lookup, one push, occasional shift on the bounded ring buffer)
and bounded in memory regardless of throughput.

Resolves M-4 from the stack-management audit.

API:
  GET /api/stack-metrics  (admin-only)
  Response: { entries: [{ nodeId, action, count, successCount,
              errorCount, avgMs, p50Ms, p95Ms }, ...] }
  Ordering: nodeId ascending, then action ascending.

Note on route mounting: /api/stack-metrics rather than the audit doc's
suggested /api/meta/stack-metrics because metaRouter is intentionally
mounted before authGate (public /api/health and /api/meta endpoints);
adding an admin-only route to that group would either bypass auth or
need a special inline gate that fights the existing structure. A
dedicated /api/stack-metrics router after authGate is cleaner.

Tests:
  - 9 unit tests in stack-op-metrics-service.test.ts: singleton,
    keyed-by-nodeId-action, p50/p95 math, ring-buffer cap at 1000,
    NaN/negative/Infinity rejection, ordering, reset.
  - 3 integration tests in stack-metrics-route.test.ts: 401 without
    auth, empty on fresh process, shape after recording.
2026-05-24 16:07:54 -04:00
Anso 60247d9c2e chore(stacks): gate informational console.log behind developer_mode (#1194)
Rebased onto current main (post H-1 / H-2 / L-3 / M-2 / M-6 merges).
Same intent as the original M-5 commit:

  - Local dlog() helper in routes/stacks.ts wrapping console.log
    behind isDebugEnabled().
  - All informational console.log in stacks.ts replaced with dlog().
  - The 3 Exec session-lifecycle console.log in DockerController.ts
    wrapped with inline if (isDebugEnabled()) gates.

console.warn and console.error remain unconditional everywhere.

Resolves M-5 from the stack-management audit.
2026-05-24 15:57:42 -04:00
Anso fbd13accda feat(stacks): optimistic concurrency on compose and env file writes (#1183)
* feat(stacks): optimistic concurrency on compose and env file writes

Two browser tabs (or one tab + an out-of-band edit) could silently
overwrite each other's compose.yaml or .env edits. GET /api/stacks/:name
and GET /api/stacks/:name/env now emit a W/"<mtime>" ETag header. PUT
on the same endpoints reads If-Match and returns 412 with
{code: 'stack_file_changed', currentMtimeMs, currentContent} on a stale
write, so the editor can recover without losing the user's text.

The 412 path in the editor surfaces a confirm dialog: "Overwrite
their changes?" Cancel loads the latest content into the editor and
exits edit mode. OK retries the PUT with no If-Match header.

If-Match is optional. A client that doesn't send it falls through to
the previous unconditional-write behavior so partial deploys (file
explorer uploads, git source sync) don't gain a surprise 412 surface.

* fix(stacks): consistent stat+read for compose mtime via held file handle

Promise.all([readFile, stat]) lets a concurrent write interleave between
the two calls: the read can return new content while the stat returns
the old mtime (or vice versa). The next If-Match check would then either
spuriously trigger 412 or silently allow an overwrite.

Hold the file descriptor open across stat and read so both ops observe
the same inode state. A rename-replace by another writer would not
affect the held fd's view.

Adds a near-boundary mtime test (1-second bump) to confirm the
Math.floor comparison detects whole-second changes on filesystems that
round to second-level precision.

* fix(stacks): defense-in-depth path validation in new compose mtime methods

CodeQL's taint engine on PR #1183 flagged 10 js/path-injection errors
across the four new FileSystemService methods (getStackContentWithMtime,
saveStackContentIfUnchanged, writeFileIfUnchanged, statMtime). The
engine does not follow the existing assertWithinBase guard across the
resolveStackDir / getComposeFilePath helper boundary; from its view the
stackName flows straight from req.params into a filesystem sink.

Eight alerts (getStackContentWithMtime + saveStackContentIfUnchanged)
were CodeQL blind-spot: the path WAS validated inside resolveStackDir.
Two alerts (writeFileIfUnchanged + statMtime) were genuinely missing
service-level guards because those methods accept a raw targetPath
from the caller and trusted the route to have validated upstream.

Add an explicit this.assertWithinBase(filePath) at the top of each
new method. The check is redundant for the two methods that already
went through resolveStackDir but makes the safety boundary visible
both to readers and to CodeQL's taint follower.

While here, port the held-file-descriptor pattern (already applied to
getStackContentWithMtime in dd7545eb) to the stat-then-read sequence
in saveStackContentIfUnchanged and writeFileIfUnchanged. This closes
the two js/file-system-race warnings on those branches so a concurrent
rename-replace cannot interleave between the mismatch detection and
the currentContent capture returned in the 412 payload.

The two remaining js/http-to-file-access warnings ("write to file
system depends on untrusted data") are semantic and intentional: this
is the save endpoint by design. They stay as warnings (not errors),
do not block CI, and are not suppressed because the codebase does not
do CodeQL suppression annotations.

* fix(stacks): inline path.resolve+startsWith barrier per CodeQL recommendation

The previous fix added this.assertWithinBase() calls at the top of each
new method. CodeQL's taint-flow analysis does not follow that helper
call across the function boundary, so it still saw the path as
user-tainted at every fs sink (10 -> 8 alerts after the first attempt).

CodeQL's documented js/path-injection sanitizer recognizes the
following inline pattern:

  filePath = path.resolve(ROOT, filePath);
  if (!filePath.startsWith(ROOT)) { throw / return; }
  // use the reassigned filePath below

The key elements are (1) path.resolve as the normalizer, (2) startsWith
check, (3) reject inline, (4) downstream use of the reassigned
variable. None of these can hide behind a helper call or the taint
tracker re-flags every sink.

Inlines the pattern in each of the four new methods (getStackContentWith-
Mtime, saveStackContentIfUnchanged, writeFileIfUnchanged, statMtime).
The check stays runtime-correct (it's the same logic isPathWithinBase
implements) but is now visible to static analysis.

writeFileIfUnchanged and statMtime had no service-level guard at all
before this commit (they trusted the caller); the inline barrier closes
that real gap as well as the CodeQL-recognition gap.

* fix(stacks): canonical CodeQL js/path-injection barrier shape

The prior inline check used path.resolve(filePath) with a single
argument and a compound condition (a !== b && !c.startsWith(d)).
CodeQL's path-injection sanitizer recognizer is shape-sensitive: it
matches path.resolve(SAFE_ROOT, untrusted) with the safe root as the
first argument, followed by a single unary startsWith check on the
resolved variable. The compound form and the single-arg resolve fell
outside the recognized pattern, leaving 8 alerts unchanged across the
four new methods.

Rewrite the barrier in each method to match the documented shape
verbatim:

  const baseResolved = path.resolve(this.baseDir);
  const safePath = path.resolve(baseResolved, untrustedInput);
  if (!safePath.startsWith(baseResolved + path.sep)) {
    throw ...;
  }
  // sinks consume safePath

baseResolved is a local variable (anchors the resolve call against a
known-safe root). safePath is the reassigned, sanitized variable that
every downstream fs.* call consumes. The single startsWith check with
path.sep appended prevents the prefix-match edge case (/foo matches
/foobar without the separator). All four affected methods get the
same form.

This is the third attempt at the CodeQL fix. The first added an
assertWithinBase helper (function call, not followed across the
boundary). The second inlined path.resolve(x) with a compound check
(non-canonical shape). This commit uses the literal recommended
sanitizer.
2026-05-24 15:48:17 -04:00
Anso d727a55a5f feat(stacks): surface post-deploy scan attempt status (#1198)
triggerPostDeployScan was fire-and-forget. When Trivy was missing on a
node, when the registry refused the digest lookup, or when a single
image scan threw, the failure went to console.error and the user
never learned. Open the security tab later, see stale data, no
indicator that the scan even tried.

Backend:
- New stack_scan_attempts table (node_id, stack_name, status,
  attempted_at, error_message). One row per stack; latest attempt
  overwrites the previous one.
- DatabaseService gains recordStackScanAttempt /
  getStackScanAttempt / clearStackScanAttempts. Status is one of
  'ok' | 'partial' | 'failed' | 'skipped'.
- triggerPostDeployScan in helpers/policyGate.ts now records every
  exit path: 'skipped' when Trivy is unavailable or no images to
  scan; 'failed' when container enumeration or all images fail;
  'partial' when some images scan and others fail; 'ok' on full
  success.
- New GET /api/stacks/:name/scan-status returns { status,
  attemptedAt, errorMessage } or { status: null } when never tried.
- DELETE /:stackName cleanup chain now clears the row alongside
  the existing update-status / auto-update cleanups.

Frontend:
- StackAnatomyPanel fetches /scan-status on stackName change.
- Renders a small warning strip below the update banner when
  status !== 'ok' (failed / partial / skipped). Hidden when status
  is 'ok' or unknown (never attempted). Title attribute carries
  the full error message for hover inspection.

Cross-feature note: the audit doc flagged this as M-6 with a
coordination note for the pending Security feature audit. The
schema kept intentionally narrow (one row per stack, simple
status enum) so the Security audit can extend it (richer history,
per-image-row breakdown, etc.) without a destructive migration.

Resolves M-6 from the stack-management audit.
2026-05-24 15:44:12 -04:00
Anso 60ecd574b3 fix(stacks): serialize concurrent lifecycle operations per stack (#1182)
* fix(stacks): serialize concurrent lifecycle operations per stack

Two simultaneous POSTs to /api/stacks/:name/{deploy,down,restart,stop,
start,update} could race against the same compose project, doubling
notifications, doubling post-deploy scans, and corrupting the
atomic-deploy backup snapshot. Each lifecycle route now acquires a
per-(nodeId, stackName) in-process lock; the second caller gets 409
with {code: 'stack_op_in_progress', inProgress: {action, startedAt,
user}} and the frontend surfaces a "X is already deploying" toast.

The lock is process-local on purpose: it shares a lifetime with the
docker compose child process. A Sencho restart clears all locks, which
matches the truth that an in-flight compose op is gone too.

The existing policy-block 409 is shape-distinguishable (has policy /
violations) and continues to work; the frontend checks the new code
discriminator first before falling through to policy handling.

* chore(stacks): validate action enum in 409 parser; cover start collision

The frontend parseStackOpInProgress used to cast the parsed action
directly to StackOpAction. A backend bug or spoofed payload returning
action='wibble' would slip through. Validate against the known enum
set before returning the parsed info.

Adds an integration test for the deploy-blocks-while-start-in-flight
case so all six lifecycle verbs have collision coverage (the existing
suite covered deploy/down/restart/stop/update; start was indirect).
2026-05-24 01:12:07 -04:00
Anso 8ba88755b1 fix(stacks): default Empty template ships ports block commented out (#1189)
The Empty branch of the Create Stack flow wrote a compose.yaml whose
first service bound the host's port 8080 by default. On any workstation
already running something on 8080 (traefik, caddy, librespeed, another
nginx, etc.) the very first deploy failed at the docker compose
networking step with "Bind for 0.0.0.0:8080 failed: port is already
allocated", which made the day-one experience feel broken right after
F-2 (PR #1168) tightened the dialog itself.

The boilerplate in FileSystemService.createStack now emits the ports
block commented out plus a one-line hint above it. A fresh deploy
binds no host port, so the container starts cleanly on any host; the
user uncomments the two-line block when they're ready to expose the
container. The deterministic shape (no probe-and-write, no random port,
no preflight scan) avoids the TOCTOU race that a free-port probe would
have left between template creation and the actual compose up.

Adds backend/src/__tests__/file-system-service-create-stack.test.ts
(8 cases): directory + file creation, structural YAML assertions via
yaml.parse to lock in the no-live-ports invariant, raw-text regex to
lock in the commented hint, and the already-exists rejection path.
FileSystemService.createStack had no coverage before this change.

Adds e2e/default-stack-template-no-fixed-port.spec.ts (1 case): drives
the dialog through Create, reads the resulting compose via the
in-browser apiFetch, and asserts the live + commented invariants
end-to-end.

docs/features/stack-management.mdx Empty bullet rewritten to describe
the minimal skeleton and the commented ports block instead of calling
it "blank".

Resolves: F-3 in the v1.0 audit tracker.
2026-05-24 01:06:22 -04:00
Anso aa3d99a594 fix(mesh): re-evaluate data plane every 10s and add opt-in auto-recreate (#1184)
* fix(mesh): re-evaluate data plane every 10s and add opt-in auto-recreate

MeshService.dataPlaneStatus was written exactly once at boot in
setupMeshNetwork() and never re-evaluated. After the operator removed
sencho_mesh at runtime (or it was recreated externally, or Sencho was
disconnected from it), /api/health and the dashboard banner kept
returning the stale boot-time discriminator until the next process
restart.

Adds a 10s revalidator that inspects the current Docker truth in one
network-inspect call and transitions dataPlaneStatus to reflect it.
Short-circuits in not_started / not_in_docker / subnet_invalid
(states that cannot change within this process) and in concurrent
ticks. Transitions are idempotent on stable state, so the timer can
tick indefinitely on a healthy mesh without log noise.

New 'not_found' reason value for the network-was-removed-at-runtime
case. Existing reasons (subnet_mismatch, subnet_overlap, attach_failed)
also surface from the revalidator when their underlying conditions
arise post-boot. transitionDataPlane keeps message and subnet fields
fresh across consecutive observations even when reason is unchanged,
so /api/health never reports stale numbers (e.g. two consecutive
subnet_mismatch observations against different external subnets).

Adds an opt-in mesh_auto_recreate global setting (default off). When
on, the revalidator additionally calls attemptInPlaceRecreate() after
surfacing not_found. The helper hard-prefers the boot-chosen subnet
(this.meshSubnet) and never iterates candidates, because changing the
subnet here would invalidate every existing extra_hosts override on
disk. A real conflict on the original subnet is reported as
subnet_overlap and preserved during the 60s recreate throttle window
so the operator-actionable reason is not flapped back to not_found
between attempts.

Self-attachment is checked via Name match (operator --hostname X
matches container Name /X) or full container-ID prefix for hex
HOSTNAMEs >= 12 chars (Docker default short ID). Non-hex HOSTNAMEs
cannot collide with container IDs at all so a Name miss is conclusive;
short hex HOSTNAMEs preserve the prior status as 'unknown' rather than
risking a false-positive prefix match.

Frontend surfaces:
- types/mesh.ts: 'not_found' added to MeshDataPlaneReason.
- MeshDataPlaneBanner: 'not_found' headline copy.
- Settings > System > Mesh data plane: TogglePill bound to
  mesh_auto_recreate, default off, helper text explains the tradeoff.

Backend coverage in backend/src/__tests__/mesh-data-plane-revalidate.test.ts
(25 cases): short-circuits, idempotent stable-state, recovery from
subnet_mismatch / subnet_overlap, transition to not_found / subnet_mismatch
/ attach_failed, transient-Docker anti-flap, re-entrancy guard, name
match path, ID-prefix path, short hex hostname ambiguity, non-hex
hostname certainty, transition message refresh on observation drift,
auto-recreate off (default), auto-recreate success with senchoIp
preservation, auto-recreate overlap classification with no subnet
drift, throttle window preserves classified reason, throttle release.
Lifecycle test covers timer wiring in start()/stop().

Existing mesh-setup-error-classification suite (27 cases) still green.

Resolves: F-4 in the v1.0 audit tracker.

* fix(mesh): address Codex review of PR #1184

Three findings from the independent review:

BLOCKER: attemptInPlaceRecreate() called recordSetupFailure() on
create / attach failures, which clears this.senchoIp. The next
revalidator tick's attachment check is guarded on senchoIp, so with
it null the check is skipped and the snapshot path can silently flip
the status back to ok against a network where Sencho is in fact not
attached. Also: a later successful recreate would call
ensureSelfAttached() with senchoIp null, which short-circuits, so
the network gets recreated without binding Sencho.

Replaced the recordSetupFailure() calls in attemptInPlaceRecreate
with a new recordRecreateFailure() that uses transitionDataPlane and
preserves senchoIp. Added two tests: create-fails-then-succeeds
(verifies senchoIp survives the failure and the later retry binds
Sencho correctly) and create-succeeds-attach-fails-then-next-tick
(verifies the snapshot path surfaces attach_failed on the next tick
instead of falsely reporting ok).

SHOULD-FIX 1: single-key POST /api/settings wrote String(value)
without re-validating against the per-key schema, so an allowlisted
enum-shaped key like mesh_auto_recreate could persist arbitrary
strings ('banana', 'true') that the bulk PATCH would later refuse.
Routed the single-key path through SettingsPatchSchema.safeParse so
both write paths validate identically. Added regression tests for
an invalid mesh_auto_recreate value, a valid mesh_auto_recreate
write, and an out-of-range numeric value.

SHOULD-FIX 2: the new Mesh data plane subsection lived inside a
section the registry exposes to non-admins, who would see the toggle
and only learn it was admin-only after the save 403'd. Gated the
subsection on `isAdmin` from useAuth so non-admins do not see the
control. The other system controls keep their existing visibility
pattern (read-only for non-admins).

71/71 backend tests green (revalidate + mesh-setup + settings-routes).
276/276 frontend tests green. tsc clean on backend + frontend.
2026-05-23 18:09:39 -04:00
Anso f03c9dc7b6 fix(webhooks): address Codex review of PR #1177 (#1181)
* fix(webhooks): close HMAC timing oracle on trigger reject paths

The trigger handler in PR #1177 returned a uniform 404 for every
unauthenticated rejection, but only the wrong-signature path computed
an HMAC over the request body. The other reject paths (unknown id,
disabled webhook, non-paid tier, missing X-Webhook-Signature header,
missing rawBody) short-circuited before any HMAC work. Repeated
near-rate-limit probes with a large attacker-controlled body could
distinguish a valid-and-enabled paid webhook id from the other reject
cases through response latency.

WebhookService.validateSignature is now constant-time over every input
shape: it always runs crypto.createHmac and crypto.timingSafeEqual
against fixed-length 32-byte buffers regardless of whether the
signature is missing, has the wrong prefix, is malformed hex, or is
the wrong length. The trigger handler calls it unconditionally before
any reject branch fires, using a stable per-process decoy secret
(WebhookService.getDecoySecret) when the webhook does not exist and an
empty buffer when the request has no body. Response timing now depends
only on the size of the request body, which the attacker already
controls and which reveals nothing webhook-specific.

Six new tests pin the behaviour: validateSignature is observed firing
on the unknown-id and missing-signature paths through a spy assertion,
and four direct-call tests confirm validateSignature returns false
without throwing for empty, wrong-prefix, malformed-hex, and
wrong-length signatures.

* fix(safe-log): redact Basic auth and lowercase Windows drive letters

The redactSensitiveText helper now covers two cases the prior chain
missed:

* Authorization: Basic <base64> previously left the base64 payload
  intact. The existing key/value regex caught only the literal word
  Basic before stopping at the space. A new Basic\s+[A-Za-z0-9+/=]+
  replacement runs before the key/value regex so the credential is
  scrubbed first.
* Windows homedir paths like c:\Users\<user>\... with a lowercase
  drive letter previously slipped through because the regex required
  [A-Z]. Changed to [A-Za-z] so both letter cases are covered.

Two new tests pin both fixes.

* docs(webhooks): document 429, fix shared schema, comply with D27/D31

* Trigger endpoint declares the 429 response that webhookTriggerLimiter
  can return (500 requests per minute per source IP); both
  docs/openapi.yaml and the response table in docs/features/webhooks.mdx
  carry the new row, and a new troubleshooting accordion explains the
  shared-NAT scenario.
* Shared Webhook schema in docs/openapi.yaml extends the action enum to
  include git-pull and documents the node_id property. The GET list
  endpoint returns these fields; the prior schema would have failed
  validation for any git-pull row.
* docs/features/webhooks.mdx:7 rewritten from a customer-side role
  enumeration ("non-admins on a paid tier can view the list but cannot
  manage it") to a single requirement statement ("Webhooks require a
  Skipper or Admiral license. Managing webhooks is admin-only.") per
  CLAUDE.md D27/D31; the prior phrasing was customer-side fence-spec.
* Two em dashes in webhook description strings I had touched in the
  prior OpenAPI sync commit replaced with semicolons per D18.
2026-05-23 16:03:48 -04:00
Anso 21ec5e7e0a fix(webhooks): harden trigger response surface (#1177)
* fix(webhooks): harden trigger response surface

Bundles six audit findings on the incoming-webhooks trigger path. All
changes preserve the documented happy path: a CI caller signing the exact
request body with the webhook secret still receives 202 Accepted.

* Uniform 404 on every unauthenticated rejection (missing webhook,
  disabled webhook, non-paid tier, missing signature header, missing
  raw body, signature mismatch). The four-way response surface previously
  let an unauthenticated probe enumerate webhook ids and fingerprint the
  instance's licence tier; callers now see one shape for any failed auth.
* Fail closed when express.json()'s verify callback did not capture the
  raw request body. Previously the handler fell back to
  JSON.stringify(req.body), which compares the HMAC against a
  re-serialised payload that is not byte-equal to what the client signed.
* Pass the already-loaded webhook through to WebhookService.execute()
  instead of re-fetching by id. Closes the delete-during-execution race
  where an admin deletion between the trigger handler's load and the async
  dispatch silently dropped the execution row. The webhook_executions
  table has ON DELETE CASCADE, so recordExecution now wraps the insert in
  try/catch and logs a warning when the FK constraint trips because the
  parent webhook was deleted mid-flight.
* Redact bearer tokens, JWTs, URL credentials, and homedir paths from
  error strings before persisting to webhook_executions.error. The
  execution history is readable by any paid user via GET /webhooks/:id/
  history; redactSensitiveText gains three home-directory patterns
  (/home/<user>, /Users/<user>, <drive>:\Users\<user>) and now runs on
  every error stored from this path.
* Cap webhook name at 100 characters on both POST and PUT, rejecting
  non-string and oversized values with 400 before they reach the DB.
* Validate the body's action override against a typed allowlist
  (isWebhookAction type guard) on the trigger endpoint, returning 400
  before queueing execution. An unknown override no longer reaches
  recordExecution as a stored failure row.

Tests updated to pass db.getWebhook(id)! instead of the raw id to the new
execute() signature. Docs at docs/features/webhooks.mdx updated to reflect
the new uniform 404 response, the new 400-on-invalid-action behaviour, and
a rewritten troubleshooting accordion that walks operators through every
cause of the uniform 404.

* test(webhooks): cover trigger handler auth, race, and redaction paths

Adds 21 vitest cases for the public webhook trigger handler and the
WebhookService.execute / recordExecution pipeline, plus 3 cases for the
new homedir patterns in redactSensitiveText.

webhooks-trigger.test.ts covers, per audit finding:

* M1 + H3 uniform 404: id unknown, webhook disabled, non-paid tier,
  missing signature header, missing rawBody, sha1= prefix, malformed
  hex signature, sig mismatch. Each asserts identical 404 body so a
  future regression that re-introduces 401 / 403 / PAID_REQUIRED breaks
  one of the 8 tests.
* Happy path: 202 with configured action, valid action override,
  unknown action override returns 400 after auth succeeds (L2),
  non-string action override returns 400.
* L1 name cap: POST and PUT both reject names over 100 chars and
  non-string names; 100-char boundary still accepted; PUT allows
  partial updates that omit name.
* M5 race: deleting the parent webhook before recordExecution runs no
  longer crashes the async dispatch; the FK cascade is swallowed with
  a console.warn, and a happy-path test pins the recordExecution row.
* M6 redaction: stubs ComposeService.runCommand to throw errors
  containing a bearer token and a homedir path, then asserts the
  persisted webhook_executions.error has both scrubbed.

safe-log.test.ts gains three unit tests pinning the new homedir
patterns in redactSensitiveText (Linux, macOS, Windows). The existing
credentials test is untouched.

Tests use prototype spies on FileSystemService and ComposeService (both
hand out a fresh instance per nodeId), so per-test mocks do not leak.
beforeEach restores all mocks and reapplies the LicenseService 'paid'
spy. Closes audit finding H2 (zero trigger-path test coverage).

* docs(webhooks): sync openapi spec with new trigger response surface

Brings docs/openapi.yaml in line with the behaviour changes from the
trigger hardening commit. Mintlify auto-generates the per-endpoint
reference pages from this spec, so the spec drift would surface as
wrong response codes in the public API reference.

POST /api/webhooks and PUT /api/webhooks/🆔
  * name: maxLength 100 (matches MAX_WEBHOOK_NAME_LENGTH on the route).
  * action enum: add git-pull (pre-existing omission; the route has
    always accepted it).
  * node_id: documented as an integer property (pre-existing omission).

POST /api/webhooks/:id/trigger:
  * requestBody required: true (body is now mandatory; the H3
    fail-closed branch rejects a missing rawBody).
  * action override: enum restricted to the allowlist.
  * 401 and 403 responses removed.
  * 404 response: description rewritten to reflect uniform-404
    behaviour; the body is { error: "Webhook not found or signature
    invalid" } for every unauthenticated reject.
  * 400 response added for an authenticated request whose action
    override is not in the allowlist.
2026-05-23 15:42:49 -04:00
Anso fcff8e9047 fix(monitor): collapse repeated host-metric alerts into per-window summary (F-11) (#1175)
* fix(monitor): collapse repeated host-metric alerts into per-window summary (F-11)

A host metric over threshold previously dispatched one notification every 5
minutes for the duration of the breach, producing 7+ identical messages
in 35 minutes and spamming Discord/Slack routes. Replace the hardcoded
5-minute cooldown for CPU/RAM/disk with a per-metric suppression window
(default 60 min, configurable via host_alert_suppression_mins). The first
breach fires immediately; subsequent cycles within the window are silently
counted; the next dispatch after the window elapses carries a summary
suffix listing how many cycles were suppressed and when the breach first
crossed threshold. Recovery clears the counter so re-breach fires fresh.

The pattern mirrors PolicyEnforcement.notifyTrivyMissingOnce: module-scope
Map, in-memory only, in-cycle dedup, with a test-reset helper. The
existing system_state row keeps post-restart re-fires bounded.

Janitor and per-stack alert rules are unchanged; they already have
adequate cadence and per-rule cooldown respectively.

* fix(ci): restore backend and frontend checks

* fix(e2e): remove create button timing race

* fix(e2e): harden create double-click test

* fix(monitor): clear persisted F-11 timestamp on recovery + clamp suppression window

Independent audit on the previous commit surfaced two issues.

1. clearHostMetricSuppression early-returned on missing in-memory state,
   leaving a stale system_state.last_host_*_alert_ts row alive after a
   process restart. Scenario: breach fires + persists timestamp, process
   restarts, metric recovers before another evaluate cycle re-seeds the
   in-memory Map, recovery cleanup early-returns. Next re-breach inside
   the original window hits the restart-survivability branch and is
   silently suppressed instead of firing fresh. Fix: read persisted
   state in clearHostMetricSuppression and reset to '0' independently
   of in-memory presence. The read-before-write also skips redundant
   writes when the row is already cleared.

2. host_alert_suppression_mins is validated by zod on the bulk PATCH
   path but the single-key POST /api/settings path accepts allowlisted
   keys without re-validation. A 999999999-minute value would silence
   host alerts for centuries. Add MAX_HOST_ALERT_SUPPRESSION_MIN = 1440
   mirroring the zod max, and clamp via Math.min in evaluateGlobalSettings.

Two new vitest cases (restart-then-recovery-then-rebreach; the 1440
clamp) confirmed failing before the fix, passing after. The existing
"metric drop" case updated to use a mock-backed persistence pattern
consistent with the new restart-scenario tests. 73/73 monitor-service
tests green; full backend suite 2507/2510 (same pre-existing Windows
EBUSY flake on filesystem-backup.test.ts as baseline).
2026-05-23 06:28:01 -04:00
Anso 0947da13ae fix(security): collapse repeated trivy-missing pre-deploy notifications (#1166)
Scan policies on a node without Trivy installed previously fired one
"Pre-deploy scan skipped" warning per deploy, flooding the notification
feed during CI loops. Add a 60-minute per-(node, stack) cooldown so an
operator sees one actionable warning, not one per deploy. The boot log
line and the one-click managed install in Settings > Security are
unchanged; this only reshapes the per-deploy fanout.

Also tighten the vulnerability-scanning entry in /docs/features/overview
to point first-touch users at the one-click install on first use.
2026-05-23 01:18:30 -04:00
Anso a51547a158 fix(monitor): decouple janitor disk-usage check from 30s cycle (F-6) (#1164)
* fix(monitor): decouple janitor disk-usage check from 30s cycle (F-6)

`docker system df` (called by the MonitorService janitor check) can take
30+ seconds on Docker Desktop with many volumes. Running it on the 30s
evaluate cycle compounded with the per-container stats fan-out and pushed
the cycle to 140s+, blocking subsequent monitoring work.

This change:

- Moves the janitor disk-usage check into its own 15-minute cycle with
  a tight 8s timeout. A circuit breaker opens after 3 consecutive
  timeouts (60-minute cooldown) so a sick daemon stops pinning Dockerode
  sockets every tick. The first janitor tick is deferred 45 seconds past
  boot to avoid head-of-line collision with the initial monitor cycle's
  stats fan-out.
- Adds a paired 8s `withTimeout` wrap to the admin prune-estimate routes
  (`/api/system/prune/estimate` and the dry-run path of
  `/api/system/prune/system`) so a slow df does not hang the admin tab.
  Both routes respond 503 with code `docker_df_slow` on timeout.
- Factors `withTimeout` and `TimeoutError` into `utils/withTimeout.ts`
  so the route layer does not have to import from a service module.
- Adds 10 unit tests covering the decoupling guardrail, breaker
  open/close, cooldown, threshold gate, the 100 MB reclaimable floor,
  re-entrancy, recovery logging, non-timeout error handling, and the
  full timer-cleanup contract of `stop()`.
- Adds 4 integration tests for the prune routes covering the 503
  timeout response, the success path, and the non-timeout 5xx path.

* fix(fleet,monitor): extend F-6 timeout to fleet prune routes; close breaker-recovery log gap

Codex audit findings on PR #1164:

Major. The fleet routes that fan out prune-estimate work on local nodes
(`POST /api/fleet/labels/fleet-prune` dry-run path and
`POST /api/fleet/prune/estimate`) called `estimateSystemReclaim` without
a timeout, so a slow local Docker daemon could still hang the fleet
admin tab even though the system-maintenance routes were already
bounded. Wrap both call sites with the shared `withTimeout(..., 8s)`
and surface a "Docker daemon is busy" message via the per-target and
per-node error channels the routes already used for other failures.
The destructive (non-dry-run) prune path stays unwrapped because it
calls `pruneSystem` / `pruneManagedOnly`, not `df`.

Minor. The janitor circuit breaker zeroed `janitorConsecutiveTimeouts`
when it opened, so a successful call after a full breaker-open cooldown
slipped past the `if (counter > 0)` recovery-log branch and never
emitted `[Monitor] Janitor disk-usage check recovered`. The operator
observability signal was missing exactly when it mattered most.
Extend the predicate to also trip on `janitorBreakerUntil > 0` (which
stays set to its past timestamp after cooldown until the next success
clears it), so recovery logs symmetrically for both partial-failure
and post-breaker recovery paths. Added a dedicated test.

Three new integration tests cover the fleet routes (timeout, success,
and the estimate endpoint's per-node unreachable shape).
2026-05-23 00:04:05 -04:00
Anso 64bf6344a3 fix(mesh): bias Sencho static IP via IPAM IPRange (F-13) (#1162)
* fix(mesh): reserve Sencho static IP via IPAM auxiliary address (F-13)

Sencho pins itself to <network>+2 on sencho_mesh, but the IPAM block only
declared Subnet, so Docker freely handed that address to any meshed
workload that restarted while Sencho was offline. A real-world hit on
arrapps-prod during a Sencho upgrade had tautulli grab 172.30.0.2, which
blocked the new Sencho container's mesh attach with "Address already in
use" and left compose in a half-state needing manual disconnect/recreate.

The fix reserves <network>+2 via AuxiliaryAddresses on the IPAM Config
when creating sencho_mesh. Aux-listed addresses are removed from the
auto-allocatable pool, so Docker refuses to hand the IP to any container
that does not explicitly request it. Sencho's own attach via
connectContainerToNetwork({ ipv4Address }) is unaffected because
explicit pins still bind aux-reserved addresses. Workload containers
without a pinned IP get .3 and up.

Wire format verified against the Docker Engine REST v1.33 OpenAPI spec:
the JSON key on POST /networks/create (and on the inspect response) is
AuxiliaryAddresses inside each IPAM.Config item, value { sencho: <ip> }.

Adopt-existing path: when Sencho boots against a sencho_mesh that
pre-dates this reservation, the data plane still comes up but a one-time
warn fires (mesh.enable activity at level: 'warn' plus a [Mesh] console
line so docker logs surfaces it). The advisory explains the squat risk
and gives the recreate recipe.

Tests: 5 cases added to mesh-setup-error-classification.test.ts covering
the explicit-env create payload, the candidate-iteration winner payload,
the adopt-legacy warn (env-unset), the adopt-already-reserved silent
path, and the TOCTOU 409 race-winner adopt-legacy warn.

Docs: one sentence added to docs/features/sencho-mesh.mdx under
"Customising the mesh subnet" describing the reservation positively.

No tier/role/capability/flag gates touched (no frontend changes).

* fix(mesh): use IPRange upper-half bias instead of aux-address reservation

The initial F-13 fix used IPAM AuxiliaryAddresses to reserve <network>+2
on sencho_mesh. Empirical probe against Docker 29.4.3 confirmed this
also blocks explicit pins via EndpointConfig.IPAMConfig.IPv4Address:
libnetwork's RequestAddress() rejects a preferred-address request when
the bit is already set by the aux reservation. Result: the freshly-
reserved network refuses Sencho's own ensureSelfAttached, and the data
plane never comes up.

The pivot uses IPRange instead. IPRange constrains Docker's auto-
allocation to the configured CIDR; preferred-address requests via
RequestAddress(prefAddress) skip the IPRange check and only consult
the subnet-wide bitmap. So setting IPRange to the upper half of the
subnet (e.g. 172.30.0.128/25 for 172.30.0.0/24) biases workloads
without an explicit IP to <network>+128 and up, while Sencho's
explicit pin to <network>+2 still succeeds.

Verified on Docker 29.4.3 with the same workstation that produced the
original audit:
  - `docker run --rm --network N --ip 10.99.99.2` against a network
    with `--aux-address sencho=10.99.99.2` → "Address already in use"
    (rejects explicit pin).
  - Same `--ip` against a network with `--ip-range 10.99.99.128/25` →
    succeeds (10.99.99.2 is outside the range but inside the subnet).
  - Auto-allocated workload on the IPRange network lands at .129.

Adopt-existing legacy detection now compares IPRange instead of
AuxiliaryAddresses. inspectExistingMeshSubnet returns { subnet,
ipRange } and the warn fires when ipRange differs from the expected
upper-half CIDR. Same once-per-process semantics as before.

createMeshNetwork now derives the IPRange via a new
getMeshIpRangeFromSubnet helper. The five regression tests assert
IPRange = <network>+128/<prefix+1> in the create payload and the
expected/actual IPRange in the legacy-warn details.
2026-05-22 21:08:56 -04:00
Anso 474290081d fix(mesh): log boot state to console so docker logs surfaces mesh status (#1159)
MeshService records its boot summary and setup failures only through
logActivity (in-memory ring buffer + WS listeners), which the Routing
tab consumes. The docker-logs surface was silent for the mesh
subsystem, so operators running Sencho-in-Docker had no boot-time
visibility into whether the data plane came up cleanly.

Mirror the existing logActivity entries to console without replacing
them:

- MeshService.start() success summary: console.log /
  console.warn / console.error gated on the summary level. Format:
  [Mesh] data plane ok, self attached at <ip>, subnet <X>
  [Mesh] data plane unavailable (<reason>: <message>)
- recordSetupFailure: console.warn for the expected dev-mode
  not_in_docker case, console.error for real failures. Format:
  [Mesh] data plane unavailable (<reason>, subnet <X>): <sanitized>

The activity entries that already drive the Routing tab banner stay
intact; the console lines are purely additive for the docker logs
workflow. Two new unit tests assert the failure and not_in_docker
console mirrors fire with the [Mesh] prefix.

Fixes F-5.
2026-05-22 16:30:10 -04:00
Anso e4fe4cfced fix(mesh): address Codex audit findings on F-1 PR (#1158)
- docs(sencho-mesh): split subnet_overlap troubleshooting into env-set
  vs env-unset paths; rewrite the "Customising the mesh subnet" intro
  to describe the candidate list and the adopt-existing behavior.
- backend(MeshService): preserve idempotent 409 handling in the
  explicit-env path. On createNetwork 409 (TOCTOU race against another
  process), re-inspect and treat the race-winner as success when its
  subnet matches the operator's request; subnet_mismatch otherwise.
- frontend(MeshDataPlaneBanner): trim the card variant to a true
  one-line strip (headline only, truncate min-w-0). Full recovery
  hint stays on the Routing tab variant and in docs.
- tests(mesh): add five cases covering the previously untested
  branches — candidate-loop non-overlap bail, adopt-existing with
  unparseable subnet, explicit-env generic createNetwork failure,
  TOCTOU 409 race-winner match, TOCTOU 409 race-winner mismatch.

Architecture map (gitignored per Directive 11) updated locally with
the new useMeshDataPlane hook node and the mesh.dashboardBanner flow
so the local interactive viewer stays accurate.
2026-05-22 13:39:12 -04:00
Anso 1a03cf82af fix(mesh): auto-fallback through candidate subnets when default overlaps (#1156)
The default mesh subnet 172.30.0.0/24 is fully contained in linuxserver/*
default networks (sonarr_default 172.30.0.0/16, etc.), so libnetwork
rejects the IPAM allocation with "Pool overlaps with other one on this
address space" on a typical homelab Docker host. The single hard-coded
default left first-run operators with a silently broken mesh.

MeshService.setupMeshNetwork now resolves the subnet via three paths:

1. Operator-explicit (SENCHO_MESH_SUBNET set): use that subnet, strict.
   Pre-existing sencho_mesh with a different subnet still raises
   subnet_mismatch.
2. Adopt-existing (env unset, sencho_mesh already on the daemon): adopt
   the existing subnet. Docker is the source of truth across restarts.
3. Candidate iteration (env unset, no existing network): walk
   172.30.0.0/24, 172.31.0.0/24, 10.42.0.0/24, 10.43.0.0/24 in order.
   First subnet Docker accepts wins. If every candidate overlaps,
   record subnet_overlap with a message naming every attempt.

The dashboard's Fleet Heartbeat card now surfaces the down state via a
compact banner above the per-node rows, plus a "mesh down" counter
suffix on the right of the title. The existing Routing-tab banner is
extracted into a shared MeshDataPlaneBanner component with tab and
card variants. Dashboard polling is gated on Admiral tier so non-paid
users do not fire the Admiral-only /mesh/status endpoint.

Six new tests in mesh-setup-error-classification cover: iterates past
first overlap, all candidates overlap, adopts existing network,
inspectNetwork non-404 failure classified as attach_failed, env-matches-
existing skip-create, and operator-explicit strict (no fallback).

Fixes F-1 in the pre-1.0 audit. Closes the silent-failure mode that
left the mesh down on the most common homelab Docker layout.
2026-05-22 13:20:27 -04:00
Anso cd1cde2fd4 fix(resources): subtract shared layers when accounting managed prune bytes (#1155)
* fix(resources): subtract shared layers when accounting managed prune bytes

Both `pruneManagedOnly` and `estimateManagedReclaim` walked the
Sencho-managed prunable image set and summed `img.Size` per image. That
counts shared base layers once per image, so a 1 GB base layer shared
across N managed images was reported as N GB freed — the same shape of
inflation we just fixed for the system-scope banner.

Introduce `getImageSharedSizeMap()` which reads `df.Images[].SharedSize`
once and lets both code paths subtract `SharedSize` per image when
totalling: `+= max(0, Size - shared)`. If `df` fails, the helper returns
an empty map and the accounting degrades to the prior sum-of-Size
behavior rather than failing the prune.

Verified against a live daemon: `/api/system/prune/estimate` with
`scope: managed, target: images` now returns the layer-aware number;
the older per-image-Size sum was roughly 1.8× larger on the same set.

* fix(resources): use df-delta for destructive managed prune; label estimate as lower bound

The first attempt at this PR subtracted SharedSize per prunable image on
both paths. That formula undercounts when prunable images share a layer
exclusively with each other: Docker frees the layer once, but the
per-image subtraction removes it from every referrer. The reported total
is then strictly less than the truth.

Split the two paths:

- pruneManagedOnly (destructive) now snapshots `docker df` before and
  after the parallel removes and reports `max(0, before.LayersSize -
  after.LayersSize)`. That is the honest measurement of bytes freed.
  Concurrent pulls during the prune can grow the after value; the clamp
  treats that as 0 reclaimed for the affected delta rather than
  attributing the new bytes to us.

- estimateManagedReclaim keeps the per-image Σ(Size - SharedSize)
  formula but the JSDoc now calls it a "conservative lower bound" and
  documents the under-report mechanism. There is no cheap way to
  exactly price an arbitrary prune subset without per-layer enumeration.

Fallback chain when df fails on the destructive path:
- before-snapshot succeeded, after failed → per-image lower bound from
  before-snapshot (safe; SharedSize was known at start).
- before-snapshot failed → report 0 with a warn log (after-only would
  build a SharedSize map missing the just-pruned images, which would
  over-report by treating them as having no sharing).

Replaces the prior `getImageSharedSizeMap()` helper with two pieces:
`safeDfSnapshot()` (I/O) and a private static `mapSharedSizesFromDf()`
(pure parse), reused by both code paths.

New invariant test asserts `prune.reclaimedBytes >= estimate.reclaimableBytes`
on the same inputs so future changes to either formula cannot flip the
direction.

Addresses Codex audit blocker on PR #1155.
2026-05-22 02:40:22 -04:00
Anso a1caf6b0dd fix(resources): use daemon-reported reclaimable image bytes (#1154)
* fix(resources): use daemon-reported reclaimable image bytes

The Reclaim banner summed per-image `VirtualSize` (or `Size`) across
every image with no running container. That counts shared base layers
once per image, so an unused base layer of 1 GB shared across ten builds
showed up as 10 GB of "prunable" space. The actual prune frees the
layer once and reports a much smaller `SpaceReclaimed`, leaving the
banner and the post-prune toast badly out of step.

Prefer Docker's own `ImageUsage.Reclaimable` (API v1.44+), which is the
exact value `docker system df` displays. Older daemons fall back to the
Docker CLI's internal formula: `LayersSize - sum(Size - SharedSize)`
for in-use images, clamped to 0, skipping any image Docker flags with
the -1 unknown-size sentinel.

Verified against a live daemon: the banner now matches
`docker system df`'s IMAGES RECLAIMABLE byte-for-byte.

* fix(resources): treat SharedSize=-1 as 0 in fallback, don't drop in-use bytes

The fallback formula is `LayersSize - used`. The previous version skipped
any active image whose VirtualSize or SharedSize was -1 (Docker's
"unknown" sentinel). Skipping leaves the image's bytes out of `used`,
which reads back as reclaimable -- the exact inflation the PR set out
to fix, just on older daemons.

Treat SharedSize=-1 (or absent) as 0 so the image's full size counts as
in-use, and only skip when no usable size is available at all
(VirtualSize and Size both unknown). Under-reporting reclaimable is the
safe direction; over-reporting was the original bug.

Add a test for the SharedSize=-1 case with Size known, and rename the
existing test so it reflects what is actually being asserted now.

Addresses Codex audit blocker on PR #1154.
2026-05-22 02:40:06 -04:00
Anso 2f2401df68 fix(fleet): route remaining fleet dispatches through getProxyTarget for pilot-agent nodes (#1152)
* fix(fleet): route remaining fleet dispatches through getProxyTarget for pilot-agent nodes

PR #1123 migrated POST /api/fleet/nodes/:id/update to use
NodeRegistry.getProxyTarget so pilot-agent rows (no api_url / api_token)
participate in remote update via the tunnel loopback. The same bug shape
lived on at ten sibling fleet-dispatch sites: each read node.api_url and
node.api_token directly, returning "Remote node not configured." against
pilots, or silently filtered pilot rows out of a fan-out loop.

Migrate every remaining fleet-wide remote dispatch to the same pattern:

- routes/fleet.ts: fleet-stop, fleet-prune, prune/estimate, snapshot
  restore (4 sites) -> getProxyTarget + mode-aware error copy +
  conditional Authorization header
- routes/imageUpdates.ts: fleet status + fleet refresh (2 sites) -> swap
  n.api_url filter for getProxyTarget != null and use target.apiUrl, so
  pilot rows appear in the aggregated image-updates view instead of
  being silently excluded
- utils/snapshot-capture.ts: captureRemoteNodeFiles (1 site) -> same
  pattern; CaptureNode interface gains required mode field so the
  thrown error message picks the pilot-tunnel copy automatically
- services/SecretsService.ts: resolveEnvFileRemote, readEnvRemote,
  writeEnvRemote (3 sites) -> same pattern; thrown errors now use the
  shared formatNoTargetError helper instead of leaking api_url/api_token
  field names

Extract the previously-private noTargetMessage helper from fleet.ts
into utils/remoteTarget.ts as formatNoTargetError so SecretsService,
snapshot-capture, and the fleet routes share one copy of the
mode-aware error string.

Add 10 regression tests in fleet-pilot-dispatch-parity.test.ts covering
each migrated route + the snapshot-capture utility: dispatch through
the loopback target with no Authorization header for pilots, and a
mode-aware error when the tunnel is disconnected.

FleetSyncService (4 additional sites) carries an api_url-anchored
targetIdentity in the wire protocol; pilot support there needs a
protocol-level identity decision and stays as a separate follow-up.

* fix(fleet): throw tunnel-disconnected error from resolveEnvFileRemote

Codex audit flagged that resolveEnvFileRemote returned null when
getProxyTarget was null. That predates the parity migration but the
migration was the right place to fix it: the null flowed through
readExistingEnv into previewPushDiff / executePush as "env file not
found", which is wrong (the env exists, the node is unreachable).

Throwing formatNoTargetError(node) here lets the existing catch arms
in previewPushDiff (lines 450-453) surface reachable=false with the
tunnel-disconnected message on the right axis, and executePush picks
up the same shape via its outer catch.

Also drop overstated coverage claims from the parity test header
(snapshot restore + SecretsService were never actually exercised in
this file, only structurally identical via tsc), and fix two describe
labels that read /api/labels/* instead of the mounted /api/fleet/labels/*.
2026-05-22 00:30:51 -04:00
Anso b740dd1078 feat(resources): protect Sencho's own image, network, volumes from deletion (#1149)
* feat(resources): protect Sencho's own image, network, volumes from deletion

Adds SelfIdentityService that reads HOSTNAME at startup and inspects the
running Sencho container via Dockerode to record its image ID, attached
networks, named volumes, and container ID. The classification API marks
these with isSencho:true, destructive delete routes return 423 Locked when
the target matches self, the orphan-containers API filters the Sencho
container out so it cannot be selected and purged from the Unmanaged tab,
and the managed-prune path adds an explicit self filter for
defense-in-depth on top of Docker's in-use semantics.

The Resources view renders a Sencho pill alongside the managed badge on
matching rows and disables the trash control with a hover tooltip.

When Sencho runs outside Docker (dev mode), inspect returns 404, the
service stays empty, and every isOwn* returns false so today's behaviour
is preserved.

* fix(resources): handle sha256-prefixed image IDs and custom hostnames

Addresses independent-review findings on PR #1149:

- Strip sha256: prefix in POST /api/system/images/delete before validating
  the ID, matching the inspect route's handling. Without this, /system/images
  responses round-trip through the UI as sha256:<hex> and got 400 Invalid
  image ID format before rejectIfSelf could run.
- Add /proc/self/cgroup fallback to SelfIdentityService so custom
  --hostname, Compose hostname:, or --uts=host setups still self-identify.
  HOSTNAME inspect runs first; on 404 the service parses the cgroup file
  for a 64-hex container ID (cgroupv1 docker, cgroupv2 docker, podman
  libpod formats all covered) and retries inspect with that ID.
- Restrict prefix matching in isOwnNetwork / matchesId to hex-shaped
  candidates (12 to 64 hex chars), so a non-Sencho network whose name
  happens to start with a hex prefix of Sencho's network ID is no longer
  flagged as self.
- Trim the resources.mdx Note to customer-visible behaviour without
  enumerating every tab.
- New tests: prefixed-image-ID 200 path, three cgroup file format
  parses (v1, v2, podman) plus the no-match and missing-file cases,
  HOSTNAME-404-then-cgroup-success fallback path, name-collision
  regression for the hex-only prefix rule, and an empty-cache
  no-regression check. Test hygiene: mockReset on the inspect stub and
  restoreAllMocks in afterEach so spies do not leak across tests.

* chore(security): VEX not_affected for CVE-2026-46680 (containerd in docker-compose)

Trivy now flags CVE-2026-46680 HIGH on
usr/local/lib/docker/cli-plugins/docker-compose, which statically embeds
github.com/containerd/containerd/v2 v2.2.3 (compose v5.1.3's resolved
module graph).

The CVE is a runtime-executor flaw: containerd's runc invocation can be
tricked into running a Kubernetes pod marked runAsNonRoot as root via
crafted user ID handling. The vulnerable code path is reached only by
containerd-shim executing a container with a populated OCI runtime spec
on the daemon side. docker-compose vendors the containerd Go module
purely as a client (gRPC stubs, API types, shared utilities); it never
executes containers and never enforces runAsNonRoot. Sencho's compose
usage (up / down / ps against user-authored files) cannot construct a
Kubernetes pod security context. The vulnerable path is unreachable.

Adds a not_affected entry to security/vex/sencho.openvex.json with
justification vulnerable_code_not_in_execute_path, bumps version 5 to 6,
and updates last_updated to 2026-05-22 per Directive 23.
2026-05-21 23:10:13 -04:00
Anso c491d309c1 feat(schedules): make every scheduled action available on Skipper (#1141)
Collapse the Admiral carveout that restricted restart, prune, auto_backup,
auto_stop, auto_down, and auto_start schedules to the Admiral variant.
Scheduled Operations stays at Skipper+ (paid). The action picker now lists
every supported operation for any paid admin, and the scheduler runner
executes every action on either variant.
2026-05-21 16:42:41 -04:00
Anso 6f301e005a feat(fleet): redesign Fleet Action cards to the System Sheet recipe (#1137)
Lift <FleetActionCard> primitive from audit §20 / DESIGN §9.12. Migrate
the three v1 cards (stop-by-label, bulk-label-assign, prune-fleet-wide)
to consume it: cyan rail on every card, action class as a chip, blast
radius as a live readout in the toolbar, preview section replaces the
warning banner, footer carries reversibility plus freshness. Drop the
per-card tone rail, the icon prop, and cards/tone.ts.

Add /fleet/labels/match-preview and /fleet/prune/estimate for the live
blast readouts (chrome falls back to "preview unavailable" if either
404s). Add dryRun: true to the existing fleet-stop and fleet-prune
endpoints so the Dry run button rehearses the full code path (locks,
per-node fan-out, remote propagation) without firing the destructive
leaf call. Result flows into the existing ResultsList.

Extend <SheetSection> with optional meta. Add --action-transformative
semantic token.
2026-05-21 11:45:52 -04:00
Anso e65c5e8551 fix(pilot): post-merge audit followups (WS via getProxyTarget, closeTunnel lifecycle, mesh source buffer, docs) (#1128)
* fix(pilot): route remote WS upgrades through NodeRegistry.getProxyTarget

Pilot-mode nodes carry empty api_url and api_token by design and expose
their API on a per-tunnel loopback bridge. The upgrade handler gated the
remote-forwarder branch on `node.api_url && node.api_token`, so WS
requests targeting pilot nodes silently fell through to the local
handlers (live logs, exec, generic) instead of tunneling to the agent.

Resolve the target via NodeRegistry.getProxyTarget so pilot and proxy
modes share one dispatch path, mirroring the HTTP proxy.
handleRemoteForwarder now takes the resolved target and, when the target
is the pilot loopback (empty token), skips the console-token exchange
and the Authorization injection so the tunnel-side auth is the only
source of truth on that path. Unresolvable targets reject the upgrade
with HTTP 503 instead of being served gateway-local data.

* fix(pilot): emit tunnel-down and mark node offline on closeTunnel

PilotTunnelManager.closeTunnel closed the underlying WebSocket but
skipped the cleanup the natural-disconnect path runs, so explicit
closures (enrollment regenerate, node deletion) left the node row at
status='online' until the next reconnect. The dashboard kept showing
the stale state for the entire interval.

closeTunnel now writes nodes.status='offline' and emits tunnel-down for
pilot bridges, and emits proxy-bridge-down for central-initiated proxy
bridges. The maps are cleared before bridge.close() so the natural
'closed' handler's bridge-identity guard short-circuits and we do not
double-emit.

* fix(mesh): buffer cross-node source data until tcp_open_ack arrives

openCrossNode piped src socket data straight to tcpStream.write before
the forward TcpStream emitted 'open'. The first packet on a fresh
cross-node stream raced ahead of the agent's tcp_open_ack on the wire,
which broke protocols that send immediately after connect (HTTP, TLS,
Redis, Postgres) on Pilot and proxy mesh paths.

Buffer src chunks in a local array capped at STREAM_PENDING_DATA_MAX_BYTES
until tcpStream emits 'open', then flush them in order before any
post-open writes. Tear down both sockets if the buffer overflows so a
misbehaving source cannot exhaust gateway memory while waiting for the
ack.

* docs(pilot): clarify host-console non-parity and narrow the parity claim

Pilot mode disables the host-console capability at the capability
registry (the agent container has no useful host shell to surface), but
the public docs listed host console among the WebSockets that ride
through the tunnel and described pilot as behaving identically to proxy
mode. State the shared-capability claim more carefully and call out the
intentional non-parity in a dedicated subsection.
2026-05-21 01:00:47 -04:00
Anso 982c7830b1 fix(mesh): address audit follow-ups (early-data, recompose, admiral gate, error codes, docs) (#1126)
* fix(mesh): buffer early TcpData on reverse-relay path

The reverse-relay code dropped the local-shaped reservation and any
TcpData frames buffered in it before acceptReverseRelay had wired up
the target stream. A peer that sent a request body immediately after
tcp_open_reverse lost those bytes when the target lived on a third
node, since reverse_local buffers them but reverse_relay did not.

Carry the reservation through acceptReverseRelay: transplant its
pendingData and pendingBytes into the new reverse_relay state, gate
TcpData writes on targetOpen, and flush buffered frames into the
target stream before sending tcp_open_ack. Mirrors the reverse_local
pattern. Regression test fires tcp_open_reverse plus an immediate
TcpData while ensureBridge is in flight and verifies the bytes
arrive intact, in order, before the ack.

* fix(mesh): recompose affected stacks when node-level mesh is disabled

disableForNode used to clear DB rows and override files but leave the
running containers attached to sencho_mesh with stale /etc/hosts
alias entries until an operator redeployed every stack by hand.

Mirror optOutStack: after the existing alias/forwarder cleanup, call
regenerateOverridesAcrossFleet, cascadeRecomposeAcrossFleet, and
triggerRedeploy for each previously meshed stack on the disabled
node so containers detach from sencho_mesh and shed the alias
entries they owned. The disabled node's mesh_stacks rows are deleted
before the cascade so listMeshStacks returns the right set with no
skip tuple required. Route threads the actor through actorFor(req)
for parity with optInStack/optOutStack. Tests cover the redeploy
fan-out, the cascade no-skip-tuple invariant, and the default actor
fallback for non-route callers.

* fix(mesh): require Admiral on the WS proxy-tunnel upgrade

HTTP mesh routes in routes/mesh.ts all enforce requireAdmiral, but
the /api/mesh/proxy-tunnel WS upgrade accepted any node_proxy or
full-admin api_token regardless of the receiver's license. A node
downgraded from Admiral kept serving mesh data-plane traffic to a
sibling central while refusing every mesh management call.

Read the receiver's local LicenseService at the upgrade and 403 when
the tier is not paid+admiral. The check sits after the existing
credential gate and uses LicenseService directly rather than
effectiveTier (which trusts forwarded proxy headers); a remote peer
dialing in cannot be trusted to assert our entitlement. Dialer and
node_proxy token format are unchanged. Three regression tests cover
community-tier node_proxy, skipper-tier node_proxy, and
community-tier full-admin api_token all rejected with 403.

* fix(mesh): handle no_target alongside push_failed in inspectStackServices

proxyFetch throws MeshError('no_target') when getProxyTarget returns
null (pilot tunnel offline, proxy bridge unreachable), but the
inspectStackServices catch branch only matched push_failed. Offline
remotes fell through to the generic 'remote unreachable' error log,
which the Routing tab surfaces as an unexpected fault.

Match both error codes and emit the operator-friendly warn message
that names the unreachable node and the error code. Regression test
spies on console.warn/console.error to pin the branch.

* docs(mesh): align env defaults and forwarder comments with current architecture

SENCHO_MESH_PROXY_TUNNEL_IDLE_MS in .env.example carried the old
five-minute idle-close value (=300000), but the code default is
DEFAULT_IDLE_TTL_MS=0 (persistent tunnel). Copying the example
silently reintroduced the idle-close behavior the dialer removed.

MeshForwarder.ts's leading docblock and inline listen comment still
described host-network mode plus extra_hosts: host-gateway as
required for forwarder reachability. Sencho runs in standard bridge
mode and attaches to the shared sencho_mesh network at a stable IP;
meshed user containers reach the forwarder by that IP directly.

Flip the env default to 0, rewrite the env comment to describe the
persistent behavior and the opt-in for idle teardown, and rewrite
both forwarder comments to match the bridge-network reality.

* fix(mesh): trust forwarded tier on proxy-tunnel WS and remove remote overrides on disable

Admiral entitlement on the WS data plane now follows the same trust model
as the HTTP mesh routes: the central asserts its tier via x-sencho-tier
and x-sencho-variant on the WS handshake, and the receiver trusts those
headers only when the upgrade carries a node_proxy credential. When no
headers are present or the credential is a full-admin api_token, the
receiver falls back to its own local license. Without this an Admiral
central could be rejected by a Community remote and a Community central
could dial a locally-Admiral remote.

disableForNode now routes through removeOverrideFromNode so override
files pushed earlier via applyLocalOverride are removed on remote nodes
via DELETE /api/mesh/local-override/:stack. Sequential awaits are wrapped
in Promise.allSettled to match regenerateOverridesForNode's parallel
push pattern.

Other changes:
- Cover the post-state-swap buffering window in the reverse-relay test
  (TcpData arriving after openTcpStream returns but before target open).
- Refresh stale MeshService comments that still referenced the removed
  sidecar layer and host-network listener model.

* test(mesh): pin removeOverrideFromNode remote HTTP shape

The disableForNode regression test mocks removeOverrideFromNode itself,
so a regression inside the helper would not be caught. Add a narrow
contract test that spies on global fetch and asserts the request shape:
DELETE /api/mesh/local-override/:stack against the resolved proxy
target, with Authorization Bearer plus the x-sencho-tier and
x-sencho-variant headers. Also covers the encodeURIComponent path and
the swallowed-network-error behavior the disable cascade relies on.

* test(mesh): use createTestApiToken helper in proxy-tunnel api_token gate test

The full-admin api_token branch of the WS Admiral gate test inlined the
canonical generateApiToken + sha256 + addApiToken triple that already
lives in the createTestApiToken helper. Switching to the helper removes
the duplicated insertion logic and aligns this test with the helper used
by the other api_token call sites.
2026-05-20 12:53:29 -04:00
Anso ee891b093b fix(self-update): resolve /app/data through bind or named volume (#1124)
SelfUpdateService scanned the container's Mounts list for Type='bind'
only when picking the host path to forward to the helper container.
A pilot enrolled with the recommended Docker Compose snippet uses a
named volume (sencho-agent-data:/app/data, Type='volume'), so the
lookup returned null and the boot log read "/app/data mount not found
- update error recovery will be unavailable". The helper still ran
on update but could not persist UPDATE_ERROR_FILE on a failed pull,
so the next gateway process had nothing to surface.

Extract findDataDirHost(mounts) and accept Type='bind' or
Type='volume'. Docker populates Source with the on-disk volume
directory for either type, so the existing :rw bind in the helper
spawn works unchanged. The independent hostBindMounts filter stays
strict to Type='bind' (forwarded operator-declared compose paths
only; named volumes are not in scope there).

Tests: pure unit coverage for the helper across 6 cases (bind hit,
volume hit, no /app/data, mixed list with siblings, empty Source,
tmpfs ignored).
2026-05-20 03:36:08 -04:00
Anso 0b50c88eb3 fix(fleet): route remote update trigger through getProxyTarget (#1123)
POST /api/fleet/nodes/:id/update and POST /api/fleet/update-all read
node.api_url + node.api_token directly, so a pilot-agent row (which
carries neither) returned "Remote node not configured." and was
filtered out of bulk update. Both routes now resolve the target via
NodeRegistry.getProxyTarget(), which returns the loopback URL for an
active pilot tunnel and the configured api_url for proxy-mode remotes.
fetchMetaForNode replaces the direct fetchRemoteMeta call for the
self-update capability check.

self-update is removed from PILOT_DISABLED_CAPABILITIES so a
Compose-deployed pilot can advertise it; the host-console filter
stays. SelfUpdateService still gates the local-end capability on the
container actually carrying docker-compose labels, so a docker-run
pilot self-disables and the Fleet UI sees a clean 503 instead of an
ambiguous failure.

Tests: new fleet-pilot-update covers the four single-node branches
(success via loopback, null target, no self-update capability, meta
offline) and two update-all cases (mixed-fleet dispatch, all-targets-
null skip). capability-registry-pilot rewired to assert the new
filter set.
2026-05-20 03:35:39 -04:00
Anso e05099f2a1 fix(fleet-sync): make control-identity-mismatch sticky and surface in UI (#1117)
Treat 409 CONTROL_IDENTITY_MISMATCH from a replica as a non-retriable
failure instead of looping the same 409 through the 5-minute retry
service forever and silently writing identical failure rows.

Backend
- DatabaseService: add `sticky_error_code`, `sticky_error_expected`,
  `sticky_error_got` columns to `fleet_sync_status` via an idempotent
  migration. New methods setFleetSyncSticky, getFleetSyncStickyCode,
  clearFleetSyncStickyForNode. recordFleetSyncSuccess clears the sticky
  flag on a clean push. getFailedSyncTargets SQL adds
  `AND sticky_error_code IS NULL` so the retry loop skips sticky rows.
- FleetSyncService.executePushToNode: short-circuits at the top when
  sticky is set (covers event-driven pushResourceAsync calls). On a 409
  with code CONTROL_IDENTITY_MISMATCH, records the failure once and
  pins sticky with the expected/got fingerprints carried in the 409 body.
- routes/nodes.ts: new POST /api/nodes/:id/fleet-sync/reset-anchor.
  Admin + paid + node:manage. Proxies POST /api/fleet/role/reanchor to
  the peer with `{override:true}` using the stored Bearer node_proxy
  token. On peer 200, clears every sticky row for the node so the next
  push re-anchors and resumes replication. Distinct 502 / 504 responses
  for peer-rejected / peer-unreachable so the UI can show a useful toast.

Frontend
- New lib/fleetSyncApi.ts + hooks/useFleetSyncStatus.ts. Polling hook
  (30s visibilityInterval) skips fetch when !isPaid.
- NodeManager.tsx: destructive banner per affected node listing both
  fingerprints, with `Reset anchor on peer` and `Remove node` buttons.
  Hidden for community-tier users via empty hook data.
- FleetConfiguration.tsx (Fleet -> Status): read-only `Policy sync`
  SummaryRow per remote node card. In sync / degraded / paused with
  a tooltip; no action buttons (the action lives in NodeManager).

Tests
- fleet-sync-service.test.ts: 4 new cases for sticky-set on first
  mismatch, short-circuit on subsequent pushes, null fingerprints,
  and non-mismatch failures not setting sticky.
- database-fleet-sync-sticky.test.ts (new): 6 cases pinning the DB
  contract incl. retry-loop SQL filter and migration idempotency.
- nodes-fleet-sync-reset-anchor.test.ts (new): 6 cases covering
  happy path, peer 401 -> 502, peer unreachable -> 504, local-node
  rejection, unknown node id, and community-tier 403.

Gate parity (Directive 30): the new POST .../reset-anchor enforces
requireAdmin + requirePaid + node:manage (matches the existing read at
GET /api/fleet/sync-status). UI banner + SummaryRow only render when the
hook returns data, which it only does for paid-tier authed users. No
existing tier-gate file moved; this is greenfield parity.

Auth audit: the peer's POST /api/fleet/role/reanchor route already uses
requireAdmin, which accepts the central's stored node_proxy Bearer
token because authMiddleware maps `scope === 'node_proxy'` to
`req.user = { username: 'node-proxy', role: 'admin', userId: 0 }`.
No widening required.

Backend tsc clean. Frontend tsc -b clean. 59 fleet-sync tests pass; full
backend suite green minus the pre-existing Windows-only file-lock flake
on filesystem-backup.test.ts that reproduces unchanged on main.
2026-05-19 19:49:16 -04:00
Anso 9dbce9c3c7 fix(spawn): attribute ENOMEM and ENOENT-under-memory-pressure spawn failures to host OOM (#1111)
Operators previously saw "spawn docker ENOENT" or "spawn /bin/sh ENOENT" when
the host was under memory pressure, which sent them down a missing-binary
debugging path. Linux libuv's posix_spawn can fail to allocate its argv /
path-search arena under low free memory and surface the underlying ENOMEM
as ENOENT.

Centralizes spawn-error mapping in a new utils/spawnErrors.ts helper:
- Explicit ENOMEM is rewritten to "Out of memory while launching <command>
  (host free memory: X MiB of Y MiB)".
- ENOENT under the 128 MiB free-memory floor is rewritten with the same
  wording plus a "reported as ENOENT under memory pressure" hint.
- ENOENT for docker on a healthy host preserves the existing
  "Docker CLI unavailable on this node" mapping.
- Other errors pass through unchanged.

Applied at the four named offenders: ComposeService.execute(),
ComposeService.captureCompose(), DockerController.getContainersByStack(),
and FileSystemService.getStacks() (which gets an ENOMEM-aware log line
for the scandir failure).

Startup also logs host free/total MiB once and warns when free memory is
below the 128 MiB floor, so the diagnostic surfaces before the first
spawn attempt rather than after it fails.

37 tests cover the mapping function directly and the ComposeService /
FileSystemService integration paths.
2026-05-19 07:27:12 -04:00
Anso 523ba5854c fix(stacks): return 404 for nonexistent stacks on deploy/down/update (F-7) (#1108)
POST /api/stacks/:name/{deploy,down,update} previously returned HTTP 500
with body {"error":"spawn docker ENOENT"} when invoked against a stack
whose compose directory was missing. The status code was wrong (the
named resource did not exist, so 404 is the right answer) and the
message misled operators into thinking the docker CLI was unavailable.

Add a small requireStackExists(nodeId, stackName, res) helper in
routes/stacks.ts that validates the stack name and confirms a compose
file is present via FileSystemService.hasComposeFile before any of the
three handlers spawn docker compose. The helper is called immediately
after requirePermission and before runPolicyGate so unauthorized
callers still get 403 first and the policy gate never runs against a
phantom stack.

In ComposeService.execute(), narrow the child.on('error') handler so
the genuine docker-binary-missing case (ENOENT on the spawn itself)
rejects with "Docker CLI unavailable on this node" instead of the raw
"spawn docker ENOENT". This is defense in depth for the rare case the
pre-check cannot cover, and it fixes the misleading-message half of
the bug as well.

Cover the new contract with stack-actions-missing-stack.test.ts (four
cases: deploy/down/update return 404, invalid name returns 400). Mock
ComposeService as a tripwire so a future code path that bypasses the
guard would fail loudly. Fix stacks-failure-notifications.test.ts by
adding hasComposeFile to its FileSystemService partial mock so the
existing happy-path-error-handling cases continue to flow into
ComposeService.
2026-05-19 00:13:57 -04:00
Anso 0cffd60173 fix(mesh): clean up activeStreams on src socket close/error (F-10) (#1105)
The cross-node MeshService.openCrossNode handler attached src.on('close')
and src.on('error') listeners that only called tcpStream.destroy() and
never deleted the activeStreams Map entry. MeshTcpStreamLike.destroy()
sends a tcp_close frame to the remote but does not synchronously emit
the local close event; that fires only when the remote sends back a
tcp_close ack via _dispatchClose (or when the entire tunnel tears down
and the bridge force-emits close on every stream).

Failed dials whose remote ack was lost (peer gone, network drop, or the
F-9-era timeouts) therefore leaked records into activeStreams until the
tunnel itself idle-closed, producing the monotonically-climbing
activeStreamCount documented in the mesh E2E audit (F-10).

Add a cleanupRecord() closure that clears the open timer and deletes
the record idempotently, then route all four lifecycle handlers
(tcpStream.on('error'), tcpStream.on('close'), src.on('close'),
src.on('error')) through it. Map.delete is naturally idempotent so the
double-delete that fires when both sides close normally is a free
no-op. The same-node openSameNode path already had this shape via its
existing teardown() closure; this brings the cross-node path to parity.

Three new Vitest cases in mesh-service.test.ts use an EventEmitter-
backed fake src so emit('close') actually delivers to the listener
(the existing makeFakeSocket uses vi.fn for .on which records calls
but never fires them). They cover src.close, src.error, and the
idempotency case where both src and tcpStream emit close in sequence.
2026-05-18 22:08:06 -04:00
Anso c460bb87a8 fix(mesh): probe upstream synchronously in route diagnostic (F-11) (#1100)
GET /api/mesh/aliases/:alias/diagnostic returned a cached state derived
from the last latency/error maps. Those maps only mutated when someone
called POST .../test or when a cross-node connect logged an event, so
once an upstream stopped the diagnostic kept reporting "healthy" until
the 60 s alias-cache refresh pruned the alias entirely.

The GET now calls testUpstream synchronously after the alias-resolved,
opted-in, tunnel-up short-circuits. Probe failures land in routeErrorMap
via logActivity (cross-node already did this; same-node timeout/error
paths now log probe.fail in the same shape), so the state computation
flips to "unreachable" on the same request that exposed the stopped
upstream. A new routeProbeAtMap stamps freshness and surfaces in the
response as lastProbeAt; the route detail sheet renders "Last probe
<age> · <ms>" using the existing formatTimeAgo helper.
2026-05-18 16:48:09 -04:00
Anso 554f662563 fix(mesh): surface stopped-stack opt-ins on routing node cards (#1098)
Add `currentlyResolvable: boolean` per entry in `MeshNodeStatus.optedInStacks`,
derived from the existing alias cache so the new field stays consistent with
`/api/mesh/aliases` without any extra Dockerode or cross-node inspect on the
status path. The Routing tab renders an amber `suspended` pill for entries
whose stack is opted in but currently has no running services, plus a single
explanatory caption below the suspended list.

Resolves the contradictory `Mesh stacks: 1 / Aliases: 0 / No mesh services
on this node yet` copy on the node card when a meshed stack's container has
been stopped; the misleading line is now only shown when the node truly has
no opt-ins. The opt-in itself remains sticky: when the stack starts again,
its aliases reappear automatically on the next refresh.

Defensive de-dup in the UI filters suspended entries against the live alias
snapshot to handle the transient gap where `/mesh/status` and `/mesh/aliases`
return slightly inconsistent views from their separate fetches.

Tests:
- new `mesh-status-resolvability.test.ts` (6 cases) locks the resolvable /
  suspended / mixed / empty / per-node-scoping / stale-alias-no-phantom
  invariants for `getStatus`.
- `mesh-topology-layout.test.ts` gains a `stacksKey` resolvability-flip case
  and a `meshNodeStateEqual` case asserting a resolvability flip on an
  otherwise identical stack registers as a state change so the topology
  layout re-runs.

Operator docs gain one new troubleshooting accordion in
`/docs/features/sencho-mesh.mdx` explaining the suspended state.
2026-05-18 01:52:18 -04:00
Anso f6e42535c8 fix(mesh): route peer→central traffic over the existing forward WS (#1094)
* fix(mesh): route peer→central traffic over the existing forward WS

The reverse mesh callback path (`/api/mesh/proxy-tunnel-from-peer`) needed
SENCHO_PRIMARY_URL on central plus a publicly reachable origin from the
peer's perspective. In a typical homelab where central sits behind NAT,
peer→central dispatch silently failed at the dialer's short-circuit and
the headline "call any service on any node by hostname" worked one way
only.

The forward WS at `/api/mesh/proxy-tunnel` is already bidirectional end
to end. Make the bridge a persistent control-plane primitive: dial every
mesh-enabled proxy peer at startup, reconcile every 60 s, never idle-close.
Peer→central traffic multiplexes over the same WS via `tcp_open_reverse`.

Removed:
- `meshProxyTunnelFromPeer.ts` WS handler and dispatch
- `MeshCentralRegistry`, `PeerToCentralMeshSessionDialer`
- `mesh_handshake` first-frame state machine in `meshProxyTunnel.ts`
- `maybeSendBootstrap`, `buildHandshakeFrame` in the dialer
- `mesh_proxy_callback_bootstrap` capability and `maybeWarnUnsetPrimaryUrl`
- `mesh_centrals` table (drop migration; greenfield, no users)
- `PilotTunnelManager.replaceOrRegisterProxyBridge` (dead after handler removal)
- twelve associated unit/integration tests plus the peer-recovery branch
  in `MeshService.openCrossNode`

Added:
- `MeshService.proactiveBridgeFanout` selects every mesh-enabled proxy
  peer (no longer gated on `mesh_stacks` rows)
- `startBridgeReconcileLoop` runs the fanout every 60 s (override via
  `SENCHO_MESH_RECONCILE_INTERVAL_MS`)
- `MeshProxyTunnelDialer` default idle TTL is now `0` and exposes
  `isDialing(nodeId)` for the status surface
- `MeshNodeStatus.reverseCallbackStatus` discriminator
  (`connected | connecting | unavailable | not_applicable`) surfaced via
  `/api/mesh/status` and rendered as a pill in the Routing tab
- `openCrossNode` error message distinguishes "no proxy target" from
  "waiting for central to dial the reverse bridge"
- New tests: `mesh-service-proxy-tunnel-reconcile`,
  `mesh-status-reverse-callback`, `mesh-proxy-tunnel-dialer-no-idle-close`

SENCHO_PRIMARY_URL is no longer required for any mesh function.

* fix(mesh): rewrite proxy-tunnel reconcile test contents

The previous commit renamed the file but the rewritten test bodies stayed
unstaged on top of the rename. This commit lands the actual rewrite: the
fanout assertion now requires every mesh-enabled proxy peer to be dialed,
not just those with `mesh_stacks` rows, and adds a reconcile-tick
repeated-call test.
2026-05-17 22:00:31 -04:00
Anso 1d7418a233 fix(mesh): recompose previously-meshed containers when alias set changes (#1090)
When a stack is opted into the mesh, every previously-meshed stack's
override.yml on disk gains the new alias, but the running containers
keep their stale /etc/hosts because extra_hosts is read at container
creation time. Cross-stack DNS for the new alias silently fails until
an operator manually redeploys each prior stack.

Complete the cascade: after regenerateOverridesAcrossFleet finishes,
fan out triggerRedeploy across every (node_id, stack_name) tuple in
mesh_stacks, skipping the just-opted-in pair (the explicit
triggerRedeploy at the end of optInStack covers it). Mirror the same
cascade in optOutStack so the dropped alias exits every container's
/etc/hosts.

triggerRedeploy already dispatches local vs remote, already wraps
runRedeploy in fire-and-forget error logging to the mesh activity
ring and audit log, and already enforces compose policy gates. The
cascade just reuses it.

regenerateAllOverrides (boot and manual /regen-overrides) is
intentionally NOT covered: a Sencho restart must not force a
fleet-wide recompose; override files alone are sufficient there.

Adds four unit tests in mesh-service.test.ts: cascade fires for every
prior meshed stack on opt-in, no double-redeploy of the just-opted-in
tuple, cascade is a no-op when no peers exist, cascade fires for
every survivor on opt-out.
2026-05-17 19:02:02 -04:00
Anso 578cac89da fix(mesh): surface data-plane failures in health, meta, and Routing tab (#1088)
The three previously-silent console.warn paths in MeshService.setupMeshNetwork
now route through a typed recordSetupFailure helper that classifies the
failure (subnet_invalid, subnet_overlap, subnet_mismatch, ip_in_use,
attach_failed, not_in_docker), emits a mesh.disable activity entry at the
matching level (error for real failures, warn for the expected dev-mode
not_in_docker case), and strips mesh_proxy_callback_bootstrap from
advertised capabilities via CapabilityRegistry.

/api/health gains a mesh.dataPlane block carrying the typed status.
/api/mesh/status carries localDataPlane at the top level so the Routing tab
renders a red banner with an operator-actionable recovery hint (set
SENCHO_MESH_SUBNET to a free /24 and recreate the container) when the data
plane is down. The success path re-enables the capability and flips the
status to ok.

Generalizes the previously-documented F-0 failure mode (IP-in-subnet
collision) to also cover the subnet-pool-overlap case where another Docker
bridge on the host already owns the requested CIDR.
2026-05-17 17:31:57 -04:00
Anso a318e6b3c1 fix(mesh): close data-plane race that dropped early TcpData frames (#1086)
The tcp_open and tcp_open_reverse receivers registered their stream
entry only after awaiting resolveTarget / resolveContainerIp. The
peer, which is free to send TcpData immediately after the open frame,
hit the lookup-miss path in handleBinaryFrame and the bytes were
silently dropped. Probes passed because they only exercise the
handshake; real HTTP hung with 0 bytes received.

Reserve the stream entry synchronously, buffer early TcpData in a
per-stream pendingData queue capped at 1 MiB, and flush onto the
local socket inside the connect handler before sending tcp_open_ack.
The payload is copied because decodeBinaryFrame returns a subarray
view of the WS receive buffer; holding the view would pin the parent
buffer past its lifecycle.

Both sides of the data plane are patched:
- tcpStreamSwitchboard.onTcpOpen (forward, agent + proxy-mode peer)
- PilotTunnelBridge.handleTcpOpenReverse / acceptReverseLocal
  (reverse, primary acting as the local dial target)

Adds unit coverage for the race window and for the overflow path.
The cap constant lives in pilot/protocol.ts alongside the other
per-stream limits.
2026-05-17 14:01:39 -04:00
Anso 50b89db3b8 chore(mesh): drop mesh_stacks table on pilot-mode DBs (#1085)
Per the C-3 design, mesh state lives on central. Pilots learn aliases
via the D-1 override push and hold them in `pilotAliasOverlay`. The
local `mesh_stacks` table on pilots has been dead since C-3 shipped.

This is a greenfield cleanup (Directive 20):

- `migrateMeshTables()` runs `DROP TABLE IF EXISTS mesh_stacks` when
  `SENCHO_MODE === 'pilot'` and skips the CREATE. Central-mode
  behavior is unchanged.
- The four mesh CRUD methods (`listMeshStacks`, `isMeshStackEnabled`,
  `insertMeshStack`, `deleteMeshStack`) short-circuit on the same
  check. Reads early-return empty/false; writes early-return;
  `insertMeshStack` also warns so an accidental pilot-side write is
  loud.
- New `isPilotMode()` helper mirrors the existing one in
  `bootstrap/startup.ts`. Layering rules forbid the shared import; a
  third call-site would justify extraction to `helpers/`.

Operator-visible behavior is unchanged on either side. On central, the
table and its rows are untouched. On pilots, the table is removed (it
held no rows in steady state) and any caller of the CRUD methods
continues to see empty list / false / no-op without touching SQLite.

Test plan

- `npx tsc --noEmit`: clean.
- New `database-service-pilot-mesh-stacks.test.ts`: 5/5 green (table
  absence + four short-circuit assertions).
- `mesh-service.test.ts`, `mesh-diagnostic-local.test.ts`, and
  `mesh-service-proactive-bootstrap-fanout.test.ts`: 58/58 green.
- Full backend suite: 2280/2281; the single failure is a pre-existing
  Windows EBUSY flake in `filesystem-backup.test.ts` that reproduces
  on a clean tree.
- Code reviewed; findings applied.
2026-05-17 05:25:00 -04:00
Anso 5bc4add099 refactor(mesh): retarget proxyFetch null-target throw to no_target (#1083)
`MeshService.proxyFetch` previously threw `MeshError('push_failed', ...)`
when `NodeRegistry.getProxyTarget(nodeId)` returned null, but semantically
no push was attempted: the target simply did not exist. Callers had to
special-case the code and explain the overload in a comment.

Retarget the throw to the existing `'no_target'` variant in
`MeshErrorCode`. `listStacksOnNode` matches the new code directly; the
explanatory comment is gone since the code reads for itself.

`MeshError('push_failed', ...)` continues to signal real outbound push
failures: `applyLocalOverride` (mesh data plane not yet ready on the
receiving node) and `pushOverrideToNode` (HTTP 404 from an outdated
remote, non-OK HTTP response). Those sites are unchanged.

Operator-visible behavior is unchanged in the steady state. The rare
"remote disappeared between opt-in start and override push" case now
returns HTTP 400 with `code: 'no_target'` instead of 503; the response
body carries a clear message ("no proxy target for node N") so clients
can disambiguate.

Test plan
- `npx tsc --noEmit`: clean.
- `npx vitest run src/__tests__/mesh-list-stacks-remote.test.ts`: 8/8 green
  (6 existing + 2 new cases pinning the catch-arm semantics).
- `npx vitest run src/__tests__/mesh-service.test.ts src/__tests__/mesh-inspect-remote.test.ts`:
  56/56 green.
- Code reviewed; findings applied.

Notes
- PR #1082 (refactor: route inspectStackServices through proxyFetch) needs
  a rebase + one-line update to its catch arm before merging on top of
  this change.
2026-05-17 05:06:58 -04:00
Anso dcc43205e0 refactor(mesh): route inspectStackServices through proxyFetch (#1082)
inspectStackServices was constructing its Authorization, x-sencho-tier,
and x-sencho-variant headers inline. proxyFetch is the centralized
helper used by listStacksOnNode, pushOverrideToNode, triggerRemeshRedeploy,
and removeOverrideFromNode. Equivalent today for a GET-no-body call, but
any future header added to proxyFetch (audit context, license fingerprint,
request id) would silently miss inspectStackServices.

Wire output is byte-identical: same URL, same method, same three headers,
same 5 s timeout. The new catch arm converts MeshError('push_failed') back
to the existing warn + return [] contract callers (optInStack,
refreshAliasCache) rely on. Pattern mirrors listStacksOnNode.
2026-05-17 05:06:45 -04:00
Anso a32183d198 fix(mesh): cascade override regen across all meshed nodes on opt-in/opt-out (#1079)
Opting a stack into mesh on node A now propagates the new alias to every
meshed node's override file, not just stacks on node A. Same on opt-out.

Pre-fix, optInStack and optOutStack called regenerateOverridesForNode
which only walks db.listMeshStacks(nodeId). Other meshed stacks on other
nodes did not learn about the new alias until something else re-pushed
their overrides; operators worked around it with a manual
POST /api/mesh/regen-overrides plus a redeploy of each affected stack.

New regenerateOverridesAcrossFleet helper iterates db.listMeshStacks()
(no arg, fleet-wide) under Promise.allSettled, skipping the
(nodeId, stackName) tuple opt-in just pushed loudly. Per-stack failures
surface as forwarder.error activity events, matching the pattern used by
regenerateAllOverrides. Offline remote nodes leave stale overrides until
the next opt-in/opt-out, the next tunnel reconnect, or a manual rerun of
the regen-overrides endpoint.

regenerateOverridesForNode stays for the tunnel-up retry listener: a
reconnecting pilot only needs its own stacks re-pushed, not the fleet.

Tests: redirect five existing spies in mesh-service.test.ts to the new
method; add two new cases asserting cross-node cascade for opt-in and
opt-out. 52 tests in mesh-service.test.ts pass.
2026-05-17 03:48:58 -04:00
Anso 6b728599c4 fix(mesh): persist PilotMetrics counters across central restart (#1078)
The 12 PilotMetrics counters (proxy_dials_failed, proxy_bridges_total,
proxy_idle_closes, the pilot-tunnel counters, and the peer-to-central
callback counters) live in a singleton holding scalar fields, so process
restart wipes them. Operators investigating rotation- or dial-failure
trends across a central restart lost the aggregate metric.

Persist the snapshot under a pilot_metrics_counters JSON row on the
existing system_state KV table. Hydrate via PilotMetrics.load() in
bootstrap/startup.ts before any service that increments runs; final-flush
in bootstrap/shutdown.ts before the SQLite handle closes. Between those
boundaries a buffered flush mirrors the audit-log pattern: every 1 s or
every 100 increments, whichever fires first. Hot increment path stays a
single property write plus a pending-counter check.

Defensive parse on the row: object check + numeric filter, so an operator
hand-edit or schema drift across releases degrades to zero state with a
warn rather than crashing boot. Schema additions back-fill missing keys
with zero so a release that adds a counter does not need a migration.

Closes F-R-4 from the mesh tracker.
2026-05-17 01:55:17 -04:00
Anso 7e3e22138b fix(mesh): name actual bridge kind in proxy-bridge rejection (#1075)
* fix(mesh): distinguish proxy bridge from pilot tunnel in registerProxyBridge rejection

When central's MeshProxyTunnelDialer dials a node whose slot is already
held by a peer-initiated proxy bridge, the registration is correctly
refused, but the activity-log message read "pilot tunnel already
registered for node N; proxy bridge refused" regardless of which kind
of bridge was actually in the slot. No pilot tunnel exists in
proxy-mode remotes by definition, so the wording pointed operators at
the wrong subsystem.

Use the existing bridgeKinds index (already consulted correctly by the
sister method replaceOrRegisterProxyBridge) to emit a kind-accurate
message: "proxy bridge already registered for node N; concurrent dial
refused" when the existing bridge is a peer-initiated proxy bridge,
keeping the original "pilot tunnel" wording for the pilot-agent case
so existing log filters and the sister test still match.

Pure cosmetic; the rejection behavior is unchanged.

New regression test asserts the discriminator in both directions. Also
tightens the test-fixture beforeEach to clear bridgeKinds alongside
bridges so future tests cannot read a stale kind.

* chore: ignore local trailer directory in git repository

* refactor(mesh): centralize bridge-kind type and conflict-message formatter

Followup tidy on PilotTunnelManager after /simplify flagged two related
items in the F-R-2 change:

- Extract `BridgeKind = 'pilot' | 'proxy'` as a module-scoped type alias
  so the kind taxonomy lives in one place. `bridgeKinds` and
  `injectBridgeForTest` now consume it; the inline union literal is
  gone.
- Extract `formatBridgeConflict(nodeId, existingKind)` as a private
  helper. Both `registerProxyBridge` and `replaceOrRegisterProxyBridge`
  call it from their "slot already held" rejection paths, removing the
  byte-identical "pilot tunnel already registered for node N; proxy
  bridge refused" string copy across the two sites.
- Trim the 6-line WHAT comment above the rejection throw to a 2-line
  WHY so the call site stays scannable.

No behavior change; the three relevant test suites stay green.
2026-05-17 01:16:21 -04:00
Anso 0b4cf90bf1 fix(mesh): peer-initiated callback bridge installs reverse dialer (#1071)
* fix(mesh): peer-initiated callback bridge installs reverse dialer

The R1-A2 symmetric-dial work added a peer-side dialer that opens a
callback WS to central at `/api/mesh/proxy-tunnel-from-peer`. The WS
upgrade, JWT validation, and `mesh_centrals` persistence all worked,
but the peer-side handler put the wrong object on its end of the pipe:
a `PilotTunnelBridge` (the central-role object) rather than a
`TcpStreamSwitchboard` with a registered `reverseDialer`.

The design doc states: "Central retains PilotTunnelBridge ownership;
peer retains TcpStreamSwitchboard + reverseDialer ownership." The
central-initiated handler at `meshProxyTunnel.ts:115-163` honors this.
The peer-initiated handler at `PeerToCentralMeshSessionDialer.attachBridge`
did not. It created a `PilotTunnelBridge(0, ws)` and stored it in a
private `currentSession` field. Result: `MeshService.reverseDialer`
stayed null, `dialMeshTcpStream` fell through to
`PilotTunnelManager.ensureBridge(target.nodeId)`, and `NodeRegistry.
getProxyTarget` on the peer returned null because proxy-mode peers
do not enroll their central. Cross-fleet dispatch from a peer
container failed with `proxy-tunnel.open.fail nodeId=<central>
reason=no_target` even though the callback bridge was up and healthy
(`bridgeOpen: true`, `last_used_at` populated).

Replace the peer-side `PilotTunnelBridge` with the same wiring the
central-initiated handler uses:

  - `attachTcpStreamSwitchboard` with `resolveByComposeLabels` (allows
    inbound `tcp_open` from central if it ever uses the callback
    bridge for a reverse-direction dispatch; matches the central-
    initiated pipe's resolver).
  - A `SwitchboardReverseDialer` that delegates `openMeshTcpStream`
    to `switchboard.openReverseStream`.
  - `MeshService.setReverseDialer(localDialer, null)` with the same
    CAS guard the central-initiated handler uses, so a concurrent
    central-initiated tunnel does not get silently overwritten.
  - `ws.on('message', onMessage)` dispatches JSON / binary frames to
    the switchboard.
  - `ws.once('close'/'error', teardown)` clears the switchboard, the
    reverseDialer, and the `currentSession` reference idempotently.

The `currentSession` field type changes from `PilotTunnelBridge | null`
to `TcpStreamSwitchboard | null`. Callers in `MeshService.openCrossNode`
only use the return value for truthiness; the cast is harmless. The
public `hasSession()` signature is unchanged.

A new `currentWs` field holds the WS reference so `resetForTest` can
close the connection on teardown (the switchboard's own `cleanup`
does not close its WS).

Test update mirrors the production wiring: the existing
"marks the row used on successful WS open" case now asserts the
return value is a `TcpStreamSwitchboard`, that `MeshService.reverseDialer`
is non-null after `ensureSession`, and explicitly closes the WS in
the test's finally so `wss.close` can resolve.

Pre-existing test logic that asserted `bridge instanceof PilotTunnelBridge`
on the same path is removed in favor of the new switchboard assertion.

Discovered during the v0.81.1 live verification: the peer-side
`route.dispatch cross-node` event always paired with
`proxy-tunnel.open.fail no_target` and `tunnel.fail`, even though
`centralCallback.bridgeOpen: true` and `mesh_centrals.last_used_at`
were populated. The callback path opened cleanly in isolation but
the dispatch path never consumed it. This fix makes the dispatch
path consume it.

Tests:
  - cd backend && npx tsc --noEmit          clean
  - cd backend && npx vitest run mesh        170/170 green
  - cd backend && npx vitest run             2259/2268 green; only
    failures are the pre-existing Windows EBUSY flake in
    filesystem-backup.test.ts (documented in prior handoff)

* fix(mesh): drop unused imports and align reverse-dialer interface name

CI lint failures on the previous push were two @typescript-eslint/no-unused-vars
errors in the test file:

  24:76  'vi' is defined but never used
  31:5   'TcpStreamSwitchboardCtor' is assigned a value but only used as a type

Both were collateral from a local-debugging simplification: vi.waitFor was
swapped for a synchronous assertion when the test's wss.close hang was traced
to an unclosed client WS; the toBeInstanceOf assertion that used the runtime
binding was removed at the same time. Restore the instanceof assertion next
to the existing not.toBeNull check so the runtime binding is in use and the
test guards against future regressions where the wrong end-of-pipe object
type is returned on the callback bridge (the bug this PR fixes). Drop the
now-unused `vi` symbol from the vitest import.

Also fold in a /simplify convergent finding: rename the local
CallbackReverseDialer interface to SwitchboardReverseDialer to match the
sibling declaration in meshProxyTunnel.ts:72. Same shape, same name; readers
of either handler now see the same mental model. Cross-file extraction of
the interface to tcpStreamSwitchboard.ts is the cleaner long-term shape but
touches a file outside this PR's scope; filing as a follow-up.

Considered-and-deferred findings from the /simplify pass (file as separate
PRs to keep one-branch-one-concern):
  - extract shared attachSwitchboard helper (~40 duplicated lines between
    meshProxyTunnel.ts and PeerToCentralMeshSessionDialer.ts)
  - public TcpStreamSwitchboard.getWs accessor + drop currentWs field
  - public MeshService.hasReverseDialer for the test bracket-cast
  - shared close-reason constants (drift risk across the two handlers)

Tests:
  - cd backend && npx tsc --noEmit          clean
  - cd backend && npx eslint <changed>      clean (lint_exit=0)
  - cd backend && npx vitest run            mesh 170/170, dialer 8/8 green
2026-05-16 20:11:31 -04:00