TypeScript and JavaScript were the two languages the new image could not serve.
The daemon said why and kept going: `language server did not start … spawn
EPERM`, once per root, while the other eight answered.
socketpair was denied beside socket, on the reading that a served process must
be able to obtain no socket fd at all. But libuv gives a child its stdio through
a socketpair, so what that denied was not a network — it was Node starting a
process, and typescript-language-server exists to run tsserver.
A socketpair reaches nothing: both ends come back already connected to each
other, in AF_UNIX, inside this jail's namespaces. It cannot be connect()ed
elsewhere, and a peer that could pass a network fd over it would have to be
outside the jail, where nothing is. socket() is still denied, so there is still
no way to obtain a fd to a network.
The filter now reads args[0] and admits AF_UNIX alone. Allowing it outright
would have been equivalent today — the kernel implements socketpair for no other
family — but that is a fact about the kernel, not a property of this program,
and the claim in the header is worth keeping true by construction.
The arithmetic is the risk: those two jump edges are hand-computed offsets over
a list whose length changes with the phase, and an off-by-one reads as ALLOW.
TestSocketpairIsDecidedByItsFamily interprets the program for AF_UNIX and for
five families that must fail, in both phases, and pins that the carve-out did
not widen to socket().
Co-authored-by: Hanzo Dev <[email protected]>
They are how a Node package says which module system a file is in, and tsserver
reads both. Left out, a package whose entry point is .mjs looked to Speaks()
like a repository with no JavaScript in it — no server started, and the daemon
answered that it had nothing for the file. .mts and .cts are the same omission
on the TypeScript side.
ID() has to move with the list or a .mjs is opened as TypeScript and gets type
errors it was never written to satisfy.
Co-authored-by: Hanzo Dev <[email protected]>
The table always carried the other entries; the image carried one toolchain, so
Available() answered false and the daemon served Go alone. Install the rest —
node with typescript-language-server, pyright and intelephense; the Rust
component tarballs with rust-src; clangd's own release; a JDK with jdtls; ruby
with ruby-lsp; composer for PHP — every version pinned, and add the java, php
and ruby entries beside them.
The three new entries keep the scripts-off bargain rather than bending it. PHP
fetches with composer's own --no-scripts --no-plugins. Java has two dependency
resolvers and both ARE build tools, so it has no fetch and answers from source
and the JDK. Ruby's bundle install compiles native extensions, so it is marked
Executes and refused, as python's is.
Three things that would otherwise be silent:
* typescript-language-server prefers the TREE's node_modules/typescript —
dependency-authored JavaScript, loaded and run in the serve phase, undoing
what --ignore-scripts bought one phase earlier. Pin tsserver to the image's.
* ruby-lsp creates .ruby-lsp/ in the project and shells out to bundle install
unless BUNDLE_GEMFILE is set at all. On a read-only tree that is EACCES
before the server starts.
* Equinox takes its writable configuration area from the passwd entry, not
$HOME, so jdtls would write to /home/lsp, which the jail does not bind.
Name the area under the jail's own /tmp.
RLIMIT_NPROC is per UID and every jail shares it, so 256 became a way for a
JVM's forty threads to stop another language forking at all.
Co-authored-by: Hanzo Dev <[email protected]>
gopls answered 'no package metadata' / 500 'language server did not answer' for
cloud, because `go mod download all` failed: cloud's go.mod requires go>=1.26.5
and the image shipped 1.26.4 with GOTOOLCHAIN=local (never auto-downloads). The
symbols op worked (no dep resolution); locate/hover/definition did not. 1.26.5 is
the latest 1.26.x and what cloud names — bump the pin, per the daemon's own design.
Co-authored-by: Hanzo Dev <[email protected]>
The shipped go is -trimpath (no compiled-in GOROOT), and the jail binds a host
allow-list with no /proc, so os.Executable fails and go/gopls die with 'cannot
find GOROOT directory' — every query answered null. Set GOROOT=/usr/local/go
(present via the /usr/local bind) and GOTELEMETRY=off (its counter child also
readlinks the absent /proc/self/exe). /v1/code/lsp answers Go queries after this.
Bump 0.1.2 -> 0.1.3.
Co-authored-by: Hanzo Dev <[email protected]>
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]>