* feat(plugins): per-plugin settings pages driven by manifest config_schema Settings > Plugins rows now open /settings/plugins/:id — a detail page showing the manifest plus a settings form generated from the plugin author's config_schema. Secret fields (secret: true / format: password) are masked on the operator API and kept on masked re-submits; a running plugin restarts on save and reads its config (cleartext secrets included) through the new Host GetConfig gRPC RPC. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(plugins): write plugin config file owner-only (0600) Operator config can now carry cleartext secret values (config_schema fields marked secret), so <id>.config.yml must not be world-readable — match the 0600 mode the record file already uses. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(plugins): vault-backed plugin secrets — Get/Set/DeleteSecret RPCs and encrypted config secrets Plugins get vault-backed secret primitives on the Host API (GetSecret/SetSecret/DeleteSecret, capability-gated, namespaced to plugin:<id>㊙️<key> so a plugin can only touch its own entries; purged on uninstall). Secret config_schema fields now live in kandev's AES-256-GCM vault too — <id>.config.yml persists only a vault reference, and the Host GetConfig RPC resolves it back to cleartext for the plugin. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore(plugins): serve PR screenshots from the media/pr-1761-screenshots ref instead of the repo The PR body now points at raw URLs pinned to an unmerged media ref, so the binaries never land in main (PRs are squash-merged; only the branch tip tree merges). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(plugins): address review feedback on settings validation and save flow - integer form fields reject non-integral input instead of parseInt-truncating - enum selections keep their schema-declared types (numeric enums round-trip) - backend enum validation normalizes int-vs-float64 before comparison - maskSecrets masks any non-zero secret value regardless of runtime type - save flow splits PATCH from refetch: a refetch failure no longer reports a successful save as an error, and typed secrets are masked on screen - optional enum fields gain a 'Not set' option so they can be cleared - resolveConfigSecrets copies before mutating; restart-failure SetStatus errors are logged; SDK test fake returns a non-nil empty config map - add missing tests: usePluginConfigForm hook, getPluginConfig client, integer/enum regression cases Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(plugins): harden secret config invariants from review - reject non-string values on declared secret fields at validation time, closing the path where a numeric/boolean secret bypassed the vault and persisted cleartext in the config file - delete removed secret fields' vault entries only AFTER the config commit succeeds, so a failed SetConfig never leaves the still-current config referencing a deleted vault entry - uninstall fails closed: the plugin's vault namespace is purged before any destructive step, and a cleanup failure aborts the uninstall (retryable) instead of letting a reinstall inherit stale secrets Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(plugins): failure-atomic config secrets, quiesced uninstall purge, ctx propagation - UpdateConfig is now failure-atomic for secret overwrites: storeConfigSecrets snapshots each vault entry before writing and returns a rollback that restores prior values (or deletes newly-created ones). A vault.Set failure mid-loop or a failed SetConfig commit rolls back, so a request reported as failed never changes the value a still-current config ref resolves to. - Uninstall stops the plugin process BEFORE purging its vault namespace, so the plugin can no longer race the cleanup with a SetSecret between ListIDs and the deletes; the purge stays fail-closed and retryable. - Thread the request context through Uninstall -> deletePluginSecrets instead of context.Background(), per the pass-context-through-chains convention. - Consolidate the 'secret not found:' prefix check into isSecretNotFound (host.go + service rollback share one definition). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(plugins): detach rollback context, fail closed without a vault, reconcile uninstall status - vault rollback closures now run on context.WithoutCancel(ctx): a request cancelled mid-save (browser closed) can no longer abort the rollback and leave the vault holding new values while the unchanged config still points at them (Greptile P1). - storeConfigSecrets fails closed (errSecretVaultRequired) when a plugin declares secret config fields but no vault is wired, instead of silently persisting the secret in cleartext. - vaultRestoreFunc distinguishes not-found from a genuine Reveal error: a backend read failure aborts the update before writing rather than risk a rollback that deletes a real secret; rollback now returns errors, joined into UpdateConfig's failure so an inconsistent vault is surfaced, not just logged. - Uninstall reconciles lifecycle status when a secret-purge failure aborts after the process was stopped: a previously-running plugin transitions to error and notifies observers instead of being reported active with no process running. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(plugins): detach removed-secret cleanup ctx and clear stale save dirty flag - cleanupRemovedConfigSecrets now runs on context.WithoutCancel(ctx), matching the rollback path, so a client disconnect right after the config commit can no longer cancel the delete and orphan now-unreferenced vault entries. - tests: assert GetConfig succeeds before treating an empty map as proof that nothing persisted (a swallowed read error would be a false pass). - web: on a post-save refetch failure, rebase initialValues onto the masked snapshot so the saved form no longer reads as dirty. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(plugins): fail tests on discarded GetConfig errors, drop setState-in-updater - config_test: capture and fatal on GetConfig errors at the four remaining positive-assertion sites (a nil from a failed read would panic on the next map access instead of giving a clear failure), matching the earlier fix. - web hook: compute the masked post-save snapshot from the current values and apply it to setValues + setInitialValues directly, instead of calling setInitialValues inside a setValues functional updater (a React anti-pattern that runs twice under StrictMode). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(plugins): skip validation of a secret field's own vault ref - validateConfigSchema takes the plugin id and skips a secret field whose value is that field's config vault ref (an internal storage marker, already validated when first set). Without this, a field declaring both secret: true and enum: [...] 400s on every save that keeps the mask, because the enum check saw the vault ref instead of a user value. Freshly entered secret values are still validated; a foreign plugin's ref is not skipped. Covered by unit + service-level regression tests. - surface mid-loop vault rollback failures in storeConfigSecrets via a warning instead of discarding them (double-fault observability). - web: integer inputs get step=1 so browsers flag non-integral input in place (serializeConfigValues remains the backstop). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(plugins): split config secret tests into config_secrets_test.go config_test.go had grown past the 800-line backend file-length limit (revive), failing CI lint. Move the vault-backed config and plugin-scoped secret tests (plus their fake-vault/store helpers) into a sibling file; no test logic changes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Kandev Agent <agent@kandev.dev> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Kandev Web — E2E Test Suite
Playwright-based end-to-end tests. Each Playwright worker spawns its own real Go backend (no mocks of internal services) on isolated ports; that backend serves the Vite SPA assets and boot data while Playwright drives a real Chromium against it.
Project layout
| Folder | What's in it |
|---|---|
tests/ |
Spec files, grouped by feature (chat/, docker/, git/, integrations/, kanban/, pr/, search/, session/, ssh/, layout/, …). |
fixtures/ |
Worker-scoped fixtures that own the backend lifecycle (backend.ts, docker-test-base.ts, ssh-test-base.ts, test-base.ts). |
helpers/ |
Reusable building blocks for specs (api-client.ts, docker.ts, ssh.ts, git-helper.ts, ws-capture.ts, …). |
pages/ |
Page Objects (one class per top-level UI surface — SessionPage, KanbanPage, JiraSettingsPage, SSHSettingsPage, …). |
playwright.config.ts |
Project definitions, timeouts, sharding config. |
global-setup.ts |
Pre-flight checks for required binaries (kandev, mock-agent) and the Vite web build. |
Playwright projects
The suite is split into four projects. Pick one with --project=<name>.
routing
Runs office-routing-*.spec.ts in an isolated desktop worker. Those specs restart
their backend with provider overrides that apply only to the restart/spec that
supplies them; the next restart rebuilds the environment from its clean baseline.
Keeping these specs separate also keeps their routing-specific provider and agent
fixtures away from tests that count agents or assert the active-agent label. Run
it directly with:
pnpm e2e --project=routing
chromium (default)
The everyday surface — runs in every CI shard. Excludes the heavyweight containers specs and the mobile specs.
pnpm e2e
mobile-chrome
Same as chromium but on Playwright's Pixel-5 viewport, gated on tests/**/mobile-*.spec.ts. Runs in the same CI shard matrix as chromium.
containers — Docker required
This is the "real-infra heavyweight" project. Despite the name, it covers more than just the Docker executor — it's where any test that needs Docker on the host as a runtime lives:
- Docker executor tests (
tests/docker/*.spec.ts) — verify kandev launches realkandev-agent:e2econtainers, recovers them across backend restarts, cleans them up on archive/delete, etc. - SSH executor tests (
tests/ssh/*.spec.ts) — verify kandev SSHes into a realkandev-sshd:e2econtainer, uploads agentctl, runs an agent end-to-end, recovers across backend restarts, etc. The SSH executor's remote target is a Docker container in tests, even though the SSH connection itself is a real SSH connection.
This project:
- Skips entirely when no Docker daemon is reachable. Contributors without Docker can still run
chromium+mobile-chrome. - Builds container images on demand. First run builds
kandev-agent:e2e(slim Node base + git) andkandev-sshd:e2e(Alpine + openssh-server + git + pre-baked mock-agent). Subsequent runs hit Docker's layer cache. - Has a longer per-test timeout (180s vs 60s) because container starts + agent setup are slow.
How to run it locally (requires Docker running):
KANDEV_E2E_CONTAINERS=1 pnpm e2e --project=containers
Or a single spec:
KANDEV_E2E_CONTAINERS=1 pnpm e2e --project=containers tests/ssh/launch-task.spec.ts
Why "containers" instead of "docker"?
This project used to be named docker. It was renamed to containers once SSH e2e tests joined it — calling it docker was misleading because SSH tests have nothing to do with the Docker executor; they just happen to use Docker as the runtime that hosts the sshd target.
KANDEV_E2E_DOCKER=1 is still honored as a deprecated alias for KANDEV_E2E_CONTAINERS=1 for one release. Local scripts and stale CI configs won't break, but new code should use the new name.
Commands
e2e, e2e:run, and e2e:ui are defined only in apps/web/package.json. Run them from apps/web (or pnpm --filter @kandev/web e2e:run from elsewhere). From the repo root you get ERR_PNPM_NO_IMPORTER_MANIFEST_FOUND.
| Command | What it does |
|---|---|
pnpm e2e |
Run the default (chromium) project headless. |
pnpm e2e:ui |
Open Playwright's UI mode for interactive runs. |
pnpm e2e:headed |
Run headless project but with a visible browser. |
pnpm e2e --project=containers |
Run container-backed tests (needs Docker). |
pnpm e2e --project=mobile-chrome |
Run mobile responsive tests. |
pnpm e2e --project=routing |
Run provider-mutating Office routing tests. |
E2E_DEBUG=1 pnpm e2e |
Surface Docker build output + extra logging. |
Common flags: --shard=1/4, -g "fragment of test name", --repeat-each=3 (flake hunting).
pnpm e2e:run — the managed runner (build + run + teardown)
e2e/scripts/run-e2e.sh (aliased as pnpm e2e:run) handles the build, the run, and cleanup so you don't have to assemble the steps by hand. It auto-selects docker vs host, runs N shards concurrently, enforces strict WS accounting by default (KANDEV_E2E_WS_ASSERT=1, matching CI), and never leaves root-owned artifacts behind.
pnpm e2e:run # auto: docker if the daemon + CI image are available, else host
pnpm e2e:run --shards 3 # 3 shards concurrently (isolated containers, or host procs with distinct ports)
pnpm e2e:run tests/chat/foo.spec.ts # extra args pass straight through to Playwright
pnpm e2e:run --host --no-build -- --grep "x" # force host, skip rebuild, forward flags after --
pnpm e2e:docker # force the docker CI image (full isolation from a host dev instance)
pnpm e2e:clean # remove build/test artifacts, incl. root-owned ones from prior docker runs
Why a script instead of raw docker run: in docker mode it builds the CGO/fts5 backend on the host and runs it in the runtime image — a host glibc that's the same or older than the image's (the usual case; the image tracks recent Ubuntu) is forward-compatible, so no build image is needed. The runner smoke-tests the binary in the image first and only falls back to the build image (KANDEV_CI_BUILD_IMAGE, default …/kandev-ci:build-latest) if your host glibc is newer. It also builds the Vite web assets on the host, points Playwright output at a container-local dir, and cleans up. Run clean if a previous bare docker run left root-owned files you can't delete.
Apple Silicon: the docker path runs the amd64 CI image. Under Docker Desktop's default QEMU emulation the amd64 Go toolchain segfaults (
SIGSEGVinmodindex.dirHash) during backend build. Use Colima with Rosetta instead:colima start --vm-type=vz --vz-rosetta. QEMU is not viable for the Go build; Rosetta is required for local amd64 E2E repro on arm64.
Office is always enabled in e2e (and dev); only prod gates it off.
profiles.yamlsetsKANDEV_FEATURES_OFFICEto"true"in thee2eanddevprofiles and"false"inprod, and the fixture selects the e2e profile viaKANDEV_E2E_MOCK=true. Sotests/office/*always have office routes registered — no manual env setup.This used to break when e2e was launched from a shell that had inherited
KANDEV_FEATURES_OFFICE=false(e.g. from a host kandev backend running the prod profile):profiles.ApplyProfileonly sets vars that are unset (so launchers/shells win — seedocs/decisions/0007-runtime-feature-flags.md), and the fixture spreadsprocess.envinto the spawned backend, so the stale prod value won and 404'd every office spec. Fixed at the source:sanitizeInheritedEnvine2e/fixtures/backend.tsstrips all inheritedKANDEV_FEATURES_*before spawn, so the e2e profile — not whatever the host exported — decides feature flags. Nounsetneeded.
Host oversubscription: running >=5 heavy shards concurrently on one machine (each = Go backend + Vite-served SPA assets + Chromium + mock agent) starves CPU/IO and induces timing flakes that CI's isolated runners never see. Use 2-3 concurrent shards locally for a clean signal; see "flake triage" in the
/e2eskill.
Backend isolation per worker
Every Playwright worker gets:
- A unique backend port in
BACKEND_BASE_PORT + workerIndex(default18080+). - A unique frontend port in
FRONTEND_BASE_PORT + workerIndex(default13000+). - A fresh tmpdir (
HOME,KANDEV_HOME_DIR, worktree base, repo clone base — all under that tmpdir). - A unique agentctl instance port range (
30001 + E2E_PORT_OFFSET * 1000 + workerIndex * 200). - Its own SQLite DB.
Workers run in parallel across CI shards (--shard=N/M); within a worker, tests run serially because the testPage fixture calls e2eReset on the shared backend before each test.
Mocked vs real
- Mocked: Jira (
KANDEV_MOCK_JIRA=true), Linear (KANDEV_MOCK_LINEAR=true), GitHub (KANDEV_MOCK_GITHUB=true), the agent process itself (KANDEV_MOCK_AGENT=only). These are third-party services or external processes we don't want CI to depend on. - Real: Everything inside the kandev backend — orchestrator, lifecycle manager, agentctl, SSH/SFTP, Docker SDK, git, worktree manager. The point of e2e is to exercise the real code paths.
The SSH executor specifically has no mock controller. Tests use a real Docker-hosted sshd as the remote target, and fault-injection (host-key rotation, dropped traffic, killed pids) is done by operating on the container itself.
Adding a new spec
- Pick a directory under
tests/(or create one for a new feature). - Decide which project it belongs to. Anything that needs Docker →
tests/docker/ortests/ssh/(lands incontainers). Anything mobile-specific → name itmobile-*.spec.ts. Otherwise it joinschromiumautomatically. - Import the right test base:
import { test, expect } from "../../fixtures/test-base";for normal tests.import { test, expect } from "../../fixtures/docker-test-base";for Docker executor tests.import { test, expect } from "../../fixtures/ssh-test-base";for SSH executor tests.
- Use
getByTestIdfor selectors. If the surface you're testing lacks stable testids, add them — drift-prone CSS / text selectors are not worth the maintenance cost.
CI
.github/workflows/e2e-tests.yml defines two jobs:
e2e— matrixedchromium+mobile-chromeshards.e2e-containers— single job, runs--project=containers, needs Docker.
Both upload blob reports that e2e-report merges into a single HTML artifact.