Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
67975187d2 | ||
|
|
35686f3462 | ||
|
|
7921911f93 | ||
|
|
628e510e75 |
+26
-6
@@ -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 {
|
||||
|
||||
@@ -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, "+
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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,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{
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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)),
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"}`),
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user