Compare commits

...
Author SHA1 Message Date
antje 67975187d2 lsp: live code intelligence over a repo and its resolved deps
POST /v1/lsp — one door, because there is one value: a language server
rooted at a workspace, asked about a position. code (static index) and
lsp (live server) are two reads of the ONE checkout.

  {repo, rev?, path, line, character, method} → hover | definition |
  references | typeDefinition | implementation | documentSymbol |
  completion | diagnostics

Positions are the LSP's and pass through untouched: 0-based line, 0-based
UTF-16 character. Re-basing them would corrupt every multi-byte line, and
the callers already speak LSP.

Isolation is structural, not checked. The org comes from the validated
principal and is BOTH the pool key and the OWNER SEGMENT of the git URL;
a caller supplies only a repo slug. There is no input from which one
tenant can name another tenant's repository.

Scripts-off by default. A dependency fetch that runs dependency-authored
code is RCE triggered by whatever the caller asked us to check out, and it
buys nothing — servers resolve from source. So: npm ci --ignore-scripts,
cargo fetch (not build), go mod download; the python fetch builds sdists
and therefore does not run. rust-analyzer is additionally told not to run
build.rs or expand proc macros, because otherwise the server does at load
time exactly what the fetch was chosen to avoid. One predicate, one place.
The deployed worker must still be sandboxed — see fetchable's comment.

Language table ported verbatim from hanzo-tools-lsp (same binaries, same
argv, same root markers); install_cmd deliberately dropped — a worker that
can npm install -g at request time is one an attacker can make write to
its own filesystem.

server.go is the testable core: Content-Length JSON-RPC with a SINGLE
reader goroutine demultiplexing responses, server→client requests and
notifications. The python tool reads inline from each call, which drops
every frame that is not the awaited response — which is why it cannot
report diagnostics, and why an unanswered client/registerCapability
deadlocks it. 24 tests drive a fake server over in-process pipes; no
toolchain, no network.

Two bugs the tests found, both real in production:
  - Close() wrote a polite shutdown unconditionally, so a server that had
    stopped reading its stdin blocked it forever — holding a pool slot and,
    at Shutdown, the whole binary.
  - rel() compared a symlink-resolved root against an unresolved target, so
    any data dir with a symlink component (/var on a Mac, a mounted volume)
    made every location "outside" the checkout and handed the caller the
    worker's absolute path instead of a repo-relative one.

Metered, not Free: cost is the COLD start (checkout + fetch + first index),
not the query. Warm point queries are recorded and free, so the pricing
does not teach callers to re-key their workspace. Gate before the work.

apps/code has NO checkout to reuse — it indexes files POSTed to it and says
so. apps/deploy has the only working-tree checkout and is not importable
(it would link k8s into this binary). This is therefore a second one,
following deploy's invocation and hardened env exactly; the fix is hoisting
it into the root cloud package, not made here.

Mount order: apps.Wire() and its integers are gone. manifest.Apps slice
position IS the order, so lsp's row sits after code's, with order_test's
frozen sequence updated in the same commit.
2026-08-05 08:16:59 -07:00
antje 35686f3462 ai: say why deep_research is unwired — it is money, not plumbing
The comment blamed apps/answer for exposing no constructor. That is true and
it is not the reason.

Research carries an explicit 25-cent per-answer fee (apps/answer/mode.go),
charged through Bill.Gate on the request path where a payer has been resolved
and can be refused. A tool call has no payer, so a direct seam to the engine
would be an unbilled 25-cent operation an agent may invoke in a loop — free
inference, reached by the exact route this codebase keeps closing.

That the package makes it awkward is not an accident to route around: Params is
built from request-scoped billing context and Sink's methods are unexported, so
the money gate is structurally hard to bypass. Wiring it properly means an entry
that takes a payer and charges it — a billing decision, not an adapter.

web_search and fetch_url are different in KIND, not merely cheaper: their HTTP
routes gate on AUTHENTICATION, and the agent request reaching the tool was
already authenticated and metered at /v1/responses. In-process use matches how
they are reached over HTTP. deep_research does not.
2026-08-05 04:37:53 -07:00
antje 7921911f93 commerce: the peer-ledger test binds its socket inside the address cap
A unix socket address is capped near a hundred bytes, and t.TempDir embeds
the test's own long name — on darwin the bind failed on a discarded goroutine
error and every dial refused, so the suite reported a phantom (socket never
began listening) instead of the truth. A short anonymous dir keeps the
address inside the cap on every platform; on a Mac without a mounted tmpfs
the tests now fail for the honest reason instead — the pure-Go SQLCipher
codec refuses to decrypt to persistent storage, which is the fail-closed
property it exists for.
2026-08-05 01:53:59 -07:00
antje 628e510e75 commerce: the health probe becomes a typed op, and every survivor names why it stays raw
The probe was a closure marshalling a map — a route and nothing else, in none
of the five projections. It is now a typed op whose answer is byte-identical
to the map it replaces (the struct's field order mirrors the map's sorted
keys), pinned by test, and whose prose rides the handler's doc comment the
way every typed op's does — so its openapi.Describe entry is gone.

Every other raw registration now states its survival in one sentence. The
webhook intake speaks the provider's protocol (HMAC over the raw payload),
the invoice PDF serves bytes, the fail-closed wildcard must shadow every
method under every prefix, and the rest obey the module-handler rule, stated
once above Mount: a handler living in hanzoai/commerce behind unexported
internals cannot ride a typed op, and a typed twin would be a second
implementation of the same money move — the drift payments.go exists to
prevent. The typed door onto a module rail is that file's exported-core
pattern, which is module work.

The eight operations that said nothing about themselves — wire, the crypto
rail, and the saved-card family — now state their gate, their tenant scope
and what they fail closed on, which is what let the commerce subset
regenerate at all. That regeneration also repaired two stale artifacts the
committed subset carried: GET /v1/billing/methods claimed its portal
sibling's operationId (the duplicate the weave gate died on), and the two
saved-card POSTs were absent outright.

The fleet golden and the floor now agree with the module: the deposit proxy
and the webhook relay were removed upstream (deposits are commerce's own
rails; the real receiver is /v1/billing/webhooks/:provider), so the golden
drops those four operations, the commerce floor is hand-lowered to four, and
the dead prose goes with them.
2026-08-05 01:53:59 -07:00
20 changed files with 3199 additions and 30 deletions
+26 -6
View File
@@ -133,12 +133,32 @@ func Mount(app cloud.Router, deps cloud.Deps) error {
// and answers 401 to a service; routing our own calls back through it is what
// once fail-closed every completion at 503 on a healthy pod.
//
// deep_research is deliberately NOT installed yet: apps/answer builds its Params
// from unexported fields and exposes no constructor, so wiring it means giving
// that package an entry point rather than reaching into it from here. Until then
// the tool reports that it is unavailable in this deployment — which is the
// honest answer, and specifically not an empty result, because an agent told "no
// results" concludes the web holds nothing and answers from memory.
// deep_research is deliberately NOT installed, and the reason is MONEY rather
// than plumbing.
//
// Research carries an explicit per-answer FEE — 25 cents, apps/answer/mode.go —
// charged through Bill.Gate on the request path, where a payer has been
// resolved and can be refused. A tool call has no payer. Installing this seam
// with a direct call to the engine would therefore be an unbilled 25-cent
// operation an agent may invoke in a loop: free inference, arrived at by the
// exact route this codebase keeps closing.
//
// That apps/answer makes it awkward is not an accident to route around:
// Params is built from request-scoped billing context and Sink's methods are
// unexported, so the money gate is structurally hard to bypass. Wiring this
// properly means giving the package an entry that takes a payer and charges
// it — a billing decision, not an adapter.
//
// The two tools above are different in kind, not merely cheaper: their HTTP
// routes gate on AUTHENTICATION (a validated principal or the service key),
// and the agent request that reaches this tool was already authenticated and
// metered at /v1/responses. Using them in-process is consistent with how they
// are reached over HTTP; deep_research is not.
//
// Until then the tool reports that it is unavailable in this deployment — the
// honest answer, and specifically NOT an empty result: an agent told "no
// results" concludes the web holds nothing on the subject and answers from
// memory in a confident voice.
webtools.SetSearch(func(ctx context.Context, query string, limit int) ([]webtools.SearchResult, error) {
hits := websearch.Search(ctx, query, "")
if limit > 0 && len(hits) > limit {
+85 -8
View File
@@ -36,15 +36,10 @@ func init() {
// ---- /_/commerce — the operator surface the ingress withholds publicly ----
// The health probe is deliberately absent here: it is a typed op (mount.go),
// so its prose is the doc comment zipdoc lifts, and a Describe beside it would
// be a second prose source for one route.
func describeAdmin() {
openapi.Describe("/_/commerce/healthz", http.MethodGet,
"Liveness for the commerce subsystem",
"Answers ok whenever the commerce subsystem is mounted. It is registered BEFORE the "+
"module embed boots, so it keeps answering even when the embed failed and every "+
"business route is serving a fail-closed 503 — which is the point: it reports that the "+
"process is reachable, never that the money plane is healthy. Unauthenticated, and "+
"under /_ so the ingress does not expose it publicly.")
openapi.Describe("/_/commerce/providers", http.MethodGet,
"List the payment providers configured for your own tenant",
"Returns the caller's own tenant row projected to a public view with the KMS paths "+
@@ -173,6 +168,83 @@ func describeBilling() {
"downgrading to free, so a transient failure never reports a paid subscriber as "+
"unsubscribed.")
openapi.Describe("/v1/billing/wire", http.MethodGet,
"The receiving bank details for a wire top-up, with your payment reference",
"Answers the serving brand's OWN receiving account — bank name, account and routing "+
"numbers, SWIFT or IBAN — resolved from the host being paid on, never from the "+
"caller. The caller's billing key is rendered into BOTH the memo and the reference "+
"(TOPUP-, then the key in bank-safe characters), and that is the point: banks label "+
"the field differently, and the reference is what links an arriving wire to the "+
"account it credits. Nothing mints here — settlement is the operator's credit once "+
"the bank confirms receipt. An anonymous caller is 401, and a deployment holding no "+
"bank details answers 503 rather than a half-empty form.")
openapi.Describe("/v1/billing/crypto/options", http.MethodGet,
"The chains and tokens the crypto custody rail accepts right now",
"Returns the supported chain and token lists straight from the live custody processor, "+
"never from a hardcoded table — which is why a chain the signer fleet grows appears "+
"on every pay surface with no client change. The one rule to know: an unconfigured "+
"or unreachable custody service answers 503 quickly rather than an empty list, so a "+
"picker rendered from this can trust that what it shows can actually be used.")
openapi.Describe("/v1/billing/crypto/deposit", http.MethodPost,
"A custody deposit address of your own for a crypto top-up",
"Mints a per-payer deposit address at the custody signer for the asked chain and token "+
"(ethereum and usdc when unstated) and answers the intent — id, status, address and "+
"expiry, about a day out. The credited payer is the PINNED caller subject, never a "+
"body value, and the payer's open intent for the same asset is REUSED, so refreshing "+
"the page cannot spray key generations or strand funds across addresses. No balance "+
"moves here: the chain watcher credits on real confirmations, so a claimed deposit "+
"is worth nothing until the chain says otherwise. An unsupported chain is 400, an "+
"unavailable custody service 503 and an anonymous caller 401.")
openapi.Describe("/v1/billing/crypto/deposit/:id", http.MethodGet,
"One crypto deposit intent's state, until it settles",
"Reports the addressed intent as the watcher last left it — pending, confirming or "+
"succeeded — which is what the pay page polls after showing the address. The read is "+
"scoped to the caller: an intent belonging to another payer answers a 404 "+
"byte-identical to a genuine miss, so intent ids cannot be probed. An anonymous "+
"caller is 401.")
openapi.Describe("/v1/billing/methods", http.MethodGet,
"Your saved payment methods, masked — the customer read",
"Answers the caller's own saved methods as masked descriptors — brand, last four, "+
"expiry and the processor's reusable reference; no card number exists here to "+
"return. It is the CUSTOMER face of the same rows the portal read serves. The "+
"subject filter is forced to the validated caller for a browser, so one member can "+
"never enumerate another's cards; only a caller holding the internal service token "+
"keeps its own filter, inside the org the gateway pinned. A caller whose subject "+
"cannot be resolved gets an empty array, never the namespace.")
openapi.Describe("/v1/billing/methods", http.MethodPost,
"Save a card against your own account for later charges",
"Vaults the browser-tokenized card as a reusable card-on-file — validating it with the "+
"processor in the same act — and answers the stored, masked method; that vaulted "+
"card is what a renewal or auto-recharge bills later. The subject keys in the body "+
"are OVERWRITTEN with the validated caller's own before the handler runs, so a card "+
"can only ever attach to the caller's account whatever the body claims. A card with "+
"no tokenized nonce is refused 400 — without one there is nothing to bill, and the "+
"row would only look like a card — and a declined vault answers 402 with the bank's "+
"reason. The card number itself never reaches this service.")
openapi.Describe("/v1/billing/methods/:id", http.MethodDelete,
"Remove one of your saved payment methods",
"Soft-deletes the addressed method and, best-effort, withdraws the card from the "+
"processor's vault so nothing is left a later charge could bill. The id is resolved "+
"against the caller's own subject: another subject's card answers 404 — never 403, "+
"which would confirm the id exists. Removing the card an auto-recharge or a running "+
"subscription bills leaves that arrangement with nothing to charge; that is the "+
"customer's call to make. Answers the deleted flag with the id.")
openapi.Describe("/v1/billing/portal/methods", http.MethodPost,
"Save a card for a subject — the portal write",
"The service-token face of the customer card save, at its own address for the same "+
"reason the portal read is: the host that publishes the customer address proxies "+
"here rather than into itself. Same constructor, same vaulting, same masked answer. "+
"Only a caller holding the internal service token may name the subject, and only "+
"within the org the gateway pinned; a browser caller's subject keys are overwritten "+
"with its own, so this address grants a browser nothing the customer one does not.")
openapi.Describe("/v1/billing/portal/methods", http.MethodGet,
"Cards saved against the caller's org, masked — the portal read",
"Answers the org's saved payment methods as masked descriptors: brand, last four, expiry "+
@@ -522,6 +594,11 @@ func describePublic() {
"default-namespace read shared by every tenant rather than per-org data, and it is "+
"public and cacheable.")
// The deposit-proxy trio and the webhook relay are deliberately absent
// here: the module removed those routes — deposits are commerce's own rails
// now (topup/token, wire, crypto) and the real webhook receiver is POST
// /v1/billing/webhooks/:provider — and prose for a route that does not
// exist never renders, so keeping it would only preserve a dead claim.
openapi.Describe("/v1/commerce/tenant", http.MethodGet,
"The public tenant configuration a checkout page boots from",
"Answers the branding, identity issuer and client id, identity-verification config, "+
+63
View File
@@ -0,0 +1,63 @@
// Copyright © 2026 Hanzo AI. MIT License.
package commerce
import (
"encoding/json"
"io"
"net/http/httptest"
"strings"
"testing"
"github.com/zap-proto/zip"
)
// The health probe used to be a closure marshalling a map; it is a typed op
// now, and the conversion is only safe if nothing a probe reads moved. The raw
// handler answered 200 with {"service":"commerce","status":"ok"} — a map's
// keys render sorted — so the typed answer is pinned to those exact bytes, not
// to a decoded equivalent: probes parse this body, and a reordered or renamed
// field is a behavior change even when the JSON is "equal".
func TestHealth_AnswerIsByteIdentical(t *testing.T) {
app := zip.New(zip.Config{})
zip.Get(app, "/_/commerce/healthz", health)
resp, err := app.Test(httptest.NewRequest("GET", "/_/commerce/healthz", nil))
if err != nil {
t.Fatalf("GET /_/commerce/healthz: %v", err)
}
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
if resp.StatusCode != 200 {
t.Fatalf("want 200, got %d (%s)", resp.StatusCode, body)
}
if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "application/json") {
t.Fatalf("want an application/json answer, got %q", ct)
}
want := `{"service":"commerce","status":"ok"}`
if string(body) != want {
t.Fatalf("the probe body moved:\n got %s\n want %s", body, want)
}
}
// The conversion's point: the probe is a REGISTERED op now, so the document —
// and through it the MCP tool list, the CLI and every generated SDK — knows it
// exists. The raw route it replaced appeared in none of them.
func TestHealth_IsAPublishedOp(t *testing.T) {
app := zip.New(zip.Config{})
zip.Get(app, "/_/commerce/healthz", health)
spec, err := json.Marshal(app.OpenAPISpec())
if err != nil {
t.Fatalf("marshal spec: %v", err)
}
var doc struct {
Paths map[string]map[string]any `json:"paths"`
}
if err := json.Unmarshal(spec, &doc); err != nil {
t.Fatalf("unmarshal spec: %v", err)
}
if _, ok := doc.Paths["/_/commerce/healthz"]["get"]; !ok {
t.Fatalf("GET /_/commerce/healthz is not in the op registry — the probe went raw again; paths: %v", doc.Paths)
}
}
+12 -1
View File
@@ -25,6 +25,7 @@ import (
"net"
"net/http"
"net/http/httptest"
"os"
"testing"
"time"
@@ -42,7 +43,17 @@ import (
// the canonical "commerce" socket — the exact pair Mount publishes at boot.
func servePeerLedger(t *testing.T) finance.Client {
t.Helper()
t.Setenv("ZIP_RUNTIME_DIR", t.TempDir())
// A unix socket address is capped near a hundred bytes, and t.TempDir embeds
// this test's own long name — on darwin the bind failed on the discarded
// goroutine error and every dial below refused, so the suite was red on any
// Mac while green in CI. An anonymous short-named dir keeps the address
// inside the cap on every platform.
sockDir, err := os.MkdirTemp("", "zip")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { os.RemoveAll(sockDir) })
t.Setenv("ZIP_RUNTIME_DIR", sockDir)
fin := finance.New(t.TempDir())
finance.Publish(fin)
+71 -14
View File
@@ -163,10 +163,23 @@ func commerceMasterKey(master []byte, lg log.Logger) []byte {
// zipdoc lifts the doc comment off each typed op into zipdoc_gen.go, which is the
// ONLY way that prose reaches the plane's registry and the MCP tool list — Go
// drops comments at compile time. commerce's typed ops are its internal
// /finance/* plane ops (balance_rpc.go, credit_rpc.go, meter_rpc.go); its HTTP
// surface belongs to the embedded module and states its prose through
// /finance/* plane ops (balance_rpc.go, credit_rpc.go, meter_rpc.go) and the
// HTTP ops this repo owns (payments.go, invoices.go, the health probe); the
// surface the embedded module serves stays raw and states its prose through
// openapi.Describe instead (describe.go).
//
// THE MODULE-HANDLER RULE, stated once because most registrations below obey
// it: a registration that binds one of hanzoai/commerce's handlers stays raw by
// necessity, not preference. Such a handler is a func(*zip.Ctx) error whose
// behavior and response shape live in the module behind unexported internals,
// so a typed op — which carries only a context and its decoded input — cannot
// run it, and the only typed twin would be a SECOND implementation of the same
// money move: two sets of bounds to drift, two response shapes to disagree,
// on the one plane where a drifted field name is a customer incident. The typed
// door onto a module rail is the payments.go pattern — export a value-taking
// core from the module, then declare the op on it — which is module work, not a
// registration rewrite here.
//
//go:generate go run github.com/zap-proto/zip/cmd/zipdoc
// Mount boots commerce ON the shared zip app (native co-residence).
@@ -223,11 +236,9 @@ func Mount(app cloud.Router, deps cloud.Deps) error {
exposePayments(zapp)
exposeInvoices(zapp)
// Native zip health endpoint — registered FIRST so probes answer even when
// the embed fails below.
app.Get("/_/commerce/healthz", func(c *zip.Ctx) error {
return c.JSON(http.StatusOK, map[string]string{"status": "ok", "service": "commerce"})
})
// The liveness probe, registered FIRST so it answers even when the embed
// fails below. Typed, so the probe is a published op like every other.
zip.Get(zapp, "/_/commerce/healthz", health)
// commerce persists its per-org SQLite + `base` tree under <DataDir>/commerce,
// NEVER at DataDir directly: cloud already owns DataDir/orgs and DataDir/base,
@@ -313,13 +324,16 @@ func Mount(app cloud.Router, deps cloud.Deps) error {
// Provider webhook intake at the LIVE registered path. Chain mirrors the
// commerce-standalone posture: gated request context, then the sessionless
// HMAC-verified handler.
// HMAC-verified handler. It stays raw because it speaks the provider's
// protocol: the signature verifies the raw payload bytes, so there is no
// typed input to declare.
app.Post("/v1/billing/webhooks/:provider", commercemid.RequestContext(), commercebilling.HandleProviderWebhook)
// Durable-cron auto-recharge poke (COMMERCE_SERVICE_TOKEN bearer) at its
// live path — the retired /v1/billing/* forwarder's session gate 403'd it. Same gate
// chain the commerce route table uses: TokenRequired authenticates the
// service token, PlatformOnly authorizes the mint.
// service token, PlatformOnly authorizes the mint. It stays raw under the
// module-handler rule.
app.Post("/v1/billing/recharge/run-all",
commercemid.RequestContext(),
commercemid.TokenRequired(),
@@ -340,6 +354,7 @@ func Mount(app cloud.Router, deps cloud.Deps) error {
// (middleware.Mint: internal service token OR platform global admin, NEVER the
// org-level Admin bit), and TokenRequired + PlatformOnly is that gate here. An org
// admin must not be able to move their own org between sandbox and production.
// It stays raw under the module-handler rule.
app.Post("/v1/billing/mode",
commercemid.RequestContext(),
commercemid.TokenRequired(),
@@ -365,7 +380,8 @@ func Mount(app cloud.Router, deps cloud.Deps) error {
// app's manifest row, deeper than the bare /v1/billing stem, which is what makes the
// host route it here. The past-tense loops recorded below are the reason each of
// these registrations exists — history, not a live hazard — and dropping one now
// misses on the /v1 remainder instead of self-dispatching.
// misses on the /v1 remainder instead of self-dispatching. It stays raw under
// the module-handler rule.
app.Get("/v1/billing/plans", commercemid.RequestContext(), commercebilling.ListPlans)
// The rest of the console's billing READS, served co-resident for the SAME reason
@@ -385,11 +401,15 @@ func Mount(app cloud.Router, deps cloud.Deps) error {
// org-scoped, not subject-scoped, but PinBillingSubject is still the auth gate that
// keeps an unvalidated caller from reaching GetOrganization; its pinned (unused)
// subject params are ignored by that handler.
//
// Every row stays raw under the module-handler rule.
billingRead := []struct {
path string
h zip.Handler
}{
{"/v1/billing/invoices", commercebilling.ListInvoices},
// The PDF download also serves bytes — an attachment, not a JSON shape —
// which keeps it off the typed plane on its own.
{"/v1/billing/invoices/:id/pdf", commercebilling.DownloadInvoicePDF},
{"/v1/billing/subscriptions", commercebilling.ListBillingSubscriptions},
{"/v1/billing/alerts", commercebilling.ListSpendAlerts},
@@ -454,7 +474,7 @@ func Mount(app cloud.Router, deps cloud.Deps) error {
// maps ANY non-2xx to TierZenFree, so a refusal downgrades a paying customer
// exactly like the crash did — 60 rpm against 500 for pro — silently, with no
// error anywhere the customer or we would see. The tier has to RESOLVE, not
// merely stop crashing.
// merely stop crashing. It stays raw under the module-handler rule.
app.Get("/v1/billing/tier",
commercemid.RequestContext(),
iammiddleware.IAMTokenRequired(),
@@ -467,7 +487,7 @@ func Mount(app cloud.Router, deps cloud.Deps) error {
// Same chain as the reads: the credited payer is the PINNED caller subject,
// never a body value, and the handler reuses the payer's open intent so a
// refresh cannot spray keygens. No balance moves here — the chain watcher
// credits on real confirmations.
// credits on real confirmations. It stays raw under the module-handler rule.
app.Post("/v1/billing/crypto/deposit",
commercemid.RequestContext(),
iammiddleware.IAMTokenRequired(),
@@ -513,6 +533,7 @@ func Mount(app cloud.Router, deps cloud.Deps) error {
// Cross-ORG is the namespace and is closed for both principals — including the
// privileged one, which bypasses commerce's intra-org owner guard but never the
// namespace (commerce api/billing/payment_methods_tenant_test.go proves it).
// Both stay raw under the module-handler rule.
app.Get("/v1/billing/portal/methods",
commercemid.RequestContext(),
commercemid.TokenRequired(),
@@ -550,7 +571,8 @@ func Mount(app cloud.Router, deps cloud.Deps) error {
// signed-in customer got 401 listing their own cards and the checkout's
// prefill failed on every load. Served in-process instead: no hop to
// misconfigure, and the same pinned-subject gate as its portal twin, which
// is what keeps a caller inside its own account whatever it sends.
// is what keeps a caller inside its own account whatever it sends. All
// three stay raw under the module-handler rule.
app.Get("/v1/billing/methods",
commercemid.RequestContext(),
iammiddleware.IAMTokenRequired(),
@@ -570,6 +592,8 @@ func Mount(app cloud.Router, deps cloud.Deps) error {
commercebilling.DetachPaymentMethod,
)
// The portal save — the service-token twin of the customer POST above. It
// stays raw under the module-handler rule.
app.Post("/v1/billing/portal/methods",
commercemid.RequestContext(),
commercemid.TokenRequired(),
@@ -599,6 +623,7 @@ func Mount(app cloud.Router, deps cloud.Deps) error {
// the gateway-pinned X-Org-Id into Locals("organization"), which AuthorizeSpendCap reads.
// No PlatformOnly:
// authorize is a per-org cap read, not a cross-org mint (unlike auto-recharge/run-all).
// It stays raw under the module-handler rule.
app.Get("/v1/billing/alerts/authorize",
commercemid.RequestContext(),
commercemid.TokenRequired(),
@@ -623,6 +648,7 @@ func Mount(app cloud.Router, deps cloud.Deps) error {
// Forward) — never any authenticated member. commerce's own `user` group admits any
// member, which would let a compromised member key DELETE the org's cap (→ unbounded
// spend) or POST a 1¢ enforce cap (→ org-wide 402 DoS). requireSpendCapAdmin closes that.
// All three stay raw under the module-handler rule.
app.Post("/v1/billing/alerts",
commercemid.RequestContext(),
commercemid.TokenRequired(),
@@ -681,6 +707,8 @@ func Mount(app cloud.Router, deps cloud.Deps) error {
// PinBillingSubject so the subject it judges is the subject the
// charge credits, and before the handler so a refusal costs no
// card authorization. See risk.go.
// It stays raw under the module-handler rule; the typed door onto this same
// charge core is POST /v1/payments (payments.go).
app.Post("/v1/billing/topup/token",
accountclient.RequireCSRF(),
commercemid.RequestContext(),
@@ -706,6 +734,7 @@ func Mount(app cloud.Router, deps cloud.Deps) error {
// only ever resolve the caller's OWN org (its `userId` is honored only inside that
// bound) — the IDOR boundary the bridge set. The PAN never touches this binary:
// the nonce goes to Square and the settled charge is its own mint authority.
// It stays raw under the module-handler rule.
app.Post("/v1/billing/subscribe/card",
accountclient.RequireCSRF(),
commercemid.RequestContext(),
@@ -753,7 +782,8 @@ func Mount(app cloud.Router, deps cloud.Deps) error {
// is a 404 miss), so tenancy is the namespace IAMTokenRequired resolves and PinBillingSubject
// is the fail-closed-anon auth gate — its pinned subject params are ignored by these
// handlers (the SAME role it plays for the org-scoped payment-config read). The bridge's
// subject-pin was likewise a no-op for these, so nothing was dropped.
// subject-pin was likewise a no-op for these, so nothing was dropped. Both stay
// raw under the module-handler rule.
app.Post("/v1/billing/subscriptions/:id/cancel",
accountclient.RequireCSRF(),
commercemid.RequestContext(),
@@ -798,6 +828,30 @@ func Mount(app cloud.Router, deps cloud.Deps) error {
return nil
}
// liveness is the probe's answer. Service precedes Status because the raw
// handler this op replaced marshalled a map, whose keys render sorted — keeping
// that order keeps the body byte-identical for every probe already parsing it.
// The name is fleet-unique on purpose: the weave refuses one schema name with
// two shapes, and the health names were already taken by sibling subsystems.
type liveness struct {
// Service names the answering subsystem; it is always commerce.
Service string `json:"service"`
// Status is always ok: mounted is the only state that can answer.
Status string `json:"status"`
}
// Answers ok whenever the commerce subsystem is mounted. It is registered
// before the module embed boots, so it keeps answering even when the embed
// failed and every business route serves the fail-closed 503 — which is the
// point: it reports that the process is reachable, never that the money plane
// is healthy. Unauthenticated, and under /_ so the ingress withholds it
// publicly.
//
// A named handler, not a closure, so zipdoc can lift this prose into the registry.
func health(_ context.Context, _ *struct{}) (*liveness, error) {
return &liveness{Service: "commerce", Status: "ok"}, nil
}
// mountCommerceFailClosed serves an honest JSON 503 on every commerce prefix when
// the embed cannot boot, so /v1/commerce/* answers "commerce unavailable" instead
// commerceErrorScope confines commerce's JSON error envelope to commerce's OWN
@@ -837,6 +891,9 @@ func mountCommerceFailClosed(app cloud.Router) {
c.SetHeader("Content-Type", "application/json")
return c.Bytes(http.StatusServiceUnavailable, []byte(`{"error":"commerce unavailable","code":503}`))
}
// Raw, and All on purpose: the degraded surface must shadow every method
// under every commerce prefix, and a typed op names one method at one path —
// projecting this would publish operations that do not exist.
for _, p := range Prefixes {
app.All(p+"/*", failed)
}
+7
View File
@@ -7,6 +7,13 @@ import (
)
func init() {
zip.Describe("GET /_/commerce/healthz", zip.Doc{
Description: "Answers ok whenever the commerce subsystem is mounted. It is registered\nbefore the module embed boots, so it keeps answering even when the embed\nfailed and every business route serves the fail-closed 503 — which is the\npoint: it reports that the process is reachable, never that the money plane\nis healthy. Unauthenticated, and under /_ so the ingress withholds it\npublicly.\n\nA named handler, not a closure, so zipdoc can lift this prose into the registry.",
Fields: map[string]string{
"liveness.service": "Service names the answering subsystem; it is always commerce.",
"liveness.status": "Status is always ok: mounted is the only state that can answer.",
},
})
zip.Describe("GET /v1/billing/invoices/:id", zip.Doc{
Description: "Reads one invoice out of the caller's org.\n\nThe org scopes the read by construction — the store is namespaced to it — so an\nid belonging to another tenant is not found rather than found and then filtered.\n\nA named handler, not a closure, so zipdoc can lift this prose into the registry.",
Fields: map[string]string{
+8
View File
@@ -0,0 +1,8 @@
# Generated by plugin/gen-app-cmds. DO NOT EDIT.
#
# The build contract is mk/plugin.mk — one file carrying every target an app
# needs: build, test, vet, openapi, clean. This names the app(s) this package
# backs and includes it. Written from the same apps.Wire() parse that writes
# plugin/<app>/main.go, so an app cannot have a main and no Makefile.
APPS := lsp
include ../../mk/plugin.mk
+220
View File
@@ -0,0 +1,220 @@
package lsp
import (
"os"
"path/filepath"
"slices"
"strings"
)
// langs.go is the language table — the ONE place that answers three questions
// about a language: how to RECOGNIZE it in a checkout, how to START its server,
// and how to FETCH its dependencies.
//
// The servers and their argv are PORTED, not invented, from the Python tool at
// hanzo/python-sdk/pkg/hanzo-tools-lsp/hanzo_tools/lsp/lsp_tool.py (LSP_SERVERS):
// same binaries, same flags, same root markers, same extensions. A second
// opinion about how to spawn gopls is a second bug surface, so there is not one.
//
// What is NOT ported is install_cmd. The Python tool installs a language server
// on demand onto the machine it runs on; this service runs the server in a cloud
// image that already ships the toolchain (phase 2: the cloud-lsp Dockerfile). A
// cloud worker that can `npm install -g` at request time is a worker an attacker
// can make write to its own filesystem, so the capability is removed rather than
// guarded.
//
// # Scripts-off is the default, and it is stated here
//
// Fetching dependencies is the dangerous half of this service. `npm install`
// runs postinstall; `cargo build` runs build.rs; `pip install` of an sdist runs
// setup.py. Each is arbitrary code from a third party executing inside the
// worker — remote code execution by design, triggered by whatever the caller
// asked us to check out.
//
// It is also UNNECESSARY. A language server resolves definitions, references and
// types from SOURCE — the dependency's .go/.d.ts/.pyi/.rs files — not from the
// artifacts a build script produces. Turning scripts off costs some generated
// code and some proc-macro expansions; it does not cost go-to-definition.
//
// So Executes marks the fetches that run dependency-authored code, and fetchable
// (workspace.go) is the ONE predicate that reads it. Today it refuses them all.
type Lang struct {
// Name is BOTH the table key and the LSP languageId sent on didOpen. One
// string, so a language cannot be called one thing here and another on the
// wire. (The per-file refinement TypeScript needs — tsx vs jsx vs plain js —
// is a property of the FILE, not the language, and lives in ID.)
Name string
// Start is the argv that runs the server speaking JSON-RPC on its stdio.
Start []string
// Roots are the marker files that make a directory this language's root.
Roots []string
// Exts are the file extensions this server answers for.
Exts []string
// Fetch is the argv that populates the dependency tree, run once per
// checkout. Nil means the language has nothing to fetch.
Fetch []string
// Executes reports that Fetch runs code authored by the DEPENDENCIES rather
// than only downloading them. It is the whole of the scripts-off policy's
// input; see fetchable in workspace.go for the policy itself.
Executes bool
// Env is added to the server's and the fetch's environment.
Env []string
// Init is initializationOptions on the initialize request — server-specific
// settings. It is where a server that would otherwise run project code at
// load time is told not to.
Init map[string]any
}
// table is every language this service speaks, keyed by Lang.Name.
var table = map[string]Lang{
"go": {
Name: "go",
Start: []string{"gopls", "serve", "-mode=stdio"},
Roots: []string{"go.work", "go.mod", "go.sum"},
Exts: []string{".go"},
// `go mod download` resolves and verifies modules against the checksum
// database. It does NOT build them, so no module's code runs: the Go
// toolchain has no install-time hook to abuse. Executes stays false.
Fetch: []string{"go", "mod", "download"},
Env: []string{"GOWORK=auto", "GOFLAGS=-mod=mod"},
},
"python": {
Name: "python",
Start: []string{"pyright-langserver", "--stdio"},
Roots: []string{"pyproject.toml", "setup.py", "requirements.txt", "pyrightconfig.json"},
Exts: []string{".py", ".pyi"},
// uv sync BUILDS any dependency published only as an sdist, which runs
// that dependency's setup.py / PEP-517 backend as us. --no-install-project
// spares us the CHECKOUT's own build, not its dependencies'. So this one
// executes, and today it does not run: pyright reads .py/.pyi source and
// resolves the stdlib and any vendored packages without it.
Fetch: []string{"uv", "sync", "--frozen", "--no-install-project"},
Executes: true,
},
"typescript": {
Name: "typescript",
Start: []string{"typescript-language-server", "--stdio"},
Roots: []string{"tsconfig.json", "package.json"},
Exts: []string{".ts", ".tsx", ".js", ".jsx"},
// --ignore-scripts is the whole reason this fetch is allowed: it is npm's
// own switch for "place the tree, run none of its lifecycle hooks". The
// .d.ts files under node_modules are what the server actually reads.
Fetch: []string{"npm", "ci", "--ignore-scripts", "--no-audit", "--no-fund"},
},
"rust": {
Name: "rust",
Start: []string{"rust-analyzer"},
Roots: []string{"Cargo.toml"},
Exts: []string{".rs"},
// `cargo fetch` downloads and unpacks; it does not compile, so no build.rs
// runs. `cargo build` would, which is why the fetch is not that.
Fetch: []string{"cargo", "fetch", "--locked"},
// The fetch being safe is not enough: rust-analyzer COMPILES AND RUNS
// build.rs and expands proc macros at workspace load, by default. That is
// the same remote code execution the fetch was careful to avoid, arriving
// through the server instead. Both are turned off here — scripts-off has
// to hold for the server or it does not hold at all.
Init: map[string]any{
"cargo": map[string]any{"buildScripts": map[string]any{"enable": false}},
"procMacro": map[string]any{"enable": false},
},
},
"cpp": {
Name: "cpp",
Start: []string{"clangd"},
Roots: []string{"compile_commands.json", "CMakeLists.txt"},
Exts: []string{".cpp", ".cc", ".cxx", ".c", ".h", ".hpp"},
// No fetch: C++ has no dependency resolver to run. clangd answers from
// compile_commands.json when the checkout carries one and degrades to
// single-file mode when it does not.
},
}
// langFor picks the language for a repo-relative path by extension.
//
// Extension, not root markers, decides — the question being asked is "which
// server answers about THIS file", and a polyglot repo (a Go service with a
// TypeScript console) has several right answers at once, one per file. Roots
// then narrow WHERE that server is rooted, which is rootFor's job.
func langFor(path string) (Lang, bool) {
ext := strings.ToLower(filepath.Ext(path))
if ext == "" {
return Lang{}, false
}
// Deterministic: map iteration is randomized, and a file that resolved to a
// different server between two identical requests would answer differently
// for no reason the caller can see. Names are walked in sorted order.
for _, name := range langNames {
l := table[name]
if slices.Contains(l.Exts, ext) {
return l, true
}
}
return Lang{}, false
}
// langNames is table's keys in sorted order — the tie-break that makes langFor a
// function of its argument alone.
var langNames = func() []string {
names := make([]string, 0, len(table))
for name := range table {
names = append(names, name)
}
slices.Sort(names)
return names
}()
// rootFor finds the directory a server should be rooted at: the DEEPEST marker
// at or above the file, bounded by the checkout.
//
// Deepest wins because the marker nearest the file describes it best — a file in
// a repo whose root go.mod is the umbrella and whose subdirectory go.mod is the
// real module belongs to the subdirectory. With no marker anywhere the checkout
// root is the answer, which is what a single-file language wants.
//
// dir is the checkout root and is the hard ceiling: the walk starts there and
// only descends, so no marker outside the tenant's own tree can ever root a
// server.
func rootFor(dir, path string, l Lang) string {
best, cur := dir, dir
for _, seg := range strings.Split(filepath.Dir(path), string(filepath.Separator)) {
if seg == "" || seg == "." {
continue
}
cur = filepath.Join(cur, seg)
for _, marker := range l.Roots {
if _, err := os.Stat(filepath.Join(cur, marker)); err == nil {
best = cur
break
}
}
}
return best
}
// ID is the LSP languageId for one FILE. It is Name for every language but
// TypeScript, whose server distinguishes four dialects that share one toolchain
// — and gets the wrong answer for a React file told it is plain TypeScript.
// Ported from the Python tool's _open_document.
func (l Lang) ID(path string) string {
if l.Name != "typescript" {
return l.Name
}
switch strings.ToLower(filepath.Ext(path)) {
case ".tsx":
return "typescriptreact"
case ".jsx":
return "javascriptreact"
case ".js":
return "javascript"
default:
return "typescript"
}
}
+456
View File
@@ -0,0 +1,456 @@
// Package lsp is live semantic code intelligence — definitions, references,
// types, hover and diagnostics — over a repository AND its resolved
// dependencies, served from the cloud with no toolchain on the caller's machine.
//
// # One value
//
// An lsp query is a language server rooted at a workspace, asked about a
// position. Everything on the wire is that value spelled out: WHICH workspace
// (repo, rev), WHICH position (path, line, character), and WHICH question
// (method). There is one door, POST /v1/lsp, because there is one value — eight
// endpoints differing only in a verb would be eight spellings of it.
//
// lsp and apps/code are two reads of the SAME checkout, not two systems: code is
// the static index (lexical, symbolic, semantic — fast, approximate, always
// available), lsp is the live server (exact, typed, resolves through
// dependencies, costs a cold start). An agent uses code to find candidates and
// lsp to be certain.
//
// # Positions are the LSP's, not a translation of them
//
// line and character are 0-BASED, and character counts UTF-16 code units, per the
// LSP specification. That is deliberately not the 1-based line an editor shows a
// human: this door's callers are agents and editors that already speak LSP, and a
// service that silently re-based positions would corrupt every multi-byte line —
// an emoji before the cursor is one UTF-16 unit in the protocol's arithmetic and
// two in Go's. Positions pass through untouched, so the protocol's answer is the
// answer.
//
// # Isolation
//
// Every request resolves its org from the validated principal, and that org is
// BOTH the pool key and the owner segment of the git URL. A caller supplies a
// repo slug, never a URL and never an owner. There is no input from which one
// tenant could name another tenant's repository.
package lsp
import (
"context"
"encoding/json"
"fmt"
"path/filepath"
"strings"
"time"
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/apps/principal"
"github.com/zap-proto/zip"
)
// diagSettle is how long diagnostics must stay unchanged before the snapshot is
// taken. See Conn.Diagnostics — LSP has no completion signal for them.
const diagSettle = 400 * time.Millisecond
// Query is one position question against one repository.
type Query struct {
// Repo is the repository NAME within the caller's own org, e.g. "cloud".
// Not a URL and not an owner/name pair: the owner is the validated
// principal's org, so this names a repository the caller already owns.
Repo string `json:"repo"`
// Rev is a branch, tag or commit sha. Empty means the default branch. A
// workspace is keyed by revision, so pinning a sha is what makes an answer
// reproducible.
Rev string `json:"rev,omitempty"`
// Path is the repo-relative file, e.g. "apps/lsp/server.go".
Path string `json:"path"`
// Line is 0-based, per the LSP specification.
Line int `json:"line"`
// Character is a 0-based UTF-16 code-unit offset within Line, per the LSP
// specification — not a byte offset and not a rune index.
Character int `json:"character"`
// Method is the question: hover, definition, references, typeDefinition,
// implementation, documentSymbol, completion or diagnostics.
Method string `json:"method"`
}
// Answer carries whichever result the method produces. Exactly one of the result
// fields is populated; the rest are omitted, so a client reads the field its
// method names and never has to discriminate a union.
type Answer struct {
Method string `json:"method"`
Repo string `json:"repo"`
Rev string `json:"rev,omitempty"`
Path string `json:"path"`
Lang string `json:"lang"`
// Cold reports that this request paid for a workspace cold start — the
// checkout, the dependency fetch and the server's first index. It is the
// billed event, surfaced so a caller can see what it was charged for.
Cold bool `json:"cold"`
Locations []Location `json:"locations,omitempty"`
Hover string `json:"hover,omitempty"`
Symbols []Symbol `json:"symbols,omitempty"`
Completions []Completion `json:"completions,omitempty"`
Diagnostics []Diagnostic `json:"diagnostics,omitempty"`
}
// Location is one place in the workspace. Path is repo-relative when the target
// is inside the checkout; for a target in the dependency cache it is the absolute
// path the server reported, which is what makes "definition in a dependency"
// answerable at all.
type Location struct {
Path string `json:"path"`
Range Range `json:"range"`
}
// Position is the LSP's: 0-based line, 0-based UTF-16 character.
type Position struct {
Line int `json:"line"`
Character int `json:"character"`
}
type Range struct {
Start Position `json:"start"`
End Position `json:"end"`
}
type Symbol struct {
Name string `json:"name"`
Kind int `json:"kind"`
Detail string `json:"detail,omitempty"`
Range Range `json:"range"`
}
type Completion struct {
Label string `json:"label"`
Kind int `json:"kind,omitempty"`
Detail string `json:"detail,omitempty"`
}
// Diagnostic is one problem the server reported. Severity is the LSP's: 1 error,
// 2 warning, 3 information, 4 hint.
type Diagnostic struct {
Range Range `json:"range"`
Severity int `json:"severity,omitempty"`
Code any `json:"code,omitempty"`
Source string `json:"source,omitempty"`
Message string `json:"message"`
}
// zipdoc lifts the doc comment off the typed op and its In/Out fields into
// zipdoc_gen.go, which is the ONLY way that prose reaches the published document
// and the MCP tool list — Go drops comments at compile time. Run by
// `make -C apps/lsp describe`.
//
//go:generate go run github.com/zap-proto/zip/cmd/zipdoc
// ask resolves one position in one repository through a live language server:
// definition, references, type, implementation, hover, document symbols,
// completion or diagnostics — over the repo AND its resolved dependencies, with
// no toolchain on the caller's machine.
//
// Positions are the LSP's: line and character are 0-BASED and character counts
// UTF-16 code units, so an editor's 1-based line must have 1 subtracted before it
// is sent. The repository is named by slug and is always one in the caller's own
// org. rev pins a branch, tag or commit sha; empty means the default branch.
//
// The first query against a (repo, rev) pays a cold start — checkout, dependency
// fetch and the server's first index — and is the billed event; later queries
// against the same revision are served from the warm workspace and are free. The
// answer says which it was.
//
// Example: {"repo":"cloud","path":"apps/lsp/server.go","line":120,"character":18,"method":"definition"}
func (s *state) ask(ctx context.Context, in *Query) (*Answer, error) {
org, ok := principal.OrgFrom(ctx)
if !ok {
return nil, zip.ErrForbidden("valid principal required")
}
method := strings.TrimSpace(in.Method)
if !known(method) {
return nil, zip.ErrBadRequest("method must be one of " + strings.Join(methods, ", "))
}
repo := strings.TrimSpace(in.Repo)
if !slug.MatchString(repo) {
return nil, zip.ErrBadRequest("repo must be a repository name in your org")
}
rev := strings.TrimSpace(in.Rev)
if rev != "" && !revision.MatchString(rev) {
return nil, zip.ErrBadRequest("rev must be a branch, tag or commit sha")
}
if in.Line < 0 || in.Character < 0 {
return nil, zip.ErrBadRequest("line and character are 0-based and cannot be negative")
}
// MONEY GATE — before the checkout, so an out-of-funds caller is refused
// rather than served work nobody can be billed for.
c, onHTTP := cloud.Request(ctx)
if onHTTP {
if err := s.gate(ctx, c, org); err != nil {
return nil, err
}
}
tree, cold, err := s.pool.get(ctx, key{org: org, repo: repo, rev: rev}, in.Path)
if err != nil {
s.Log.Warn("lsp workspace failed", "org", org, "repo", repo, "err", err)
return nil, zip.ErrInternal("workspace unavailable")
}
abs, err := clean(tree.dir, in.Path)
if err != nil {
return nil, zip.ErrBadRequest(err.Error())
}
uri, err := tree.conn.Open(tree.lang, abs)
if err != nil {
return nil, zip.ErrInternal("open document")
}
out := &Answer{
Method: method, Repo: repo, Rev: rev,
Path: in.Path, Lang: tree.lang.Name, Cold: cold,
}
if err := s.resolve(ctx, tree, out, uri, in); err != nil {
s.Log.Warn("lsp query failed", "org", org, "repo", repo, "method", method, "err", err)
return nil, zip.ErrInternal("language server did not answer")
}
if onHTTP {
s.charge(c, org, method, cold)
}
return out, nil
}
// resolve asks the server the one question and folds its reply into out.
//
// diagnostics is the outlier and is handled first: it is not a request at all in
// LSP but an unsolicited notification the server publishes after didOpen, so it
// is collected rather than called.
func (s *state) resolve(ctx context.Context, t *Tree, out *Answer, uri string, in *Query) error {
ctx, cancel := context.WithTimeout(ctx, callWait)
defer cancel()
if in.Method == "diagnostics" {
out.Diagnostics = t.conn.Diagnostics(ctx, uri, diagSettle)
if out.Diagnostics == nil {
out.Diagnostics = []Diagnostic{}
}
return nil
}
doc := map[string]any{"uri": uri}
pos := map[string]any{"line": in.Line, "character": in.Character}
params := map[string]any{"textDocument": doc, "position": pos}
var call string
switch in.Method {
case "hover":
call = "textDocument/hover"
case "definition":
call = "textDocument/definition"
case "typeDefinition":
call = "textDocument/typeDefinition"
case "implementation":
call = "textDocument/implementation"
case "completion":
call = "textDocument/completion"
case "references":
call = "textDocument/references"
params["context"] = map[string]any{"includeDeclaration": true}
case "documentSymbol":
call = "textDocument/documentSymbol"
params = map[string]any{"textDocument": doc} // no position: the whole file
default:
return fmt.Errorf("unroutable method %q", in.Method) // known() already refused this
}
raw, err := t.conn.Call(ctx, call, params)
if err != nil {
return err
}
fold(out, in.Method, raw, t.dir)
return nil
}
// fold decodes the server's result into the field the method names.
//
// A null result is not an error: "no definition here" is a real, useful answer,
// and it arrives as JSON null. Every branch therefore leaves out's slice empty
// rather than failing, so a caller distinguishes "nothing found" from "the server
// broke" by status code and not by guesswork.
func fold(out *Answer, method string, raw json.RawMessage, dir string) {
if len(raw) == 0 || string(raw) == "null" {
return
}
switch method {
case "hover":
out.Hover = hover(raw)
case "documentSymbol":
out.Symbols = symbols(raw)
case "completion":
out.Completions = completions(raw)
default: // every location-shaped method
out.Locations = locations(raw, dir)
}
}
// locations decodes the three shapes a location-returning request may answer with
// — a single Location, an array of them, or an array of LocationLink (the
// linkSupport form, whose target range lives under a different key). All three
// are in the specification and gopls, rust-analyzer and tsserver do not agree on
// which to send, so all three are read.
func locations(raw json.RawMessage, dir string) []Location {
var many []struct {
URI string `json:"uri"`
Range Range `json:"range"`
TargetURI string `json:"targetUri"`
Target Range `json:"targetSelectionRange"`
}
if json.Unmarshal(raw, &many) != nil {
var one struct {
URI string `json:"uri"`
Range Range `json:"range"`
}
if json.Unmarshal(raw, &one) != nil || one.URI == "" {
return []Location{}
}
return []Location{{Path: rel(uriPath(one.URI), dir), Range: one.Range}}
}
out := make([]Location, 0, len(many))
for _, m := range many {
uri, rng := m.URI, m.Range
if uri == "" { // a LocationLink
uri, rng = m.TargetURI, m.Target
}
p := uriPath(uri)
if p == "" {
continue // a jar:/zipfile: target names no path of ours
}
out = append(out, Location{Path: rel(p, dir), Range: rng})
}
return out
}
// rel renders a path repo-relative when it is inside the checkout. A path OUTSIDE
// it — a definition in the module cache — is returned as the server gave it,
// because that is a real location and pretending otherwise would lose it.
//
// The checkout is matched in BOTH spellings, resolved and raw. A language server
// reports paths as the OS handed them to it, and a data directory reached through
// a symlink — /var → /private/var on a Mac, a mounted volume in the cluster —
// gives one file two spellings. Comparing one resolved path against one raw one
// puts every location "outside" the checkout, and the fallback then hands the
// caller the worker's ABSOLUTE path for files that were in their own repo all
// along.
//
// This is presentation, not the security boundary: [clean] is what proves a
// requested path is inside the tree, and it resolves symlinks precisely because
// it has to.
func rel(abs, dir string) string {
if abs == "" {
return ""
}
roots := []string{dir}
if resolved, err := filepath.EvalSymlinks(dir); err == nil && resolved != dir {
roots = append(roots, resolved)
}
for _, root := range roots {
if r, err := filepath.Rel(root, abs); err == nil && !strings.HasPrefix(r, "..") {
return filepath.ToSlash(r)
}
}
return abs
}
// hover decodes MarkupContent, a MarkedString, or an array of either.
func hover(raw json.RawMessage) string {
var h struct {
Contents json.RawMessage `json:"contents"`
}
if json.Unmarshal(raw, &h) != nil || len(h.Contents) == 0 {
return ""
}
var markup struct {
Value string `json:"value"`
}
if json.Unmarshal(h.Contents, &markup) == nil && markup.Value != "" {
return markup.Value
}
var plain string
if json.Unmarshal(h.Contents, &plain) == nil {
return plain
}
var list []json.RawMessage
if json.Unmarshal(h.Contents, &list) != nil {
return ""
}
parts := make([]string, 0, len(list))
for _, item := range list {
if json.Unmarshal(item, &markup) == nil && markup.Value != "" {
parts = append(parts, markup.Value)
continue
}
if json.Unmarshal(item, &plain) == nil && plain != "" {
parts = append(parts, plain)
}
}
return strings.Join(parts, "\n\n")
}
func symbols(raw json.RawMessage) []Symbol {
var list []struct {
Name string `json:"name"`
Kind int `json:"kind"`
Detail string `json:"detail"`
Range Range `json:"range"`
Location struct {
Range Range `json:"range"`
} `json:"location"`
}
if json.Unmarshal(raw, &list) != nil {
return []Symbol{}
}
out := make([]Symbol, 0, len(list))
for _, s := range list {
rng := s.Range
if rng == (Range{}) { // SymbolInformation carries it under location
rng = s.Location.Range
}
out = append(out, Symbol{Name: s.Name, Kind: s.Kind, Detail: s.Detail, Range: rng})
}
return out
}
// completions decodes CompletionList or a bare CompletionItem array, and bounds
// the reply: a server offering every identifier in a large dependency tree can
// answer with tens of thousands of items, which is not an answer anybody reads.
func completions(raw json.RawMessage) []Completion {
const maxItems = 200
type item struct {
Label string `json:"label"`
Kind int `json:"kind"`
Detail string `json:"detail"`
}
var list struct {
Items []item `json:"items"`
}
var items []item
if json.Unmarshal(raw, &list) == nil && list.Items != nil {
items = list.Items // CompletionList
} else if json.Unmarshal(raw, &items) != nil {
return []Completion{} // neither shape
}
if len(items) > maxItems {
items = items[:maxItems]
}
out := make([]Completion, 0, len(items))
for _, i := range items {
out = append(out, Completion{Label: i.Label, Kind: i.Kind, Detail: i.Detail})
}
return out
}
+99
View File
@@ -0,0 +1,99 @@
package lsp
// meter.go charges for this surface, on the Bill/Gate pattern apps/answer and
// every other metered app already use — Base.Bill is the per-org ResourceMeter
// the composition root builds, and the prepaid Commerce ledger behind it is the
// one ledger. Nothing here is a second accounting of anything.
//
// # What is billed, and why it is the cold start
//
// A COLD start is a git checkout, a dependency fetch and a language server
// indexing a repository: seconds to minutes of CPU, hundreds of megabytes, and a
// process that then sits resident. That is the cost this service actually incurs,
// so that is the event that carries a fee.
//
// A WARM point query is a JSON-RPC round trip to a process that is already
// running and already holds the index. It costs microseconds. Charging per query
// would price the cheap thing and hide the expensive one, which teaches callers
// to re-key their workspace instead of reusing it — the opposite of what the pool
// is for. Warm queries are recorded for attribution and cost nothing.
//
// The GATE runs before the work, not after: an out-of-funds caller gets a clean
// 402 instead of a checkout we performed and cannot bill.
import (
"context"
"strings"
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/apps/metering"
"github.com/hanzoai/cloud/apps/principal"
"github.com/zap-proto/zip"
)
// kind names this surface on the ledger — the scope a debit is attributed to and
// the one string commerce groups these charges by.
const kind = "lsp"
// coldCents is the flat fee for one cold start. Flat rather than measured because
// the caller chooses the repository, not the cost of indexing it, and a bill that
// varies with how large somebody else's dependency tree turned out to be is not
// one anybody can predict.
const coldCents = 2
// payer is the org whose ledger this request debits: principal.Ledger, the
// SELECTED billing org, which a SuperAdmin masquerade deliberately moves off the
// effective org — so it is not what principal.Org carries. It falls back to the
// effective org, which is the answer for every caller who is not masquerading.
//
// It is read from the REQUEST and never from a body field: a caller-supplied
// payer is a caller billing somebody else. Empty off the HTTP path, which is the
// unbilled default — and the door has already refused anything without a
// validated principal before this runs.
func payer(c *zip.Ctx, org string) string {
if subject := principal.Ledger(c); subject != "" {
return subject
}
return org
}
// gate refuses the request unless the payer can cover a cold start.
//
// It gates the COLD price on every request, including ones that will turn out to
// be warm, because whether a workspace is warm is not known until the pool is
// asked — and it is not a fact about the caller. Gating the worst case and
// charging the real one is the order that never bills for work it refused.
func (s *state) gate(ctx context.Context, c *zip.Ctx, org string) error {
subject := payer(c, org)
if subject == "" {
return nil // off the HTTP path: unbilled, and already principal-gated
}
// The project here is the VALIDATED cap scope — the per-project spend limit
// the gate enforces — which is a different question from the attribution
// scope the ledger records below, and answered by a different call.
project, validated := principal.ValidatedProject(c)
if err := s.Bill.Gate(ctx, subject, project, validated, kind, coldCents); err != nil {
return cloud.DenyResource(c, err)
}
return nil
}
// charge records the debit once the work is done. A warm query debits zero — it
// is still recorded, so per-project attribution sees the traffic.
func (s *state) charge(c *zip.Ctx, org, method string, cold bool) {
subject := payer(c, org)
if subject == "" {
return
}
var cents int64
if cold {
cents = coldCents
}
s.Bill.MeterUsage(subject, kind, metering.Usage{
Model: method,
AmountCents: cents,
Project: principal.ProjectScope(c),
RequestID: strings.Clone(strings.TrimSpace(c.Header("X-Request-Id"))),
ClientIP: strings.Clone(cloud.ClientIP(c)),
})
}
+124
View File
@@ -0,0 +1,124 @@
package lsp
// mount.go is this subsystem's registration: build the state, bind the door.
//
// It follows apps/code's Mount exactly — same signature, same fail-closed
// argument checks, same package-global for Shutdown to reach, and routes() as a
// FUNCTION rather than inline so this package's tests drive the REAL registration
// instead of a reconstruction of it that can drift from what the binary serves.
//
// A NOTE ON MOUNT ORDER. The brief asked for "order ~135, before ai's /v1/*
// catch-all at 150". Those integers no longer exist: apps.Wire() is gone, and
// manifest/apps.go is the hand-authored fleet list whose SLICE POSITION is the
// order (build.go: "There is NO Order field"). The "Order 134" in code.go's
// header is a comment describing a position, not a field. So lsp's row sits
// immediately after code's in manifest.Apps — the same intent expressed in the
// mechanism that actually exists — and manifest/order_test.go's frozen sequence
// is updated in the same commit, which is how a reorder stays a decision.
// Routing does not in fact depend on it: nested static prefixes resolve by
// SPECIFICITY, so /v1/lsp beats ai's /v1 wherever it registers.
import (
"context"
"fmt"
"os"
"strings"
"github.com/hanzoai/cloud"
"github.com/zap-proto/zip"
)
// forgeDefault is the git host repositories are checked out from. WHICH host
// this deployment's repositories live on is infra wiring, so it is env with a
// constant default — not a policy row, and never a request field: see checkout,
// where the owner segment is the validated principal's org and the caller
// supplies only a slug.
const forgeDefault = "https://git.hanzo.ai"
// state is the subsystem: the shared Base plus the warm-workspace pool. It holds
// no org in a field — the org is a parameter on every call, so one process serves
// all orgs and an org can never be captured from stale state.
type state struct {
cloud.Base
forge string
pool *pool
}
var mounted *state
// Mount wires /v1/lsp onto app per HIP-0106.
func Mount(app cloud.Router, deps cloud.Deps) error {
if app == nil {
return fmt.Errorf("lsp.Mount: nil app")
}
if deps.Logger == nil {
return fmt.Errorf("lsp.Mount: nil deps.Logger")
}
if deps.DataDir == "" {
return fmt.Errorf("lsp.Mount: empty DataDir")
}
s := &state{Base: cloud.NewBase(deps, "lsp"), forge: forge()}
s.pool = newPool(s.build)
mounted = s
if err := routes(app, s); err != nil {
return err
}
s.Log.Info("lsp surface mounted (native)",
"brand", deps.Brand, "forge", s.forge, "languages", len(table))
return nil
}
// routes registers the /v1/lsp surface: ONE typed op, because there is one value.
// Typed rather than raw so the OpenAPI operation, the MCP tool, the CLI command
// and every generated SDK method are all projected from this one entry — a
// surface built for coding agents, where the MCP tool is the point.
func routes(app cloud.Router, s *state) error {
// cloud.Bridge is not installed here: the composer installs it once at the
// root, after the identity check that mints the validated org and before any
// subsystem registers a route — an order only the whole program can assert.
//
// Grouped at "/v1" with "/lsp" as the member, the shape apps/bots and
// apps/account already use for a route that IS its prefix. The obvious
// spelling — Group("/v1/lsp") with an empty member — addresses "/v1/lsp/",
// a different path from the "/v1/lsp" the manifest row publishes, and that
// one-character mismatch between what the binary serves and what the host
// routes is invisible until a client 404s.
g := app.Group("/v1")
zip.Post(g, "/lsp", s.ask)
return nil
}
// Shutdown closes every warm workspace: each is a live subprocess and a directory
// on disk, and neither is reclaimed by the process exiting cleanly. Idempotent.
func Shutdown(_ context.Context) error {
if mounted == nil {
return nil
}
mounted.pool.closeAll()
mounted = nil
return nil
}
// Invalidate drops every warm revision of one repository, for when a push or a
// re-index makes a checkout stale.
//
// It is exported and unused IN THIS BINARY, which is the honest state of it:
// apps/code and apps/lsp are separate processes, so code cannot call this
// in-process when it re-indexes, and the push signal has to arrive over the bus.
// That is phase 2. It is mostly self-correcting meanwhile — workspaces are keyed
// by revision, so new content is a new key — and the gap is a caller that tracks
// a branch while the branch moves.
func Invalidate(org, repo string) {
if mounted != nil {
mounted.pool.drop(org, repo)
}
}
func forge() string {
if v := strings.TrimSpace(os.Getenv("LSP_FORGE_URL")); v != "" {
return v
}
return forgeDefault
}
+472
View File
@@ -0,0 +1,472 @@
package lsp
// server.go is a JSON-RPC 2.0 client speaking the Language Server Protocol over
// a language server's stdio: Content-Length framing, one multiplexed connection,
// initialize → didOpen → ask.
//
// It is the testable core of this app, so it knows nothing about orgs, HTTP,
// billing or git. It is given a reader, a writer and a Lang; everything else is
// the caller's. [newConn] is the seam that makes that true — [Start] spawns a
// real server and hands it here, and a test hands it an in-process pipe, and
// both drive the identical code.
//
// The one structural decision: a SINGLE reader goroutine owns the stdout side
// and demultiplexes it. LSP is not request/response — a server interleaves
// responses, its own requests, and unsolicited notifications on the same stream,
// and diagnostics are only ever the third kind. Reading inline from Call (which
// is what the Python tool does) drops every message that is not the response
// being waited for, which is why that tool cannot report diagnostics without a
// second read path. One reader, three destinations, no second path.
import (
"bufio"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/url"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
)
// maxFrame bounds one inbound message. A language server is a subprocess we
// spawned, but it is parsing a tenant's checkout, and a hostile input that makes
// it emit an enormous frame must not become an allocation the pod dies on.
const maxFrame = 32 << 20 // 32 MiB
// handshake, request and shutdown budgets. A cold rust-analyzer or gopls indexes
// before it answers, so initialize is generous where a point query is not.
const (
initWait = 90 * time.Second
callWait = 30 * time.Second
closeWait = 3 * time.Second
)
// Conn is one live language server: a process (or, in a test, a pipe) plus the
// bookkeeping to route its stream. Safe for concurrent use — Call may be entered
// from several requests against the same warm workspace.
type Conn struct {
w io.WriteCloser
stop func() // releases the transport (kills the process)
wmu sync.Mutex // serializes frame writes; a torn frame desynchronizes the stream
seq atomic.Int64
mu sync.Mutex
wait map[int64]chan msg
dmu sync.Mutex
diag map[string][]Diagnostic
done chan struct{} // closed when the reader stops
err error // why it stopped; read only after done
once sync.Once
}
// msg is any JSON-RPC frame in either direction. Which of the four kinds it is
// follows from which fields are present, which is what [Conn.read] switches on.
type msg struct {
ID json.RawMessage `json:"id,omitempty"`
Method string `json:"method,omitempty"`
Params json.RawMessage `json:"params,omitempty"`
Result json.RawMessage `json:"result,omitempty"`
Error *rpcError `json:"error,omitempty"`
}
type rpcError struct {
Code int `json:"code"`
Message string `json:"message"`
}
func (e *rpcError) Error() string { return fmt.Sprintf("lsp: rpc %d: %s", e.Code, e.Message) }
// Start spawns l's language server rooted at root and completes the handshake.
//
// The process is deliberately NOT tied to ctx: a Conn outlives the request that
// warmed it (that is the entire point of the pool), so binding it to the
// request's context would kill the server the moment the caller got its answer.
// [Conn.Close] is what ends it.
func Start(ctx context.Context, l Lang, root string) (*Conn, error) {
if len(l.Start) == 0 {
return nil, fmt.Errorf("lsp: language %q has no server", l.Name)
}
cmd := exec.Command(l.Start[0], l.Start[1:]...)
cmd.Dir = root
cmd.Env = env(l)
// The server's stderr is its own log, not ours to relay: it can be chatty and
// it can echo tenant source. Dropping it keeps both out of our logs.
cmd.Stderr = io.Discard
in, err := cmd.StdinPipe()
if err != nil {
return nil, fmt.Errorf("lsp: stdin: %w", err)
}
out, err := cmd.StdoutPipe()
if err != nil {
return nil, fmt.Errorf("lsp: stdout: %w", err)
}
if err := cmd.Start(); err != nil {
return nil, fmt.Errorf("lsp: start %s: %w", l.Start[0], err)
}
c := newConn(in, out, func() {
if cmd.Process != nil {
_ = cmd.Process.Kill()
}
_ = cmd.Wait()
})
if err := c.handshake(ctx, l, root); err != nil {
c.Close()
return nil, err
}
return c, nil
}
// newConn wires a Conn onto an already-open transport and starts its reader.
// Start uses it for a real process; a test uses it for a pipe.
func newConn(w io.WriteCloser, r io.Reader, stop func()) *Conn {
c := &Conn{
w: w,
stop: stop,
wait: make(map[int64]chan msg),
diag: make(map[string][]Diagnostic),
done: make(chan struct{}),
}
go c.read(bufio.NewReaderSize(r, 64<<10))
return c
}
// handshake performs initialize → initialized. Ported from the Python tool's
// _initialize_lsp, with initializationOptions added (langs.go: Init) because
// that is where rust-analyzer's build scripts get turned off.
func (c *Conn) handshake(ctx context.Context, l Lang, root string) error {
ctx, cancel := context.WithTimeout(ctx, initWait)
defer cancel()
uri := pathURI(root)
params := map[string]any{
"processId": os.Getpid(),
"rootUri": uri,
"rootPath": root,
"capabilities": map[string]any{
"workspace": map[string]any{"workspaceFolders": true, "applyEdit": false},
"textDocument": map[string]any{
"synchronization": map[string]any{"dynamicRegistration": true, "didSave": true},
"completion": map[string]any{"completionItem": map[string]any{"snippetSupport": true}},
"hover": map[string]any{"contentFormat": []string{"markdown", "plaintext"}},
"definition": map[string]any{"dynamicRegistration": true, "linkSupport": true},
"references": map[string]any{"dynamicRegistration": true},
"publishDiagnostics": map[string]any{"relatedInformation": false},
},
},
"workspaceFolders": []map[string]any{{"uri": uri, "name": filepath.Base(root)}},
}
if l.Init != nil {
params["initializationOptions"] = l.Init
}
if _, err := c.Call(ctx, "initialize", params); err != nil {
return fmt.Errorf("lsp: initialize: %w", err)
}
return c.Notify("initialized", map[string]any{})
}
// Call issues a request and waits for the response with that id.
func (c *Conn) Call(ctx context.Context, method string, params any) (json.RawMessage, error) {
id := c.seq.Add(1)
ch := make(chan msg, 1)
c.mu.Lock()
c.wait[id] = ch
c.mu.Unlock()
defer func() {
c.mu.Lock()
delete(c.wait, id)
c.mu.Unlock()
}()
if err := c.send(map[string]any{
"jsonrpc": "2.0", "id": id, "method": method, "params": params,
}); err != nil {
return nil, err
}
select {
case m := <-ch:
if m.Error != nil {
return nil, m.Error
}
return m.Result, nil
case <-c.done:
return nil, c.stopped()
case <-ctx.Done():
return nil, ctx.Err()
}
}
// Notify sends a notification — no id, so no response is expected or waited for.
func (c *Conn) Notify(method string, params any) error {
return c.send(map[string]any{"jsonrpc": "2.0", "method": method, "params": params})
}
// Open sends textDocument/didOpen for a file already inside the workspace, which
// is what makes the server willing to answer about it. Content is read from
// disk: the checkout is the source of truth, and accepting caller-supplied text
// would let one request answer about a file the tenant's repo does not have.
func (c *Conn) Open(l Lang, abs string) (string, error) {
text, err := os.ReadFile(abs)
if err != nil {
return "", fmt.Errorf("lsp: read %s: %w", filepath.Base(abs), err)
}
uri := pathURI(abs)
return uri, c.Notify("textDocument/didOpen", map[string]any{
"textDocument": map[string]any{
"uri": uri, "languageId": l.ID(abs), "version": 1, "text": string(text),
},
})
}
// Diagnostics collects what the server published for uri.
//
// LSP has no "diagnostics complete" signal — publishDiagnostics is unsolicited
// and a server may publish several times as analysis deepens. So this waits for a
// first publication, then for a settle window in which nothing new arrives, and
// reports what it has. A server that publishes nothing (a clean file) is reported
// as clean at the deadline, which is the honest reading.
func (c *Conn) Diagnostics(ctx context.Context, uri string, settle time.Duration) []Diagnostic {
const tick = 50 * time.Millisecond
var last int
var quiet time.Duration
for {
select {
case <-ctx.Done():
return c.published(uri)
case <-c.done:
return c.published(uri)
case <-time.After(tick):
}
got := c.published(uri)
if len(got) != last {
last, quiet = len(got), 0
continue
}
if last > 0 {
if quiet += tick; quiet >= settle {
return got
}
}
}
}
func (c *Conn) published(uri string) []Diagnostic {
c.dmu.Lock()
defer c.dmu.Unlock()
if d, ok := c.diag[uri]; ok {
return append([]Diagnostic(nil), d...)
}
return nil
}
// Close shuts the server down politely, then unconditionally. Idempotent.
//
// Polite first because a server asked to exit flushes and releases its own
// children. But the polite phase can BLOCK: a server that has stopped reading its
// stdin — crashed, or wedged mid-index — leaves our write with nowhere to go, and
// Close would then never return. It would hold a pool slot, a process handle
// and, at Shutdown, the whole binary.
//
// So the courtesy runs off to the side and this waits on a clock, never on the
// server. Closing the writer afterwards is what releases that goroutine: a
// blocked write to a closed pipe returns rather than waits.
func (c *Conn) Close() {
c.once.Do(func() {
select {
case <-c.done:
// Already dead. There is nobody to say goodbye to, and saying it
// anyway is exactly how this used to hang.
default:
polite := make(chan struct{})
go func() {
defer close(polite)
ctx, cancel := context.WithTimeout(context.Background(), closeWait)
defer cancel()
_, _ = c.Call(ctx, "shutdown", nil)
_ = c.Notify("exit", nil)
}()
select {
case <-polite:
case <-time.After(closeWait):
}
}
_ = c.w.Close()
select {
case <-c.done:
case <-time.After(closeWait):
}
if c.stop != nil {
c.stop()
}
})
}
// send frames one message. The write lock spans header and body: two goroutines
// interleaving there would produce a frame whose length does not match its
// payload, and the stream never recovers from that.
func (c *Conn) send(v any) error {
b, err := json.Marshal(v)
if err != nil {
return fmt.Errorf("lsp: encode: %w", err)
}
c.wmu.Lock()
defer c.wmu.Unlock()
if _, err := fmt.Fprintf(c.w, "Content-Length: %d\r\n\r\n", len(b)); err != nil {
return fmt.Errorf("lsp: write header: %w", err)
}
if _, err := c.w.Write(b); err != nil {
return fmt.Errorf("lsp: write body: %w", err)
}
return nil
}
// read is the sole owner of the inbound stream. Every frame is exactly one of
// four kinds, and each has exactly one destination.
func (c *Conn) read(r *bufio.Reader) {
defer close(c.done)
for {
body, err := readFrame(r)
if err != nil {
c.err = err
return
}
var m msg
if json.Unmarshal(body, &m) != nil {
continue // a frame we cannot parse is not a reason to drop the session
}
switch {
case m.Method != "" && len(m.ID) > 0:
// A server→client REQUEST (workspace/configuration,
// client/registerCapability). It BLOCKS the server until answered,
// so silence here is a hang, not a no-op. A null result is a valid
// answer to every one of them and commits us to nothing.
_ = c.send(map[string]any{"jsonrpc": "2.0", "id": m.ID, "result": nil})
case m.Method != "" && len(m.ID) == 0:
if m.Method == "textDocument/publishDiagnostics" {
c.publish(m.Params)
}
case len(m.ID) > 0:
var id int64
if json.Unmarshal(m.ID, &id) != nil {
continue // a response to an id we never issued
}
c.mu.Lock()
ch := c.wait[id]
c.mu.Unlock()
if ch != nil {
ch <- m // buffered, and the waiter is the only receiver
}
}
}
}
func (c *Conn) publish(params json.RawMessage) {
var p struct {
URI string `json:"uri"`
Diagnostics []Diagnostic `json:"diagnostics"`
}
if json.Unmarshal(params, &p) != nil || p.URI == "" {
return
}
c.dmu.Lock()
c.diag[p.URI] = p.Diagnostics
c.dmu.Unlock()
}
func (c *Conn) stopped() error {
if c.err != nil && !errors.Is(c.err, io.EOF) {
return fmt.Errorf("lsp: server stopped: %w", c.err)
}
return errors.New("lsp: server stopped")
}
// readFrame reads one Content-Length-framed message.
//
// Content-Length is REQUIRED and is the only header that matters; Content-Type is
// accepted and ignored, as the spec allows. A frame is refused rather than
// truncated when it exceeds maxFrame, because a truncated read leaves the stream
// pointing at the middle of a message.
func readFrame(r *bufio.Reader) ([]byte, error) {
n := -1
for {
line, err := r.ReadString('\n')
if err != nil {
return nil, err
}
line = strings.TrimRight(line, "\r\n")
if line == "" {
break // end of headers
}
k, v, ok := strings.Cut(line, ":")
if !ok || !strings.EqualFold(strings.TrimSpace(k), "Content-Length") {
continue
}
if n, err = strconv.Atoi(strings.TrimSpace(v)); err != nil {
return nil, fmt.Errorf("lsp: bad Content-Length %q", v)
}
}
if n < 0 {
return nil, errors.New("lsp: frame without Content-Length")
}
if n > maxFrame {
return nil, fmt.Errorf("lsp: frame of %d bytes exceeds %d", n, maxFrame)
}
body := make([]byte, n)
if _, err := io.ReadFull(r, body); err != nil {
return nil, err
}
return body, nil
}
// env is the environment a language server and its dependency fetch run under.
//
// It is BUILT, never inherited. The cloud process holds gateway credentials, KMS
// addresses and cluster tokens in its own environment, and a language server is a
// third-party binary parsing tenant source — the two must not meet. PATH and HOME
// are what a toolchain needs to find itself and its caches; nothing else is
// passed, and the deployment adds proxy settings through Lang.Env.
func env(l Lang) []string {
base := []string{
"PATH=" + os.Getenv("PATH"),
"HOME=" + os.Getenv("HOME"),
"GIT_TERMINAL_PROMPT=0",
"GIT_CONFIG_GLOBAL=/dev/null",
"GIT_CONFIG_SYSTEM=/dev/null",
}
return append(base, l.Env...)
}
// pathURI renders an absolute path as a file: URI. url.URL does the escaping, so
// a path with a space or a percent survives the round trip.
func pathURI(p string) string {
return (&url.URL{Scheme: "file", Path: p}).String()
}
// uriPath is pathURI's inverse for a file: URI, and the empty string for anything
// else — a server may cite a definition inside a jar: or zipfile: URI, which
// names no path on our disk.
func uriPath(raw string) string {
u, err := url.Parse(raw)
if err != nil || u.Scheme != "file" {
return ""
}
return u.Path
}
+351
View File
@@ -0,0 +1,351 @@
package lsp
// server_test.go drives the real client against a FAKE language server.
//
// The fake speaks the actual wire protocol — Content-Length framing, JSON-RPC 2.0
// — over an in-process pipe, so these tests need no gopls, no npm and no network,
// and still exercise the same code Start hands a real subprocess. newConn is the
// seam that makes that possible; nothing here reimplements the client under test.
import (
"bufio"
"context"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
)
// fake is a language server: it records the methods it is asked, in order, and
// answers from a table of canned replies.
type fake struct {
mu sync.Mutex
seen []string
reply map[string]any // method → result
// push, when set, is published as a diagnostics notification after didOpen,
// which is how a real server delivers them: unsolicited, not as a response.
push map[string]any
}
// serve reads frames from r and writes answers to w until r closes.
func (f *fake) serve(r io.Reader, w io.WriteCloser) {
defer w.Close()
br := bufio.NewReader(r)
for {
body, err := readFrame(br)
if err != nil {
return
}
var m struct {
ID json.RawMessage `json:"id"`
Method string `json:"method"`
}
if json.Unmarshal(body, &m) != nil {
continue
}
f.mu.Lock()
f.seen = append(f.seen, m.Method)
f.mu.Unlock()
if m.Method == "exit" {
return
}
if len(m.ID) > 0 { // a request: answer it
result, ok := f.reply[m.Method]
if !ok {
result = map[string]any{}
}
f.write(w, map[string]any{"jsonrpc": "2.0", "id": m.ID, "result": result})
}
if m.Method == "textDocument/didOpen" && f.push != nil {
f.write(w, map[string]any{
"jsonrpc": "2.0",
"method": "textDocument/publishDiagnostics",
"params": f.push,
})
}
}
}
func (f *fake) write(w io.Writer, v any) {
b, _ := json.Marshal(v)
fmt.Fprintf(w, "Content-Length: %d\r\n\r\n", len(b))
w.Write(b)
}
func (f *fake) methods() []string {
f.mu.Lock()
defer f.mu.Unlock()
return append([]string(nil), f.seen...)
}
// dial wires a Conn to a fake over two pipes.
func dial(t *testing.T, f *fake) *Conn {
t.Helper()
toServer, fromClient := io.Pipe()
toClient, fromServer := io.Pipe()
go f.serve(toServer, fromServer)
c := newConn(fromClient, toClient, func() {})
t.Cleanup(c.Close)
return c
}
// sample writes a file into a temp dir and returns the dir and the abs path.
func sample(t *testing.T, name, body string) (string, string) {
t.Helper()
dir := t.TempDir()
abs := filepath.Join(dir, name)
if err := os.WriteFile(abs, []byte(body), 0o600); err != nil {
t.Fatalf("write %s: %v", name, err)
}
return dir, abs
}
// TestDefinitionDrivesTheProtocol is the core claim: given a position, the client
// performs initialize → initialized → didOpen → definition, in that order, and
// parses the Location the server answered with.
func TestDefinitionDrivesTheProtocol(t *testing.T) {
dir, abs := sample(t, "main.go", "package main\n\nfunc main() {}\n")
target := filepath.Join(dir, "other.go")
f := &fake{reply: map[string]any{
"initialize": map[string]any{"capabilities": map[string]any{}},
"textDocument/definition": []any{map[string]any{
"uri": pathURI(target),
"range": map[string]any{
"start": map[string]any{"line": 41, "character": 8},
"end": map[string]any{"line": 41, "character": 16},
},
}},
}}
c := dial(t, f)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
l := table["go"]
if err := c.handshake(ctx, l, dir); err != nil {
t.Fatalf("handshake: %v", err)
}
uri, err := c.Open(l, abs)
if err != nil {
t.Fatalf("didOpen: %v", err)
}
raw, err := c.Call(ctx, "textDocument/definition", map[string]any{
"textDocument": map[string]any{"uri": uri},
"position": map[string]any{"line": 2, "character": 5},
})
if err != nil {
t.Fatalf("definition: %v", err)
}
// The ORDER is the contract: a server asked before initialize completes is
// entitled to refuse, and one asked about a document it was never told about
// answers null.
want := []string{"initialize", "initialized", "textDocument/didOpen", "textDocument/definition"}
got := f.methods()
if len(got) != len(want) {
t.Fatalf("methods = %v, want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Errorf("method %d = %q, want %q", i, got[i], want[i])
}
}
locs := locations(raw, dir)
if len(locs) != 1 {
t.Fatalf("locations = %d, want 1", len(locs))
}
if locs[0].Path != "other.go" {
t.Errorf("path = %q, want %q (a target inside the checkout is repo-relative)", locs[0].Path, "other.go")
}
// Positions pass through untouched — 0-based line, 0-based UTF-16 character.
if locs[0].Range.Start.Line != 41 || locs[0].Range.Start.Character != 8 {
t.Errorf("start = %+v, want {41 8} (LSP positions must not be re-based)", locs[0].Range.Start)
}
}
// TestServerRequestIsAnswered pins the deadlock this client is built to avoid: a
// server→client request (workspace/configuration, client/registerCapability)
// BLOCKS the server until it is answered. A client that only reads responses
// hangs here.
func TestServerRequestIsAnswered(t *testing.T) {
toServer, fromClient := io.Pipe()
toClient, fromServer := io.Pipe()
answered := make(chan struct{})
go func() {
defer fromServer.Close()
br := bufio.NewReader(toServer)
f := &fake{}
// Read the client's initialize, then turn around and ASK it something
// before answering — exactly what rust-analyzer does.
body, err := readFrame(br)
if err != nil {
return
}
var m struct {
ID json.RawMessage `json:"id"`
}
json.Unmarshal(body, &m)
f.write(fromServer, map[string]any{
"jsonrpc": "2.0", "id": 9001, "method": "client/registerCapability",
"params": map[string]any{"registrations": []any{}},
})
if _, err := readFrame(br); err != nil { // the client's reply
return
}
close(answered)
f.write(fromServer, map[string]any{"jsonrpc": "2.0", "id": m.ID, "result": map[string]any{}})
for {
if _, err := readFrame(br); err != nil {
return
}
}
}()
c := newConn(fromClient, toClient, func() {})
t.Cleanup(c.Close)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := c.handshake(ctx, table["go"], t.TempDir()); err != nil {
t.Fatalf("handshake: %v", err)
}
select {
case <-answered:
default:
t.Fatal("client never answered the server's request — a real server would still be blocked")
}
}
// TestDiagnosticsAreCollectedFromNotifications: diagnostics are published, not
// returned, so they only arrive if the reader routes notifications.
func TestDiagnosticsAreCollectedFromNotifications(t *testing.T) {
dir, abs := sample(t, "main.go", "package main\n")
uri := pathURI(abs)
f := &fake{
reply: map[string]any{"initialize": map[string]any{}},
push: map[string]any{
"uri": uri,
"diagnostics": []any{map[string]any{
"range": map[string]any{
"start": map[string]any{"line": 0, "character": 0},
"end": map[string]any{"line": 0, "character": 7},
},
"severity": 1,
"source": "compiler",
"message": "undefined: x",
}},
},
}
c := dial(t, f)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
l := table["go"]
if err := c.handshake(ctx, l, dir); err != nil {
t.Fatalf("handshake: %v", err)
}
if _, err := c.Open(l, abs); err != nil {
t.Fatalf("didOpen: %v", err)
}
got := c.Diagnostics(ctx, uri, 100*time.Millisecond)
if len(got) != 1 {
t.Fatalf("diagnostics = %d, want 1", len(got))
}
if got[0].Message != "undefined: x" || got[0].Severity != 1 {
t.Errorf("diagnostic = %+v, want severity 1 'undefined: x'", got[0])
}
}
// TestCallFailsWhenTheServerDies proves the client fails CLOSED: a dead server is
// an error, never a hang until the request's own deadline and never a silently
// empty answer that reads as "no definition found".
func TestCallFailsWhenTheServerDies(t *testing.T) {
toServer, fromClient := io.Pipe()
toClient, fromServer := io.Pipe()
go func() {
br := bufio.NewReader(toServer)
readFrame(br) // take the request
fromServer.Close() // then die without answering
}()
c := newConn(fromClient, toClient, func() {})
t.Cleanup(c.Close)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if _, err := c.Call(ctx, "textDocument/hover", map[string]any{}); err == nil {
t.Fatal("Call succeeded against a dead server")
} else if ctx.Err() != nil {
t.Fatalf("Call waited for its own deadline instead of noticing the server: %v", err)
}
}
// TestCloseDoesNotHangOnAWedgedServer is the regression this suite found: Close
// used to write a polite `shutdown` unconditionally, and a server that had
// stopped reading its stdin left that write with nowhere to go — Close never
// returned, holding a pool slot and, at Shutdown, the whole binary.
func TestCloseDoesNotHangOnAWedgedServer(t *testing.T) {
toServer, fromClient := io.Pipe()
toClient, fromServer := io.Pipe()
// A server that reads NOTHING and answers nothing. Writes to it block.
_ = toServer
c := newConn(fromClient, toClient, func() {})
done := make(chan struct{})
go func() { defer close(done); c.Close() }()
select {
case <-done:
case <-time.After(30 * time.Second):
t.Fatal("Close hung on a server that stopped reading its stdin")
}
fromServer.Close()
}
// TestFrameRejectsAnOversizeLength: Content-Length is attacker-influenced (a
// server parsing tenant source emits it), so an absurd one must be refused rather
// than allocated.
func TestFrameRejectsAnOversizeLength(t *testing.T) {
r := bufio.NewReader(strings.NewReader("Content-Length: 999999999999\r\n\r\n"))
if _, err := readFrame(r); err == nil {
t.Fatal("readFrame accepted a length past maxFrame")
}
}
// TestFrameRejectsAMissingLength: a frame with no Content-Length has no boundary,
// so continuing to read desynchronizes the stream.
func TestFrameRejectsAMissingLength(t *testing.T) {
r := bufio.NewReader(strings.NewReader("Content-Type: application/vscode-jsonrpc\r\n\r\n"))
if _, err := readFrame(r); err == nil {
t.Fatal("readFrame accepted a frame with no Content-Length")
}
}
// TestURIRoundTrip: paths with characters that must be escaped survive both
// directions, so a definition in a directory with a space is still findable.
func TestURIRoundTrip(t *testing.T) {
for _, p := range []string{"/tmp/x/main.go", "/tmp/a b/c#d/main.go", "/tmp/100%/x.go"} {
if got := uriPath(pathURI(p)); got != p {
t.Errorf("round trip %q → %q", p, got)
}
}
if got := uriPath("jar:file:///x.jar!/A.class"); got != "" {
t.Errorf("non-file URI resolved to a path %q — it names nothing on our disk", got)
}
}
+457
View File
@@ -0,0 +1,457 @@
package lsp
// workspace.go owns the working tree: check a repo out at a revision, fetch its
// dependencies under the scripts-off policy, and keep the resulting live server
// warm in a bounded pool.
//
// # On not reusing apps/code's checkout
//
// There is nothing to reuse. apps/code holds a static INDEX, not a tree — its
// door is POST /v1/code/index {repo,files:[{path,content}]}, files PUSHED to it
// by a client, and its own doc comment says so ("get_repo_structure over the
// org's own index, with no git checkout involved"). It never clones anything.
//
// The repo does have ONE git working-tree checkout already: apps/deploy's
// unexported gitSource.render — shallow clone, rev-parse, hardened env. It is not
// importable: apps/deploy pulls in the Kubernetes machinery (unstructured, the
// sync engine) that would then be linked into the lsp binary, and each app here
// is its own binary precisely so that does not happen.
//
// So this is the second working-tree checkout in the tree, and that is a real
// duplicate, not a resolved one. It follows deploy's invocation and its hardened
// environment exactly so the two cannot drift in BEHAVIOUR while they wait for
// the fix, which is to hoist the primitive into the root cloud package and have
// both call it. That is a change to a live deployment path and is not made here.
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"regexp"
"slices"
"strings"
"sync"
"time"
"github.com/hanzoai/cloud"
)
// fetchable is the ENTIRE scripts-off policy: one predicate, one place, read by
// the one caller that runs a dependency fetch.
//
// A fetch that runs dependency-authored code (langs.go: Executes) is remote code
// execution triggered by whatever the caller asked us to check out, and it buys
// nothing a language server needs — definitions and references come from source.
// So it does not run, and no configuration turns it on: the phase-2 sandbox is
// what would earn that, and until it exists an env var here would only be a way
// to lose the argument at 3am.
//
// THE DEPLOYED WORKER MUST STILL BE SANDBOXED. Even scripts-off, this process
// runs third-party language servers over untrusted source. The cloud-lsp worker's
// pod (phase 2) must additionally run non-root with a read-only root filesystem
// and all capabilities dropped, restrict egress to the module mirrors alone, cap
// CPU/memory/PIDs by cgroup, and mount no KMS material, no cloud metadata
// endpoint and no other tenant's volume. Scripts-off narrows the blast radius; it
// is not the boundary.
func fetchable(l Lang) bool { return len(l.Fetch) > 0 && !l.Executes }
// Bounds. A workspace is a checkout plus a language server process, so the cap is
// on memory and file descriptors, not on rows.
const (
warmMax = 8 // live workspaces held at once
warmTTL = 20 * time.Minute // idle before eviction
fetchMax = 10 * time.Minute // dependency fetch budget
cloneMax = 5 * time.Minute // checkout budget
)
// key names one workspace. org is FIRST and is always the validated principal's
// org: two tenants naming the same repo at the same revision get two keys, two
// directories and two servers, so a warm workspace can never be handed across the
// tenant boundary.
type key struct{ org, repo, rev string }
// Tree is one checked-out revision with its language server attached.
//
// ready is closed when it is usable. It is inserted into the pool BEFORE the slow
// work starts, so a second request for the same key waits for the first checkout
// instead of starting a competing one into the same directory.
type Tree struct {
key key
dir string
lang Lang
conn *Conn
used time.Time
ready chan struct{}
err error
}
func (t *Tree) close() {
if t == nil {
return
}
if t.conn != nil {
t.conn.Close()
}
if t.dir != "" {
_ = os.RemoveAll(t.dir)
}
}
// pool is the bounded set of warm workspaces, LRU by last use with an idle TTL.
//
// open and now are FIELDS rather than calls so the eviction policy can be tested
// as the arithmetic it is, with no git, no toolchain and no wall clock.
//
// The path is an argument to open rather than part of key on purpose: a workspace
// is a CHECKOUT, and which file you are asking about is not part of which
// checkout you want. Building one still needs it — the path picks the language
// and roots the server — so it is passed, not keyed.
type pool struct {
mu sync.Mutex
warm map[key]*Tree
max int
ttl time.Duration
open func(ctx context.Context, k key, path string) (*Tree, error)
now func() time.Time
}
func newPool(open func(ctx context.Context, k key, path string) (*Tree, error)) *pool {
return &pool{
warm: make(map[key]*Tree),
max: warmMax,
ttl: warmTTL,
open: open,
now: time.Now,
}
}
// get returns the workspace for k, building it if it is not warm. The bool
// reports a COLD start — the checkout, the fetch and the server handshake — which
// is the event meter.go charges for.
func (p *pool) get(ctx context.Context, k key, path string) (*Tree, bool, error) {
p.mu.Lock()
p.sweep()
if t, ok := p.warm[k]; ok {
t.used = p.now()
p.mu.Unlock()
select {
case <-t.ready:
case <-ctx.Done():
return nil, false, ctx.Err()
}
if t.err != nil {
return nil, false, t.err
}
return t, false, nil
}
t := &Tree{key: k, used: p.now(), ready: make(chan struct{})}
p.warm[k] = t
p.evict()
p.mu.Unlock()
built, err := p.open(ctx, k, path)
if err != nil {
t.err = err
close(t.ready)
p.mu.Lock()
if p.warm[k] == t {
delete(p.warm, k)
}
p.mu.Unlock()
return nil, true, err
}
t.dir, t.lang, t.conn = built.dir, built.lang, built.conn
close(t.ready)
return t, true, nil
}
// sweep drops workspaces idle past the TTL. Caller holds mu.
func (p *pool) sweep() {
cut := p.now().Add(-p.ttl)
for k, t := range p.warm {
if t.settled() && t.used.Before(cut) {
delete(p.warm, k)
go t.close()
}
}
}
// evict enforces max by dropping least-recently-used entries. Caller holds mu.
//
// An entry still being built is never chosen: it has no server to close and a
// request is already waiting on it. That does mean a burst of cold starts can
// briefly exceed max, which is the right trade — the alternative is evicting the
// checkout somebody is blocked on.
func (p *pool) evict() {
for len(p.warm) > p.max {
var oldest key
var found bool
for k, t := range p.warm {
if !t.settled() {
continue
}
if !found || t.used.Before(p.warm[oldest].used) {
oldest, found = k, true
}
}
if !found {
return
}
t := p.warm[oldest]
delete(p.warm, oldest)
go t.close()
}
}
func (t *Tree) settled() bool {
select {
case <-t.ready:
return true
default:
return false
}
}
// drop discards every warm revision of one repo for one org.
//
// A push or a re-index makes a checkout stale. Mostly that resolves itself — keys
// are revision-pinned, so new content is a new key — but a caller that tracks a
// BRANCH keeps asking for the same key while the branch moves, and this is what
// releases it.
//
// Cross-process invalidation is phase 2 and is stated here rather than implied:
// code and lsp are separate binaries, so apps/code cannot call this in-process.
// The push signal has to arrive over the bus.
func (p *pool) drop(org, repo string) {
p.mu.Lock()
defer p.mu.Unlock()
for k, t := range p.warm {
if k.org == org && k.repo == repo && t.settled() {
delete(p.warm, k)
go t.close()
}
}
}
func (p *pool) closeAll() {
p.mu.Lock()
warm := p.warm
p.warm = make(map[key]*Tree)
p.mu.Unlock()
for _, t := range warm {
if t.settled() {
t.close()
}
}
}
// ── building one workspace ───────────────────────────────────────────────────
// build is the cold path: check out, fetch dependencies, start the server.
func (s *state) build(ctx context.Context, k key, path string) (*Tree, error) {
l, ok := langFor(path)
if !ok {
return nil, fmt.Errorf("no language server for %q", filepath.Ext(path))
}
dir, err := s.dirFor(k)
if err != nil {
return nil, err
}
if err := checkout(ctx, dir, k, s.forge); err != nil {
_ = os.RemoveAll(dir)
return nil, err
}
root := rootFor(dir, path, l)
fetch(ctx, l, root)
conn, err := Start(ctx, l, root)
if err != nil {
_ = os.RemoveAll(dir)
return nil, err
}
return &Tree{key: k, dir: dir, lang: l, conn: conn}, nil
}
// dirFor is where a workspace lives: inside the ORG's own partition, under the
// same {DataDir}/orgs/{slug} convention every per-org store in this binary uses
// (orgdb.go). The org segment is rendered by cloud.OrgNamespace from the
// validated principal — the one door that turns a principal into a name — so the
// isolation is a property of the path, not of a check somebody has to remember.
func (s *state) dirFor(k key) (string, error) {
ns, err := cloud.OrgNamespace(k.org, "")
if err != nil {
return "", err
}
rev := k.rev
if rev == "" {
rev = "_default"
}
dir := filepath.Join(s.DataDir, "orgs", ns.ID(), "lsp", k.repo, rev)
if err := os.MkdirAll(dir, 0o700); err != nil {
return "", fmt.Errorf("workspace dir: %w", err)
}
return dir, nil
}
// checkout materializes repo@rev into dir.
//
// init + fetch, not clone, because it is ONE path for both a branch name and a
// bare sha — `clone --branch` cannot take a sha, and having two checkout paths
// would mean the answer depends on which kind of revision you named.
//
// The URL is BUILT, never accepted: base is the deployment's forge and the owner
// segment is k.org, the validated principal's org. A caller supplies only the
// repo slug, already narrowed to [a-zA-Z0-9._-] by [slug]. There is therefore no
// input from which a request could name another tenant's repo, an internal
// address, or a host of its own choosing — the class of bug is absent rather than
// defended against.
func checkout(ctx context.Context, dir string, k key, base string) error {
ctx, cancel := context.WithTimeout(ctx, cloneMax)
defer cancel()
url := strings.TrimSuffix(base, "/") + "/" + k.org + "/" + k.repo + ".git"
rev := k.rev
if rev == "" {
rev = "HEAD"
}
steps := [][]string{
{"init", "-q"},
{"remote", "add", "origin", url},
{"fetch", "--depth", "1", "--no-tags", "origin", rev},
{"checkout", "-q", "--detach", "FETCH_HEAD"},
}
for _, args := range steps {
if err := git(ctx, dir, args...); err != nil {
return err
}
}
return nil
}
// git runs one git subprocess in dir under the hardened environment apps/deploy
// and apps/git both use: no terminal prompt, no system or global config (so no
// inherited credential helper, insteadOf rewrite or proxy), protocols restricted
// to http/https, and redirects refused so a moved ref cannot bounce the fetch to
// another host. Arguments are a SLICE, never a shell string.
func git(ctx context.Context, dir string, args ...string) error {
cmd := exec.CommandContext(ctx, "git", append([]string{"-C", dir}, args...)...)
cmd.Env = append([]string{
"GIT_TERMINAL_PROMPT=0",
"GIT_CONFIG_GLOBAL=/dev/null",
"GIT_CONFIG_SYSTEM=/dev/null",
"GIT_ALLOW_PROTOCOL=http:https",
"HOME=" + os.TempDir(),
"PATH=" + os.Getenv("PATH"),
}, gitConfig("http.followRedirects=false")...)
out, err := cmd.CombinedOutput()
if err != nil {
// git's stderr can echo a URL; it never carries our credential, which
// rides env-injected config and not argv. Bounded so a chatty failure
// cannot balloon an error string.
return fmt.Errorf("git %s: %w: %s", args[0], err, trim(string(out), 512))
}
return nil
}
// gitConfig renders git config as env (GIT_CONFIG_COUNT/KEY/VALUE), the form
// apps/git/mirror.go uses — config that never appears on argv or in a log.
func gitConfig(kv ...string) []string {
env := []string{fmt.Sprintf("GIT_CONFIG_COUNT=%d", len(kv))}
for i, pair := range kv {
k, v, _ := strings.Cut(pair, "=")
env = append(env,
fmt.Sprintf("GIT_CONFIG_KEY_%d=%s", i, k),
fmt.Sprintf("GIT_CONFIG_VALUE_%d=%s", i, v))
}
return env
}
// fetch populates the dependency tree when the policy allows it.
//
// Failure is NOT fatal and is not reported to the caller: a language server still
// answers about the checkout's own source with no dependencies resolved, and a
// private module the worker cannot reach is a degraded answer, not a 500.
func fetch(ctx context.Context, l Lang, root string) {
if !fetchable(l) {
return
}
ctx, cancel := context.WithTimeout(ctx, fetchMax)
defer cancel()
cmd := exec.CommandContext(ctx, l.Fetch[0], l.Fetch[1:]...)
cmd.Dir = root
cmd.Env = env(l)
_ = cmd.Run()
}
// ── input narrowing ──────────────────────────────────────────────────────────
// slug is a repository name: the shape apps/git gives a repo under an owner. No
// slash, so it cannot name another owner's repository; no leading dot, so it
// cannot climb out of the org's data directory.
var slug = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]{0,99}$`)
// revision is a branch, tag or sha. The leading character is constrained to
// alphanumeric, which is what stops a revision from being read by git as a FLAG —
// `--upload-pack=…` in the rev position is command execution on the fetch.
var revision = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._/-]{0,199}$`)
// clean narrows a repo-relative path to a location proven to be inside dir.
//
// The check is done on the RESOLVED path, and that is the part that matters.
// Rejecting ".." is not sufficient: a checkout is TENANT-CONTROLLED content, and a
// repo may contain a symlink named `src` pointing at /etc, at the KMS mount, or at
// another org's directory one level up. Only resolving symlinks and then proving
// containment refuses that, so that is what happens — lexical checks first (they
// refuse the cheap attacks without a syscall), then EvalSymlinks, then a
// containment proof against the resolved root.
func clean(dir, path string) (string, error) {
if path == "" {
return "", fmt.Errorf("path is required")
}
if filepath.IsAbs(path) {
return "", fmt.Errorf("path must be repo-relative")
}
rel := filepath.Clean(filepath.FromSlash(path))
if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
return "", fmt.Errorf("path escapes the repository")
}
root, err := filepath.EvalSymlinks(dir)
if err != nil {
return "", fmt.Errorf("resolve workspace: %w", err)
}
abs, err := filepath.EvalSymlinks(filepath.Join(root, rel))
if err != nil {
return "", fmt.Errorf("no such file in the repository")
}
if abs != root && !strings.HasPrefix(abs, root+string(filepath.Separator)) {
return "", fmt.Errorf("path escapes the repository")
}
if info, err := os.Stat(abs); err != nil || info.IsDir() {
return "", fmt.Errorf("no such file in the repository")
}
return abs, nil
}
func trim(s string, n int) string {
s = strings.TrimSpace(s)
if len(s) > n {
return s[:n] + "…"
}
return s
}
// methods is every LSP request this door forwards. A CLOSED set: the door names
// what it serves, so a method the table does not carry is a 400 here rather than
// an arbitrary string handed to a language server.
var methods = []string{
"hover", "definition", "references", "typeDefinition",
"implementation", "documentSymbol", "completion", "diagnostics",
}
func known(m string) bool { return slices.Contains(methods, m) }
+452
View File
@@ -0,0 +1,452 @@
package lsp
// workspace_test.go tests the pool as the arithmetic it is — an injected opener
// and an injected clock, no git, no toolchain, no wall clock — and the input
// narrowing that keeps one tenant's query inside that tenant's checkout.
import (
"context"
"errors"
"os"
"path/filepath"
"sync"
"testing"
"time"
)
// clock is a hand-advanced time source, so a TTL test states the elapsed time it
// means instead of sleeping for it.
type clock struct {
mu sync.Mutex
t time.Time
}
func (c *clock) now() time.Time {
c.mu.Lock()
defer c.mu.Unlock()
return c.t
}
func (c *clock) advance(d time.Duration) {
c.mu.Lock()
c.t = c.t.Add(d)
c.mu.Unlock()
}
// testPool builds a pool whose opener makes a Tree with a real directory (so
// close() has something to remove) and no server process.
func testPool(t *testing.T, max int, ttl time.Duration) (*pool, *clock, func() int) {
t.Helper()
clk := &clock{t: time.Unix(1<<30, 0)}
root := t.TempDir()
var mu sync.Mutex
var opened int
p := newPool(func(_ context.Context, k key, _ string) (*Tree, error) {
mu.Lock()
opened++
n := opened
mu.Unlock()
dir := filepath.Join(root, k.org, k.repo, k.rev, string(rune('a'+n%26)))
if err := os.MkdirAll(dir, 0o700); err != nil {
return nil, err
}
return &Tree{key: k, dir: dir, lang: table["go"]}, nil
})
p.max, p.ttl, p.now = max, ttl, clk.now
t.Cleanup(p.closeAll)
return p, clk, func() int {
mu.Lock()
defer mu.Unlock()
return opened
}
}
func get(t *testing.T, p *pool, k key) (*Tree, bool) {
t.Helper()
tree, cold, err := p.get(context.Background(), k, "main.go")
if err != nil {
t.Fatalf("get %v: %v", k, err)
}
return tree, cold
}
// TestWarmWorkspaceIsReused: the second query for the same (org, repo, rev) must
// not check out again. This is the whole reason the pool exists, and it is also
// what the billing model rests on — only a cold start is charged.
func TestWarmWorkspaceIsReused(t *testing.T) {
p, _, opened := testPool(t, 4, time.Hour)
k := key{org: "acme", repo: "cloud", rev: "main"}
if _, cold := get(t, p, k); !cold {
t.Fatal("first get was not reported as a cold start")
}
if _, cold := get(t, p, k); cold {
t.Fatal("second get reported a cold start — the workspace was not reused")
}
if opened() != 1 {
t.Fatalf("opened %d workspaces for one key, want 1", opened())
}
}
// TestIdleWorkspaceIsEvictedAtTTL: a workspace holds a subprocess and a checkout,
// so an idle one must not hold them forever.
func TestIdleWorkspaceIsEvictedAtTTL(t *testing.T) {
p, clk, opened := testPool(t, 4, 10*time.Minute)
k := key{org: "acme", repo: "cloud", rev: "main"}
get(t, p, k)
clk.advance(11 * time.Minute)
if _, cold := get(t, p, k); !cold {
t.Fatal("workspace survived its idle TTL")
}
if opened() != 2 {
t.Fatalf("opened %d, want 2 (evicted then rebuilt)", opened())
}
}
// TestUseKeepsAWorkspaceWarm: the TTL is on IDLE time, so a workspace queried
// steadily must never be evicted out from under its caller.
func TestUseKeepsAWorkspaceWarm(t *testing.T) {
p, clk, opened := testPool(t, 4, 10*time.Minute)
k := key{org: "acme", repo: "cloud", rev: "main"}
get(t, p, k)
for range 5 {
clk.advance(6 * time.Minute) // past half the TTL, never past all of it
if _, cold := get(t, p, k); cold {
t.Fatal("an actively used workspace was evicted")
}
}
if opened() != 1 {
t.Fatalf("opened %d, want 1", opened())
}
}
// TestPoolEvictsLeastRecentlyUsed: over the cap, the workspace nobody has touched
// goes first — not an arbitrary one.
func TestPoolEvictsLeastRecentlyUsed(t *testing.T) {
p, clk, _ := testPool(t, 2, time.Hour)
a := key{org: "acme", repo: "a", rev: "main"}
b := key{org: "acme", repo: "b", rev: "main"}
c := key{org: "acme", repo: "c", rev: "main"}
get(t, p, a)
clk.advance(time.Minute)
get(t, p, b)
clk.advance(time.Minute)
get(t, p, a) // a is now the most recent, b the least
clk.advance(time.Minute)
get(t, p, c) // over the cap: b must go
p.mu.Lock()
defer p.mu.Unlock()
if len(p.warm) != 2 {
t.Fatalf("pool holds %d workspaces, want max 2", len(p.warm))
}
if _, ok := p.warm[b]; ok {
t.Error("evicted something other than the least-recently-used workspace")
}
if _, ok := p.warm[a]; !ok {
t.Error("evicted the most-recently-used workspace")
}
}
// TestOrgsDoNotShareAWorkspace is the isolation claim at the pool layer: the same
// repo name and revision in two orgs are two keys, two directories and two
// servers. Nothing about a warm workspace can be reached across the tenant
// boundary because there is no shared entry to reach.
func TestOrgsDoNotShareAWorkspace(t *testing.T) {
p, _, opened := testPool(t, 4, time.Hour)
one, _ := get(t, p, key{org: "acme", repo: "cloud", rev: "main"})
two, cold := get(t, p, key{org: "other", repo: "cloud", rev: "main"})
if !cold {
t.Fatal("a second org was served the first org's warm workspace")
}
if one.dir == two.dir {
t.Fatalf("two orgs share a checkout directory %q", one.dir)
}
if opened() != 2 {
t.Fatalf("opened %d, want 2", opened())
}
}
// TestConcurrentGetsOpenOnce: several requests racing for the same cold workspace
// must produce ONE checkout, not N competing clones into one directory.
func TestConcurrentGetsOpenOnce(t *testing.T) {
p, _, opened := testPool(t, 4, time.Hour)
k := key{org: "acme", repo: "cloud", rev: "main"}
var wg sync.WaitGroup
for range 16 {
wg.Add(1)
go func() {
defer wg.Done()
p.get(context.Background(), k, "main.go")
}()
}
wg.Wait()
if opened() != 1 {
t.Fatalf("opened %d workspaces concurrently for one key, want 1", opened())
}
}
// TestDropInvalidatesOneRepo: a push makes a branch checkout stale, and drop is
// what releases it — without touching another repo or another org.
func TestDropInvalidatesOneRepo(t *testing.T) {
p, _, _ := testPool(t, 4, time.Hour)
stale := key{org: "acme", repo: "cloud", rev: "main"}
other := key{org: "acme", repo: "other", rev: "main"}
foreign := key{org: "rival", repo: "cloud", rev: "main"}
get(t, p, stale)
get(t, p, other)
get(t, p, foreign)
p.drop("acme", "cloud")
p.mu.Lock()
defer p.mu.Unlock()
if _, ok := p.warm[stale]; ok {
t.Error("drop left the invalidated workspace warm")
}
if _, ok := p.warm[other]; !ok {
t.Error("drop evicted a different repo")
}
if _, ok := p.warm[foreign]; !ok {
t.Error("drop reached across the org boundary")
}
}
// TestFailedOpenIsNotCached: a checkout that failed must not be remembered as a
// workspace, or one transient forge outage poisons the key until the TTL.
func TestFailedOpenIsNotCached(t *testing.T) {
p := newPool(func(context.Context, key, string) (*Tree, error) {
return nil, errors.New("forge unreachable")
})
t.Cleanup(p.closeAll)
k := key{org: "acme", repo: "cloud", rev: "main"}
if _, _, err := p.get(context.Background(), k, "main.go"); err == nil {
t.Fatal("get succeeded despite a failing opener")
}
p.mu.Lock()
defer p.mu.Unlock()
if len(p.warm) != 0 {
t.Fatalf("a failed open left %d entries in the pool", len(p.warm))
}
}
// ── input narrowing ──────────────────────────────────────────────────────────
// TestCleanRefusesEscapes is the containment proof, and the symlink cases are the
// ones that matter: a checkout is TENANT-CONTROLLED content, so a repo can contain
// a symlink pointing anywhere. A lexical ".." check alone would pass every one of
// these.
func TestCleanRefusesEscapes(t *testing.T) {
dir := t.TempDir()
elsewhere := t.TempDir()
outside := filepath.Join(elsewhere, "secret.txt")
if err := os.WriteFile(outside, []byte("another tenant's data"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "main.go"), []byte("package main\n"), 0o600); err != nil {
t.Fatal(err)
}
// A file symlink out of the tree, and a directory symlink out of the tree.
if err := os.Symlink(outside, filepath.Join(dir, "escape.go")); err != nil {
t.Skipf("symlinks unavailable: %v", err)
}
if err := os.Symlink(elsewhere, filepath.Join(dir, "away")); err != nil {
t.Fatal(err)
}
for _, path := range []string{
"../../etc/passwd", // lexical traversal
"/etc/passwd", // absolute
"escape.go", // symlink to a file outside
"away/secret.txt", // through a symlinked directory
"", // empty
"nope.go", // absent
".", // the directory itself
"../" + "elsewhere", // traversal by name
} {
if got, err := clean(dir, path); err == nil {
t.Errorf("clean(%q) allowed %q — it must stay inside the checkout", path, got)
}
}
// The honest case still works.
got, err := clean(dir, "main.go")
if err != nil {
t.Fatalf("clean rejected a legitimate path: %v", err)
}
if filepath.Base(got) != "main.go" {
t.Errorf("clean returned %q", got)
}
}
// TestRevisionRefusesAFlag: a revision is passed to `git fetch` in the ref
// position, so one beginning with "-" would be read as an OPTION.
// --upload-pack=… there is command execution.
func TestRevisionRefusesAFlag(t *testing.T) {
for _, bad := range []string{
"--upload-pack=/bin/sh",
"-x",
"main;rm -rf /",
"main branch",
"$(whoami)",
"--",
} {
if revision.MatchString(bad) {
t.Errorf("revision accepted %q", bad)
}
}
for _, ok := range []string{"main", "v1.2.3", "release/2026-01", "a1b2c3d4e5f6"} {
if !revision.MatchString(ok) {
t.Errorf("revision rejected the legitimate %q", ok)
}
}
}
// TestSlugRefusesAPath: the repo slug becomes a URL segment and a directory name.
// A slug carrying a slash could name another org's repository on the forge; one
// carrying ".." could climb out of the org's data directory.
func TestSlugRefusesAPath(t *testing.T) {
for _, bad := range []string{
"rival/private", "../../etc", "..", ".", "a b",
"https://evil.example/x", "-x", "", "repo.git/../../other",
} {
if slug.MatchString(bad) {
t.Errorf("slug accepted %q", bad)
}
}
for _, ok := range []string{"cloud", "hanzo-node", "go.mod-tools", "a"} {
if !slug.MatchString(ok) {
t.Errorf("slug rejected the legitimate %q", ok)
}
}
}
// TestScriptsAreOff is the policy, asserted rather than described: no language
// whose dependency fetch executes dependency-authored code may run that fetch.
func TestScriptsAreOff(t *testing.T) {
for name, l := range table {
if l.Executes && fetchable(l) {
t.Errorf("%s: a fetch that runs dependency code is enabled (%v)", name, l.Fetch)
}
}
if !fetchable(table["typescript"]) {
t.Error("the npm fetch is disabled, but --ignore-scripts makes it safe and it is needed for .d.ts")
}
if fetchable(table["python"]) {
t.Error("the python fetch builds sdists, which runs setup.py as us")
}
// npm must never be invoked without --ignore-scripts.
npm := table["typescript"].Fetch
var guarded bool
for _, a := range npm {
if a == "--ignore-scripts" {
guarded = true
}
}
if !guarded {
t.Errorf("npm fetch %v is missing --ignore-scripts", npm)
}
// rust-analyzer must be told not to run build scripts or expand proc macros:
// the server does at load time exactly what `cargo fetch` was chosen to avoid.
rust := table["rust"].Init
cargo, _ := rust["cargo"].(map[string]any)
scripts, _ := cargo["buildScripts"].(map[string]any)
if scripts["enable"] != false {
t.Error("rust-analyzer may run build.rs — cargo.buildScripts.enable is not false")
}
proc, _ := rust["procMacro"].(map[string]any)
if proc["enable"] != false {
t.Error("rust-analyzer may expand proc macros — procMacro.enable is not false")
}
}
// TestLangForIsDeterministic: map iteration is randomized, so a table walked
// directly would resolve a file to different servers on different requests.
func TestLangForIsDeterministic(t *testing.T) {
for range 50 {
l, ok := langFor("apps/lsp/server.go")
if !ok || l.Name != "go" {
t.Fatalf("langFor(.go) = %q, %v", l.Name, ok)
}
}
if _, ok := langFor("README"); ok {
t.Error("langFor matched a file with no extension")
}
if _, ok := langFor("notes.txt"); ok {
t.Error("langFor matched an unknown extension")
}
}
// TestRootForStopsAtTheCheckout: the deepest marker wins, and the walk may never
// climb above the checkout — a marker outside the tenant's tree must never root a
// server.
func TestRootForStopsAtTheCheckout(t *testing.T) {
dir := t.TempDir()
nested := filepath.Join(dir, "svc", "api")
if err := os.MkdirAll(nested, 0o700); err != nil {
t.Fatal(err)
}
for _, at := range []string{dir, filepath.Join(dir, "svc")} {
if err := os.WriteFile(filepath.Join(at, "go.mod"), []byte("module x\n"), 0o600); err != nil {
t.Fatal(err)
}
}
got := rootFor(dir, "svc/api/main.go", table["go"])
if want := filepath.Join(dir, "svc"); got != want {
t.Errorf("rootFor = %q, want the deepest marker %q", got, want)
}
if got := rootFor(dir, "main.go", table["go"]); got != dir {
t.Errorf("rootFor = %q, want the checkout root %q", got, dir)
}
// No marker anywhere: the checkout root, never a parent.
bare := t.TempDir()
if got := rootFor(bare, "a/b/c.go", table["go"]); got != bare {
t.Errorf("rootFor = %q, want %q — the walk must not climb out", got, bare)
}
}
// TestMethodsAreAClosedSet: the door forwards a fixed list, so an arbitrary
// string never reaches a language server.
func TestMethodsAreAClosedSet(t *testing.T) {
for _, m := range methods {
if !known(m) {
t.Errorf("known(%q) = false for a listed method", m)
}
}
for _, m := range []string{"", "workspace/executeCommand", "shutdown", "exit", "HOVER"} {
if known(m) {
t.Errorf("known(%q) = true for an unlisted method", m)
}
}
}
// TestLanguageIDNamesTheDialect: TypeScript's server gets the wrong answer for a
// React file told it is plain TypeScript.
func TestLanguageIDNamesTheDialect(t *testing.T) {
ts := table["typescript"]
for path, want := range map[string]string{
"a.ts": "typescript", "a.tsx": "typescriptreact",
"a.js": "javascript", "a.jsx": "javascriptreact",
} {
if got := ts.ID(path); got != want {
t.Errorf("ID(%q) = %q, want %q", path, got, want)
}
}
if got := table["go"].ID("main.go"); got != "go" {
t.Errorf("ID(main.go) = %q, want go", got)
}
}
+25
View File
@@ -0,0 +1,25 @@
// Code generated by zipdoc; DO NOT EDIT.
package lsp
import (
"encoding/json"
"github.com/zap-proto/zip"
)
func init() {
zip.Describe("POST /v1/lsp", zip.Doc{
Description: "Resolves one position in one repository through a live language server:\ndefinition, references, type, implementation, hover, document symbols,\ncompletion or diagnostics — over the repo AND its resolved dependencies, with\nno toolchain on the caller's machine.\n\nPositions are the LSP's: line and character are 0-BASED and character counts\nUTF-16 code units, so an editor's 1-based line must have 1 subtracted before it\nis sent. The repository is named by slug and is always one in the caller's own\norg. rev pins a branch, tag or commit sha; empty means the default branch.\n\nThe first query against a (repo, rev) pays a cold start — checkout, dependency\nfetch and the server's first index — and is the billed event; later queries\nagainst the same revision are served from the warm workspace and are free. The\nanswer says which it was.",
Fields: map[string]string{
"Answer.cold": "Cold reports that this request paid for a workspace cold start — the\ncheckout, the dependency fetch and the server's first index. It is the\nbilled event, surfaced so a caller can see what it was charged for.",
"Query.character": "Character is a 0-based UTF-16 code-unit offset within Line, per the LSP\nspecification — not a byte offset and not a rune index.",
"Query.line": "Line is 0-based, per the LSP specification.",
"Query.method": "Method is the question: hover, definition, references, typeDefinition,\nimplementation, documentSymbol, completion or diagnostics.",
"Query.path": "Path is the repo-relative file, e.g. \"apps/lsp/server.go\".",
"Query.repo": "Repo is the repository NAME within the caller's own org, e.g. \"cloud\".\nNot a URL and not an owner/name pair: the owner is the validated\nprincipal's org, so this names a repository the caller already owns.",
"Query.rev": "Rev is a branch, tag or commit sha. Empty means the default branch. A\nworkspace is keyed by revision, so pinning a sha is what makes an answer\nreproducible.",
},
Example: json.RawMessage(`{"repo":"cloud","path":"apps/lsp/server.go","line":120,"character":18,"method":"definition"}`),
})
}
+5
View File
@@ -248,6 +248,11 @@ var Apps = []App{
{Name: "venue", Prefixes: []string{"/v1/cloud"}},
{Name: "captable", Prefixes: []string{"/v1/captable"}},
{Name: "code", Prefixes: []string{"/v1/code"}},
// lsp sits next to code because they are two reads of one checkout: code is
// the static index, lsp the live language server. Adjacency is documentation,
// not routing — /v1/lsp is a deeper static prefix than ai's "/v1", so it wins
// on specificity wherever it registers.
{Name: "lsp", Prefixes: []string{"/v1/lsp"}},
// zt held "/v1/edge/nodes" — a top-level name for something that was never a
// product. Four unrelated things wore "edge": the on-device inference runtime
// (hanzoai/edge, a binary a customer runs on their own machine, so it has no cloud
+1 -1
View File
@@ -30,7 +30,7 @@ var frozen = []string{
"framework", "knowledge", "help", "content", "catalogsync", "webhooks",
"ml", "label", "reference", "risk", "dataset", "usage", "leaderboard", "crm", "marketing", "ads",
"campaign", "validators", "social", "analytics", "git", "sync",
"visor", "venue", "captable", "code", "zt", "share",
"visor", "venue", "captable", "code", "lsp", "zt", "share",
"dataroom", "explorer", "security", "integrations", "destinations", "cloudflare",
"sbom", "team", "meet", "settings", "prefs", "notify",
"channels", "gateway", "entitlements", "exec", "websearch", "crawl",
+38
View File
@@ -0,0 +1,38 @@
package main
import (
"fmt"
"os"
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/apps/lsp"
)
// Standalone entry for the lsp app.
//
// This is the app's OWN composition root — it links only apps/lsp and the
// cloud request tier, never package apps, so the build is this one subsystem
// and not the whole fleet. The light host loads it as a plugin; run directly it
// serves standalone. Its OpenAPI subset comes from `lsp openapi`.
//
// Scaffolded by plugin/gen-app-cmds from the manifest.Apps row; now hand-owned.
//
// Metered, not Free: a query's cost is not a property of the door but of whether
// it had to check the repository out and index it, which only the app knows. The
// edge therefore charges nothing and apps/lsp/meter.go owns the debit — a cold
// start is billed, a warm point query is not.
//
// Shutdown is not optional here. A warm workspace is a LIVE language-server
// subprocess and a checkout on disk; neither is reclaimed by this process exiting,
// so without the hook a rolling deploy leaves orphaned gopls processes behind.
func main() {
if err := cloud.Listen([]cloud.Plugin{{
Name: "lsp",
Price: cloud.Metered,
Mount: lsp.Mount,
Shutdown: lsp.Shutdown,
}}, []string{"lsp"}); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
+227
View File
@@ -0,0 +1,227 @@
{
"openapi": "3.1.0",
"info": {
"title": "Hanzo Cloud API",
"description": "Package lsp is live semantic code intelligence — definitions, references, types, hover and diagnostics — over a repository AND its resolved dependencies, served from the cloud with no toolchain on the caller's machine.",
"version": "v1"
},
"servers": [
{
"url": "https://api.hanzo.ai"
}
],
"tags": [
{
"name": "lsp"
}
],
"paths": {
"/v1/lsp": {
"post": {
"operationId": "post_v1_lsp",
"summary": "Resolves one position in one repository through a live language server: definition, references, type, implementation, hover, document symbols, completion or diagnostics — over the repo AND its resolved dependencies, with no toolchain on the caller's machine.",
"description": "Resolves one position in one repository through a live language server:\ndefinition, references, type, implementation, hover, document symbols,\ncompletion or diagnostics — over the repo AND its resolved dependencies, with\nno toolchain on the caller's machine.\n\nPositions are the LSP's: line and character are 0-BASED and character counts\nUTF-16 code units, so an editor's 1-based line must have 1 subtracted before it\nis sent. The repository is named by slug and is always one in the caller's own\norg. rev pins a branch, tag or commit sha; empty means the default branch.\n\nThe first query against a (repo, rev) pays a cold start — checkout, dependency\nfetch and the server's first index — and is the billed event; later queries\nagainst the same revision are served from the warm workspace and are free. The\nanswer says which it was.",
"tags": [
"lsp"
],
"requestBody": {
"content": {
"application/json": {
"example": {
"character": 18,
"line": 120,
"method": "definition",
"path": "apps/lsp/server.go",
"repo": "cloud"
},
"schema": {
"$ref": "#/components/schemas/Query"
}
}
},
"required": true
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Answer"
}
}
},
"description": "ok"
}
}
}
}
},
"components": {
"schemas": {
"Answer": {
"properties": {
"cold": {
"description": "Cold reports that this request paid for a workspace cold start — the\ncheckout, the dependency fetch and the server's first index. It is the\nbilled event, surfaced so a caller can see what it was charged for.",
"type": "boolean"
},
"completions": {
"items": {
"$ref": "#/components/schemas/Completion"
},
"type": "array"
},
"diagnostics": {
"items": {
"$ref": "#/components/schemas/Diagnostic"
},
"type": "array"
},
"hover": {
"type": "string"
},
"lang": {
"type": "string"
},
"locations": {
"items": {
"$ref": "#/components/schemas/Location"
},
"type": "array"
},
"method": {
"type": "string"
},
"path": {
"type": "string"
},
"repo": {
"type": "string"
},
"rev": {
"type": "string"
},
"symbols": {
"items": {
"$ref": "#/components/schemas/Symbol"
},
"type": "array"
}
},
"type": "object"
},
"Completion": {
"properties": {
"detail": {
"type": "string"
},
"kind": {
"type": "integer"
},
"label": {
"type": "string"
}
},
"type": "object"
},
"Diagnostic": {
"properties": {
"code": {
"type": "object"
},
"message": {
"type": "string"
},
"range": {
"$ref": "#/components/schemas/Range"
},
"severity": {
"type": "integer"
},
"source": {
"type": "string"
}
},
"type": "object"
},
"Location": {
"properties": {
"path": {
"type": "string"
},
"range": {
"$ref": "#/components/schemas/Range"
}
},
"type": "object"
},
"Position": {
"properties": {
"character": {
"type": "integer"
},
"line": {
"type": "integer"
}
},
"type": "object"
},
"Query": {
"properties": {
"character": {
"description": "Character is a 0-based UTF-16 code-unit offset within Line, per the LSP\nspecification — not a byte offset and not a rune index.",
"type": "integer"
},
"line": {
"description": "Line is 0-based, per the LSP specification.",
"type": "integer"
},
"method": {
"description": "Method is the question: hover, definition, references, typeDefinition,\nimplementation, documentSymbol, completion or diagnostics.",
"type": "string"
},
"path": {
"description": "Path is the repo-relative file, e.g. \"apps/lsp/server.go\".",
"type": "string"
},
"repo": {
"description": "Repo is the repository NAME within the caller's own org, e.g. \"cloud\".\nNot a URL and not an owner/name pair: the owner is the validated\nprincipal's org, so this names a repository the caller already owns.",
"type": "string"
},
"rev": {
"description": "Rev is a branch, tag or commit sha. Empty means the default branch. A\nworkspace is keyed by revision, so pinning a sha is what makes an answer\nreproducible.",
"type": "string"
}
},
"type": "object"
},
"Range": {
"properties": {
"end": {
"$ref": "#/components/schemas/Position"
},
"start": {
"$ref": "#/components/schemas/Position"
}
},
"type": "object"
},
"Symbol": {
"properties": {
"detail": {
"type": "string"
},
"kind": {
"type": "integer"
},
"name": {
"type": "string"
},
"range": {
"$ref": "#/components/schemas/Range"
}
},
"type": "object"
}
}
}
}