mirror of
https://github.com/hanzoai/lsp.git
synced 2026-09-30 22:52:48 +00:00
A static symbol index knows what a repository says about itself. It cannot say
where dep.Greet is DEFINED, because the definition is in a module the repository
only names — and finding it means downloading that module and type-checking both
halves, which means running a third-party binary over untrusted bytes. That does
not belong in the API process. It belongs here, behind a jail.
The invariant is the separation: the network and untrusted bytes are never in the
same process. FETCH runs `go mod download all` with a socket, first-party
toolchain only, writing the tree and the dependency cache. SERVE runs gopls with
seccomp denying socket/socketpair/io_uring and every bind read-only. The phase is
not a field anyone sets — it is jail.Fetch or jail.Serve, so there is no value to
get wrong, and the only difference between their filters is two syscall numbers.
Roots are (org, repo, resolved-sha). Refusing a branch name at the door is the
entire cache-invalidation design: a sha names one tree for all time, a push is a
different key, and nothing decides when anything is stale. The daemon holds no
git credential and makes no git request; the caller sends the tree, and /ask
answers 409 {"need":"tree"} when it has not.
Fail closed. On Linux the jail is required: at boot a canary is built in each
phase and asked the kernel for a socket — serving must be refused one, fetching
must get one — and if that does not hold, /readyz is 503 and every request that
would run a language server is refused. There is no environment variable that
turns the jail off, so there is none that can be set by accident.
REUSED, not rewritten:
internal/lsp/conn.go, langs.go — ported from hanzoai/cloud branch `lsp`
(apps/lsp, 67975187) with their tests, including the two regressions that
build pinned: Close hanging on a wedged server, and a frame reader that
would allocate whatever Content-Length it was told.
internal/jail/ — a copy of hanzoai/code-exec's jailer (d2794ce), generalized
to two phases. It SHOULD be github.com/hanzoai/jail imported by both; a
boundary that exists twice drifts. Noted in LLM.md, not decided here.
Three bugs found and fixed on the way to green, all of which made the service
answer null with no error anywhere:
- the jail child now locks its OS thread before installing seccomp. The filter
and no_new_privs are per-THREAD and syscall.Exec execs the calling thread; a
goroutine migration between them would leave the exec'd program unfiltered.
This is a fix over the code-exec original and belongs upstream there too.
- clean() returns the root's own spelling, not the symlink-resolved one. The
proof of containment still resolves, but a server rooted at /var/x and handed
a document at /private/var/x does not consider it to be in its workspace.
- didOpen is sent once per document. It is not idempotent in LSP, and a root is
immutable, so once is also all that is ever needed.
- the fetch is `go mod download all`: the bare form records only each
dependency's go.mod hash, so the read-only serve phase then failed on a
missing go.sum entry.
Phase 1 serves Go. The table carries five complete languages and Available()
decides, so adding one is installing its server in the Dockerfile.
Acceptance is hermetic: a module is published into a directory laid out as a Go
proxy and fetched with GOPROXY=file://, so the real `go mod download` runs with
no network, and locate/definition on dep.Greet must return external:true at the
module coordinate. Passes in 0.08s once warm.
Co-authored-by: Hanzo Dev <[email protected]>
93 lines
4.3 KiB
Docker
93 lines
4.3 KiB
Docker
# syntax=docker/dockerfile:1
|
|
|
|
# lsp — the jailed language-server daemon.
|
|
#
|
|
# Three stages:
|
|
# 1. build — compiles the static Go binary.
|
|
# 2. tools — compiles the language servers the runtime will run.
|
|
# 3. runtime — lean Debian with the toolchains + tini, non-root.
|
|
#
|
|
# The isolation is the Go-NATIVE jailer built into the binary (namespaces +
|
|
# minimal chroot + seccomp) running under a gVisor RuntimeClass — NOT nsjail.
|
|
# nsjail is incompatible with gVisor (it calls prctl(SECUREBITS), which gVisor
|
|
# does not implement, and needs a cgroup ns plus a netlink net-ns gVisor
|
|
# rejects), so this image ships no jailer binary at all: the daemon re-execs
|
|
# ITSELF as the jail child.
|
|
#
|
|
# Built in CI on the self-hosted amd64 scale set — never on GitHub's builders,
|
|
# never on a developer's laptop. The cluster is amd64; the image targets
|
|
# linux/amd64.
|
|
|
|
# ── 1. The daemon ────────────────────────────────────────────────────────────
|
|
FROM golang:1.26.4-bookworm AS build
|
|
WORKDIR /src
|
|
# Module graph first for layer caching. There are no dependencies today — stdlib
|
|
# only, by design — but this keeps the shape right if one is ever added.
|
|
COPY go.mod ./
|
|
RUN go mod download
|
|
COPY . .
|
|
# CGO off ⇒ a fully static binary with no shared-library surface of its own. The
|
|
# jail child re-execs this exact binary.
|
|
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
|
|
go build -trimpath -ldflags="-s -w" -o /out/lsp .
|
|
|
|
# ── 2. The language servers ──────────────────────────────────────────────────
|
|
#
|
|
# PINNED, not @latest. A floating server version means two builds of the same
|
|
# commit answer differently, and the thing that changed is a binary nobody
|
|
# reviewed. Bumping is one line here.
|
|
#
|
|
# Phase 1 ships Go only. Adding a language is: install its server below, and
|
|
# internal/lsp/langs.go already carries the entry — Lang.Available() lights it up
|
|
# at runtime with no code change.
|
|
FROM golang:1.26.4-bookworm AS tools
|
|
ARG GOPLS_VERSION=v0.23.0
|
|
RUN GOBIN=/out CGO_ENABLED=0 go install golang.org/x/tools/gopls@${GOPLS_VERSION}
|
|
|
|
# ── 3. Runtime ───────────────────────────────────────────────────────────────
|
|
FROM debian:bookworm-slim AS runtime
|
|
|
|
# ca-certificates for the FETCH phase's TLS to the module proxy; tini to reap the
|
|
# jail children (each root forks a language server, which forks `go list`).
|
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
|
ca-certificates tini curl \
|
|
&& rm -rf /var/lib/apt/lists/*
|
|
|
|
# The Go toolchain, which gopls shells out to for `go list` and which the fetch
|
|
# phase runs as `go mod download`. A pinned tarball rather than apt: Debian's
|
|
# golang lags, and the toolchain version decides which repositories resolve.
|
|
ARG GO_VERSION=1.26.4
|
|
RUN set -eux; \
|
|
arch="$(dpkg --print-architecture)"; \
|
|
case "$arch" in amd64) goarch=amd64 ;; arm64) goarch=arm64 ;; *) echo "unsupported arch $arch"; exit 1 ;; esac; \
|
|
curl -fsSL "https://go.dev/dl/go${GO_VERSION}.linux-${goarch}.tar.gz" -o /tmp/go.tgz; \
|
|
tar -C /usr/local -xzf /tmp/go.tgz; \
|
|
rm /tmp/go.tgz
|
|
ENV PATH="/usr/local/go/bin:${PATH}"
|
|
|
|
COPY --from=tools /out/gopls /usr/local/bin/gopls
|
|
COPY --from=build /out/lsp /usr/local/bin/lsp
|
|
|
|
# Roots and dependency caches live on a PVC mounted at /var/lib/lsp. The staging
|
|
# directory each jail chroots into is /tmp, an emptyDir — it holds nothing but
|
|
# mountpoints and must be writable by the non-root uid.
|
|
RUN groupadd -r lsp && useradd -r -g lsp -d /home/lsp -m lsp \
|
|
&& mkdir -p /var/lib/lsp/roots /var/lib/lsp/deps \
|
|
&& chown -R lsp:lsp /var/lib/lsp
|
|
|
|
ENV LSP_LISTEN=":8000" \
|
|
LSP_ROOTS="/var/lib/lsp/roots" \
|
|
LSP_DEPS="/var/lib/lsp/deps" \
|
|
LSP_STAGE="/tmp/jail" \
|
|
LSP_PROXY="https://proxy.golang.org,direct" \
|
|
LSP_SUMDB="sum.golang.org"
|
|
# LSP_KEY is NOT set here. It comes from KMS through a KMSSecret; an unset key
|
|
# makes the daemon refuse every request rather than serve whoever asks.
|
|
|
|
USER lsp
|
|
EXPOSE 8000
|
|
|
|
# tini as PID 1 so orphaned language-server processes are reaped.
|
|
ENTRYPOINT ["/usr/bin/tini", "--"]
|
|
CMD ["/usr/local/bin/lsp"]
|