Skip to content

Flatten repo layout, authenticate the sidecar API, and add its first tests - #13

Merged
blakebauman merged 13 commits into
mainfrom
chore/flatten-repo-layout
Sep 7, 2026
Merged

blakebauman merged 13 commits into
mainfrom
chore/flatten-repo-layout

Conversation

@blakebauman

Copy link
Copy Markdown
Owner

Started as a repo reorganisation and grew into the adjacent work that fell out of it. Thirteen commits, each self-contained and individually reviewable.

The security fix is the one to read first.

Repo layout

tauri/ held a second Vite app that duplicated app/ almost line for line — index.html, vite.config.ts, tsconfig.json, main.tsx, and the dependency list — differing only in paths aliased back into app/src. Its declared "@deckhand/app": "workspace:*" was never imported by name; resolution went through relative paths and the @ alias. The only irreplaceable thing under it was src-tauri/.

The duplication also caused bugs. Both configs hardcoded strictPort: 1420, so either could claim the port and serve a Deckhand-looking app. That is why scripts/tauri-frontend-dev.sh needed 50 lines of lsof cwd forensics, and why tauri/vite.config.ts threw on deckhand copy paths. Both guards are gone; one Vite config cannot collide with itself.

Now the canonical Tauri shape — src/, index.html, vite.config.ts, src-tauri/ at the root, sidecar/ unchanged as a separate Go module. Workspaces dropped, so package.json is the only manifest and bun.lock is simply the lockfile.

Sidecar authentication

The sidecar served Access-Control-Allow-Origin: * with no authentication of any kind on a loopback TCP port. Any web page visited while Deckhand was running could drive the Docker daemon — POST /api/docker/containers with a bind mount of / is host root — and read the responses back. The ephemeral port used in Tauri mode is obscurity, not a control.

It now mints a 32-byte token per launch and requires it as Authorization: Bearer or ?token=. Both transports are necessary, not belt-and-braces: EventSource, WebSocket, and window.open cannot set headers, and the events, logs, exec, stats, and volume-export endpoints are driven through exactly those.

CORS mirrors auth rather than using an origin allowlist — Origin is echoed only after the token is accepted. An allowlist was rejected because the Tauri webview origin differs per platform (tauri://localhost on macOS, http://tauri.localhost elsewhere) and a wrong guess breaks a platform that cannot be tested locally.

A follow-up commit closes the matching hole in the reuse path: attaching to whatever already held :7420 inherited no token, so it either 401'd everything or — against a build predating auth — ran unauthenticated. Reuse now requires DECKHAND_SIDECAR_TOKEN.

Tests

The sidecar had 32 Go files and no tests. Now 163 cases, concentrated where a silent regression is worst.

Secret redaction is asserted by marshalling the summary and searching it for the values, so it fails if anyone adds a field that can carry data — a test that only checked Keys would not notice. cleanVolPath carries a per-case invariant that the result is never absolute and never escapes the root. Memory stats cover the cache > usage case where an unconditional uint64 subtraction underflows.

Two fixes fell out: summarizeSecret was extracted to be testable at all, and secret keys are now sorted — Go randomises map iteration and the UI polls this endpoint, so the key list reshuffled on every refresh.

Everything else

  • CI gaps — go vet/go test never ran, and cmd/deckhand was compiled nowhere, so the CLI could break silently. bin/ was neither tracked nor ignored.
  • Bundle — measuring per dependency first showed vega, the library I had assumed was responsible, was not in the bundle at all. The real outlier was xterm.js. Splitting it took the initial chunk from 388.4 kB to 303.7 kB gzipped (−21.8 %). vega, vega-lite, and next-themes are imported nowhere and are removed.
  • ls parsing — the volume browser showed my file.txt as file.txt and a symlink as an entry named after its target. Both came from treating the filename as the last field when it is the remainder of the line.
  • Biome — replaces having no TS linter or formatter. Three rules are off with reasons recorded; four real findings fixed, including a pending state that all three Reconnect buttons ignored.
  • Sidebar marks — Docker's whale (supplied) and a geometrically constructed Kubernetes helm.

Verification

gofmt, go build, go vet, go test (163 cases), biome ci, tsc --noEmit, vite build, and version:check all pass. A full tauri:build produced a .dmg whose bundle contains a working sidecar. The auth behaviour was exercised against a live sidecar — unauthenticated and wrong-token requests 401 including the bind-mount payload, both valid transports 200, an unauthenticated cross-origin request gets no Allow-Origin, preflight returns 204, and the CLI 401s without DECKHAND_TOKEN.

Known follow-ups, deliberately not in this PR

  • 16 real accessibility findings — click targets with no keyboard handler, div+role instead of semantic elements, untitled chart SVGs. Set to warn so they stay visible rather than switched off. The keyboard ones mean parts of the UI are unusable without a mouse.
  • The Kubernetes mark is my geometric reconstruction, not the official asset.
  • Route-level code splitting for the remaining 967 kB; for a desktop app loading from disk this is parse time, not download.

Note for whoever merges

Any installed build older than this PR still runs an unauthenticated sidecar on :7420 until it is rebuilt and reinstalled.

🤖 Generated with Claude Code

https://claude.ai/code/session_015dD6k7ZHtNg7d44xnKLMjH

blakebauman and others added 13 commits September 7, 2026 08:25
Replaces the Spectrum component kit and Spectrum Charts with shadcn/ui
primitives under app/src/components/ui, generated with the Maia preset
(base-maia, bbVJxYW). Spectrum providers, style() macros, and the
per-page style conversion script are gone.

- SpectrumProvider -> AppProvider (theme + TooltipProvider)
- components/spectrum/{Field,RowMenu,StatusBadge,Tip} -> components/*
- charts/SpectrumChartsPanel -> charts/ChartsPanel
- Adds MoreActionsMenu and SegmentedControl app helpers
- Icons standardize on lucide-react via Icon / lucideProps
- Refreshes brand marks, favicons, and generated app icons
- Swaps .cursor/rules/spectrum-ui.mdc for shadcn-ui.mdc

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015dD6k7ZHtNg7d44xnKLMjH
app/ and tauri/ held two near-identical Vite apps: tauri/ duplicated
app/'s index.html, vite.config.ts, tsconfig.json, main.tsx, and package
deps, differing only in paths aliased back into app/src. Its declared
"@deckhand/app": "workspace:*" dep was never imported by name; resolution
went through relative paths and the @ alias.

Both configs hardcoded strictPort 1420, so either could claim the port and
serve a Deckhand-looking app. scripts/tauri-frontend-dev.sh existed purely
to arbitrate that with lsof cwd forensics, as did the "deckhand copy" throw
in tauri/vite.config.ts. One Vite config cannot collide with itself, so
both guards are gone; strictPort now fails loudly instead.

Layout is now the canonical Tauri shape — src/, index.html, vite.config.ts,
tsconfig.json, components.json, public/, src-tauri/ at the root, with
sidecar/ unchanged as a separate Go module. Workspaces are dropped, so
package.json is the only manifest and bun.lock is simply the lockfile.

Two fixes the merge forced:
- src/lib/version.ts imported ../../../package.json, one level too deep.
- The Google Fonts <link> lived only in tauri/index.html, so keeping app/'s
  would have dropped JetBrains Mono to a ui-monospace fallback. Bundled
  @fontsource-variable/jetbrains-mono instead — a local-first desktop app
  should not fetch fonts at launch. Outfit was loaded but never referenced
  in CSS; dropped.

Verified: bun install, bun run build, version:check, build:sidecar,
cargo check, and a full tauri:build producing a .dmg whose app bundle
contains a working deckhand-sidecar.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015dD6k7ZHtNg7d44xnKLMjH
CI only ran scripts/build-sidecar.sh, which builds the single
cmd/deckhand-sidecar binary. Nothing compiled cmd/deckhand, so the CLI
that README advertises could break without any job failing, and neither
go vet nor go test ran at all.

The sidecar job now runs go build ./... (all 11 packages, both commands),
go vet ./..., and go test ./... before producing the release binary.
go test currently reports "no test files" everywhere, so it is a no-op
today but means the first _test.go runs in CI without a workflow change.

Also ignores bin/, where bun run build:cli writes the CLI — it was
neither tracked nor ignored, so the binary was one git add -A away from
being committed.

Verified locally: go build/vet/test all pass, go build ./... leaves no
stray binaries, bun run build:cli produces a working bin/deckhand, and
git correctly ignores it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015dD6k7ZHtNg7d44xnKLMjH
The sidecar served Access-Control-Allow-Origin: * with no authentication of
any kind on a loopback TCP port. Any web page the user visited while Deckhand
was running could drive the Docker daemon — POST /api/docker/containers with
a bind mount of / is host root — and read the responses back. The ephemeral
port used in Tauri mode is obscurity, not a control: /health returned a
distinctive body and loopback ports scan quickly.

The sidecar now generates a 32-byte token per launch and requires it on every
request, as Authorization: Bearer <t> or ?token=<t>. Both forms are needed
because EventSource, WebSocket, and window.open cannot set headers, and the
events, logs, exec, stats, and volume-export endpoints are driven through
exactly those. Comparison is constant-time.

CORS now mirrors auth instead of using an origin allowlist: Origin is echoed
only after the token is accepted, so a page that cannot authenticate cannot
read the response either. An allowlist was rejected because the Tauri webview
origin differs per platform (tauri://localhost on macOS, http://tauri.localhost
elsewhere) and a wrong guess breaks the app on a platform not testable here;
a caller holding the token is authorized regardless of origin. Preflight is
answered before the token check, since a preflight never carries the header.

Token transport: the sidecar prints DECKHAND_SIDECAR_TOKEN= on stdout next to
the addr line, Tauri parses both and exposes sidecar_token, and App.tsx sets
it before the first health poll. The restart-recovery loop refreshes the token
with the URL — a restarted sidecar mints a new one, so refreshing only the URL
reconnects to an endpoint that 401s everything.

Escape hatches: --token and DECKHAND_SIDECAR_TOKEN to pin a value,
VITE_SIDECAR_TOKEN for browser split-dev, DECKHAND_TOKEN for the CLI, and
--no-auth to disable it with a logged warning. dev:sidecar defaults to
deckhand-dev so split-dev keeps working.

Verified against a live sidecar: unauthenticated GET/POST and wrong tokens all
401 (including the bind-mount-/ payload), both valid transports 200, an
unauthenticated cross-origin request gets no Allow-Origin header, an
authenticated one gets its origin echoed with Vary, preflight returns 204 with
Allow-Headers including Authorization, --no-auth still serves and warns, and
the CLI 401s without DECKHAND_TOKEN and succeeds with it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015dD6k7ZHtNg7d44xnKLMjH
…levant parts.

The sidecar had 32 Go files and no tests at all. This covers the functions
where a silent regression would be worst: the ones that decide what is
authorized, what is exposed, and where a path lands.

internal/server/auth: token extraction across both transports, constant-time
comparison, and the guard end to end — unauthenticated and wrong-token
requests 401 (including /health, which is not exempt), a 401 carries no
Access-Control-Allow-Origin so an unauthenticated page cannot read the
rejection, an authenticated caller gets its origin echoed whatever that origin
is, preflight is answered without credentials, and --no-auth still serves.

internal/k8s: secret redaction is asserted by marshalling the summary and
searching it for the values, so it fails if anyone adds a field capable of
carrying data — a test that only inspected Keys would not notice. Also covers
metadata mapping and the empty-secret case.

internal/docker: cleanVolPath against traversal (../, repeated, absolute, and
mid-path), with an invariant on every case that the result is never absolute
and never escapes the root; parseMounts type inference and error paths;
normalizeEnv's bare-name handling, which stops Docker from inheriting a daemon
env var when the user meant an empty one; StatsFromResponse CPU math including
the first-sample and missing-OnlineCPUs paths, and the memory working-set
subtraction including the guard against uint64 underflow when cache exceeds
usage.

internal/domains: matchDomainLabels suffix trimming, comma lists, both label
keys, and the boundary cases — "api" matches host "api.v2" but not "apifoo",
and a ".local"-only label matches nothing.

Two fixes fell out of writing these:

- summarizeSecret extracted from ListSecrets so redaction is testable without
  a live cluster or a fake clientset (the client holds a concrete
  *kubernetes.Clientset, so it cannot be substituted).
- Secret keys are now sorted. Map iteration order is random in Go and the UI
  polls this endpoint, so the key list reshuffled on every refresh.

parseLSListing has two real limitations, covered by tests that document the
current wrong output rather than asserting it is correct: a filename with a
space is truncated to its last word, and a symlink reports its target as its
name. Both come from splitting `ls -la` on whitespace and taking the last
field. Fixing them needs a decision about symlink display, so it is left out
of this change.

151 cases, all passing. gofmt reports five pre-existing unformatted files
(builds.go, gpu.go, run.go, volume_ops.go, helm/service.go); they are untouched
here and were already unformatted at HEAD.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015dD6k7ZHtNg7d44xnKLMjH
Bundle. Measuring the chunk composition per dependency first showed the 1.3 MB
was broadly distributed rather than dominated by one library — and that vega,
the library I had assumed was responsible, was not in the bundle at all. The
one clear outlier was xterm.js at ~85 KB gzipped, needed only once someone
opens a shell tab. ExecTerminal already mounts lazily inside an active
TabsContent, but the static import pulled xterm into the initial chunk anyway;
ExecTerminalLazy makes the import lazy too, with a TerminalFrame fallback so
the panel does not pop.

  initial chunk  1301 kB -> 967 kB   (388.4 kB -> 303.7 kB gzipped, -21.8%)
  xterm          split into its own chunk, fetched on first shell tab
  xterm.css      split alongside it

Dead dependencies. vega, vega-lite, and next-themes are imported nowhere —
leftovers from the Spectrum-era charts and theming, which the shadcn migration
replaced with ChartsPanel and useThemeSync. They were never in the bundle, so
this shrinks install and lockfile rather than shipped output. The other
apparently-unused entries were checked and kept: @tauri-apps/* are reached
through dynamic import(), and @tailwindcss/vite is used by vite.config.ts.

gofmt. CI now fails on unformatted Go, and the five files that had already
drifted (builds.go, gpu.go, run.go, volume_ops.go, helm/service.go) are
formatted. That diff is struct-tag alignment only — git diff -w reports no
change. gofmt -l exits 0 even when it lists files, so the step checks its
output explicitly rather than trusting the exit code.

No formatter or linter is configured for TypeScript. Adding Prettier would
mean reformatting ~90 files in one mechanical pass, which is a call worth
making deliberately rather than folding into this change; tsc --noEmit remains
the only gate there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015dD6k7ZHtNg7d44xnKLMjH
parseLSListing took the filename to be the last whitespace-separated field of
an `ls -la` line. That is wrong in two ways the volume and image file browsers
hit in practice: "my file.txt" was shown as "file.txt", and a symlink line
"link -> /target" was shown as an entry literally named "/target", with the
real name lost.

Both come from the same mistake — the name is the remainder of the line, not a
field — so splitLSColumns now takes the eight fixed metadata columns and
returns the rest untouched. Internal spacing is preserved rather than
normalized, since the remainder is never re-split.

Symlink targets are split off only when the mode column marks the entry a
symlink, so a regular file named "a -> b.txt" keeps its name. The target is
surfaced as VolumeFileEntry.Link and rendered in the volume browser, which
previously had no way to show where a link pointed.

The two tests added earlier to document the wrong output are replaced with
tests asserting the correct output, plus cases for preserved inner spacing, a
symlink with spaces on both sides of the arrow, an arrow inside an ordinary
filename, and splitLSColumns itself.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015dD6k7ZHtNg7d44xnKLMjH
Replaces the abstract stacked-boxes glyph with Docker's own whale, and runs it
at height 22 rather than 18.

Three changes to the supplied artwork were needed to fit the sidebar:

- fill was var(--iconColor, #00153c); it is now currentColor, so the mark
  follows the button's text colour and stays legible against both the selected
  pill (text-primary-foreground) and the unselected rail in either theme. The
  hardcoded navy would have been invisible when selected.
- The wrapping clipPath carried id="_clip1" from a 140-wide wordmark artboard.
  Element ids must be unique per document, so rendering the mark more than once
  would have produced duplicates; the clip bounded nothing at 38 wide, so it is
  gone.
- The art is 38x30, so `size` now sets the height and the width follows the
  aspect ratio. Feeding it to both, as the square marks do, would have squashed
  the whale.

Because it is short and wide, matching the other marks' height would make it
read as the smallest of the three, so the mode list carries a per-entry
iconSize override rather than changing the shared default.

Verified in the running dev app: the whale renders at the correct aspect ratio
and picks up the selected pill's foreground colour.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015dD6k7ZHtNg7d44xnKLMjH
spawn_sidecar attached to whatever was already listening on 127.0.0.1:7420 and
took its token from DECKHAND_SIDECAR_TOKEN, defaulting to empty. Since
authentication landed, that is wrong in both directions:

- Against a current sidecar there is no token, so every request 401s and the
  app is dead on arrival — the ordinary "relaunch while the old one is still
  winding down" case.
- Against one predating authentication it runs unauthenticated. This is not
  hypothetical: launching the dev build while the installed alpha.2 app held
  :7420 silently attached to that older sidecar, which answers /health 200 with
  no token. It also means any local process that squats the port first can sit
  in the middle of the app's Docker traffic.

Reuse now requires DECKHAND_SIDECAR_TOKEN to be set, which is the operator
declaring they know what is on the port. Otherwise the app starts its own
sidecar on an ephemeral port and logs the reason. When the port is already
taken we go straight to ephemeral rather than attempting a bind that cannot
succeed.

Verified against the exact condition that exposed this: with the installed
alpha.2 app still holding :7420, the dev build logged the refusal, spawned its
own sidecar on an ephemeral port, that sidecar returned 401 without a token,
and the webview held 10 established connections to it — so the Tauri token
handshake works end to end, which the earlier dev run had not actually
exercised.

cargo fmt also reformatted the pre-existing stdout-drain block in this file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015dD6k7ZHtNg7d44xnKLMjH
Matches the treatment the Docker mark just got. The previous glyph was a
circle with three crossing lines, which read as a generic wheel rather than
Kubernetes.

The heptagon vertices, hub, and seven spokes are computed at exact 360/7
intervals rather than eyeballed, so the seven-fold symmetry is true — a
hand-placed approximation is visibly off at this size, where the spokes sit
only a few pixels apart. Sized at 21 rather than the Docker mark's 22 because
it is square and therefore covers more area at the same nominal height.

Note this is the mark reconstructed geometrically, not the official asset
file; paste the real SVG in if you would rather ship that, the same way the
Docker whale was supplied.

Verified in the running dev app: heptagon, spokes, and filled hub all render
cleanly at 21px, and the mark inherits the rail's foreground colour.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015dD6k7ZHtNg7d44xnKLMjH
Replaces the gap left by having no TS linter or formatter at all — tsc --noEmit
was the only gate. Biome covers both jobs in one tool, so this does not add
ESLint and Prettier separately.

Triaging the first run mattered more than the count. Of 292 initial
diagnostics, three recommended rules are switched off because each fights a
deliberate pattern rather than finding a defect, and biome.json records why:

- suspicious/noExplicitAny (85): Docker and Kubernetes payloads are passed
  through as any. Typing them is a real project; rewriting to unknown would
  only relocate the casts.
- style/noNonNullAssertion (18): `selected!` after an explicit guard is the
  idiom in the detail panes.
- correctness/useExhaustiveDependencies (10): every hit was an *excess*
  dependency, not a missing one, and several are dataUpdatedAt — used
  deliberately to re-run an effect when React Query refetches. The autofix
  would have changed behaviour.

src/components/ui/** is excluded from linting because the shadcn CLI
regenerates those files; fixes there are lost. They are still formatted.

Four findings were real and are fixed rather than suppressed:

- api.useContext and api.useDockerContext are HTTP calls that switch the
  active kube/Docker context, not React hooks. The use* prefix tripped the
  rules-of-hooks check and reads as a hook to anyone scanning the file, so
  they are now switchKubeContext and switchDockerContext.
- useDockerReconnect exposes a pending state covering eleven query
  invalidations, and all three Reconnect buttons ignored it — the button could
  be spammed with no feedback. They now honour it.
- ExecTerminal computed a statusVariant that nothing consumed, left behind by
  the Spectrum-to-shadcn migration.

Five noArrayIndexKey hits are legitimate and carry targeted suppressions with
reasons: skeleton placeholders, positional dividers, a fixed-length bar series,
and two composite keys where the index is only a tiebreaker.

Sixteen accessibility findings are real but are their own piece of work —
missing keyboard handlers on click targets, div-with-role instead of semantic
elements, untitled chart SVGs. Those rules are set to warn so they stay visible
and reported without blocking, rather than being switched off.

Formatting is applied in the commit that follows, kept separate so this one
stays reviewable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015dD6k7ZHtNg7d44xnKLMjH
Pure mechanical output of `biome check --write`: formatting to the configured
100-column width, import ordering, and the safe fixes for useTemplate,
useConst, and noUnusedImports. No behavioural change — tsc --noEmit and the
production build both pass unchanged.

Kept separate from the commit that introduced Biome so that one stays
reviewable, and recorded in .git-blame-ignore-revs so blame skips it:

  git config blame.ignoreRevsFile .git-blame-ignore-revs

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015dD6k7ZHtNg7d44xnKLMjH
Enable locally with:
  git config blame.ignoreRevsFile .git-blame-ignore-revs

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015dD6k7ZHtNg7d44xnKLMjH
@blakebauman
blakebauman merged commit 6d0191d into main Sep 7, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant