Repository navigation
Flatten repo layout, authenticate the sidecar API, and add its first tests - #13
Merged
Merged
Conversation
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 duplicatedapp/almost line for line —index.html,vite.config.ts,tsconfig.json,main.tsx, and the dependency list — differing only in paths aliased back intoapp/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 wassrc-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 whyscripts/tauri-frontend-dev.shneeded 50 lines oflsofcwd forensics, and whytauri/vite.config.tsthrew ondeckhand copypaths. 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, sopackage.jsonis the only manifest andbun.lockis 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/containerswith 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: Beareror?token=. Both transports are necessary, not belt-and-braces:EventSource,WebSocket, andwindow.opencannot 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 —
Originis echoed only after the token is accepted. An allowlist was rejected because the Tauri webview origin differs per platform (tauri://localhoston macOS,http://tauri.localhostelsewhere) 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
:7420inherited no token, so it either 401'd everything or — against a build predating auth — ran unauthenticated. Reuse now requiresDECKHAND_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
Keyswould not notice.cleanVolPathcarries a per-case invariant that the result is never absolute and never escapes the root. Memory stats cover thecache > usagecase where an unconditionaluint64subtraction underflows.Two fixes fell out:
summarizeSecretwas 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
go vet/go testnever ran, andcmd/deckhandwas compiled nowhere, so the CLI could break silently.bin/was neither tracked nor ignored.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, andnext-themesare imported nowhere and are removed.lsparsing — the volume browser showedmy file.txtasfile.txtand 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.pendingstate that all three Reconnect buttons ignored.Verification
gofmt,go build,go vet,go test(163 cases),biome ci,tsc --noEmit,vite build, andversion:checkall pass. A fulltauri:buildproduced a.dmgwhose 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 noAllow-Origin, preflight returns 204, and the CLI 401s withoutDECKHAND_TOKEN.Known follow-ups, deliberately not in this PR
div+roleinstead of semantic elements, untitled chart SVGs. Set towarnso they stay visible rather than switched off. The keyboard ones mean parts of the UI are unusable without a mouse.Note for whoever merges
Any installed build older than this PR still runs an unauthenticated sidecar on
:7420until it is rebuilt and reinstalled.🤖 Generated with Claude Code
https://claude.ai/code/session_015dD6k7ZHtNg7d44xnKLMjH