feat(vendo,store): one tenant brings its own tools, and only that tenant's users get them - #1473
Conversation
Greptile SummaryThis change adds tenant-scoped MCP and OpenAPI connector registration, encrypted credential storage, tenant-aware tool discovery, and connector lifecycle operations. Two failures remain in Confidence Score: 1/5Do not merge until connector replacement updates are made atomic and cached connector overlays are invalidated across all instances using the store. Two independently reproduced security-impacting failures remain: replacement credentials can be routed to an old endpoint after a write failure, and removed connectors can continue running from another instance's warmed cache. Files Needing Attention: packages/vendo/src/tenant-connectors.ts
|
3681502 to
a0ce61b
Compare
a0ce61b to
4340cfa
Compare
| const vaulted = secretName(input.org, input.name); | ||
| if (input.token === undefined) await deps.ops.secrets.delete(vaulted); | ||
| else await deps.ops.secrets.set(vaulted, input.token); | ||
| } | ||
| await records().put({ |
There was a problem hiding this comment.
Replacement credential publication is non-atomic
register() writes the replacement secret before it persists the replacement registration row. An overlapping overlay build can therefore read the prior connector URL with the replacement token and send that bearer credential to the old endpoint. Publish the registration metadata and credential atomically, or prevent readers from observing the intermediate state.
Artifacts
Deterministic tenant connector overlap regression harness
- Authored Vitest harness pauses the replacement registration record write after the secret is stored and starts an overlay build, takeaway: it deterministically opens the vulnerable interleaving.
Vitest output showing the old endpoint receiving the replacement bearer token
- Observed successful Vitest run records the old persisted URL receiving `Bearer replacement-token` while the replacement record write is paused, takeaway: the credential-to-old-endpoint exposure is reproduced.
| const cache = new Map<string, Promise<ToolRegistry | undefined>>(); | ||
|
|
||
| const api: TenantConnectors = { | ||
| async register(input) { | ||
| try { | ||
| const row: Registration = { | ||
| org: input.org, | ||
| name: input.name, | ||
| kind: input.kind, | ||
| ...(input.url === undefined ? {} : { url: input.url }), | ||
| ...(input.spec === undefined ? {} : { spec: input.spec }), | ||
| registeredAt: new Date().toISOString(), | ||
| ...(input.token === undefined ? {} : { vaulted: true as const }), | ||
| }; | ||
| // Validate by CONNECTING: the discovered tools are the proof, and they | ||
| // are what the caller gets back. | ||
| const tools = await connectorFor(row, input.token).descriptors(); | ||
| if (input.token !== undefined && deps.ops === undefined) { | ||
| throw new VendoError( | ||
| "not-implemented", | ||
| "this deployment's store has no secret vault, so a tenant connector token cannot be stored: " | ||
| + "use the default store (or any store on the named-operation surface — Vendo Cloud, your own Postgres via createStore).", | ||
| ); | ||
| } | ||
| if (deps.ops !== undefined) { | ||
| // The vault always ends up holding exactly what this call VALIDATED | ||
| // with. A re-registration that pasted no token drops the old one | ||
| // instead of leaving runtime to send a credential to a url its owner | ||
| // never paired it with — which is also the only way `register`'s | ||
| // discovery and the later live calls can be the same request. | ||
| const vaulted = secretName(input.org, input.name); | ||
| if (input.token === undefined) await deps.ops.secrets.delete(vaulted); | ||
| else await deps.ops.secrets.set(vaulted, input.token); | ||
| } | ||
| await records().put({ | ||
| id: rowId(input.org, input.name), | ||
| data: row as unknown as Json, | ||
| // The ownership stamp: an org id IS a row subject (§9.5), so the | ||
| // existing erase cascade's subject leg reaches these rows. | ||
| refs: { subject: input.org }, | ||
| }); | ||
| cache.delete(input.org); | ||
| return { status: "ok", tools }; | ||
| } catch (error) { | ||
| return failed(error); | ||
| } | ||
| }, | ||
|
|
||
| async list(org) { | ||
| return (await rowsFor(org)).map(summaryOf); | ||
| }, | ||
|
|
||
| async remove(org, name) { | ||
| await records().delete(rowId(org, name)); | ||
| if (deps.ops !== undefined) await deps.ops.secrets.delete(secretName(org, name)); | ||
| cache.delete(org); | ||
| }, | ||
|
|
||
| async test(org, name) { | ||
| try { | ||
| const row = (await records().get(rowId(org, name)))?.data as unknown as Registration | undefined; | ||
| if (row === undefined) { | ||
| throw new VendoError("not-found", `no tenant connector "${name}" registered for org "${org}"`); | ||
| } | ||
| return { status: "ok", tools: await connectorFor(row, await readToken(row)).descriptors() }; | ||
| } catch (error) { | ||
| return failed(error); | ||
| } | ||
| }, | ||
| }; | ||
|
|
||
| const connectorsFor = async (org: string): Promise<Connector[]> => | ||
| await Promise.all((await rowsFor(org)).map(async (row) => connectorFor(row, await readToken(row)))); | ||
|
|
||
| const registryFor = (org: string): Promise<ToolRegistry | undefined> => { | ||
| let built = cache.get(org); | ||
| if (built === undefined) { | ||
| built = (async () => { | ||
| const connectors = await connectorsFor(org); | ||
| return connectors.length === 0 ? undefined : deps.bind(connectors); | ||
| })(); | ||
| // A failed build is never cached: without this one transient store blip | ||
| // leaves a rejected promise here and every later turn rethrows it. | ||
| built.catch(() => cache.delete(org)); | ||
| cache.set(org, built); | ||
| } | ||
| return built; |
There was a problem hiding this comment.
Connector cache is stale across instances
The overlay cache belongs to a single createTenantConnectors() composition, while mutations invalidate only that composition's entry. When another instance sharing the store removes or replaces a connector, this instance continues to list and execute its cached connector. Use a shared generation or invalidation mechanism, or revalidate cached registries against the store before use.
Artifacts
Deterministic tenant connector overlap regression harness
- Authored Vitest harness pauses the replacement registration record write after the secret is stored and starts an overlay build, takeaway: it deterministically opens the vulnerable interleaving.
Vitest output showing the old endpoint receiving the replacement bearer token
- Observed successful Vitest run records the old persisted URL receiving `Bearer replacement-token` while the replacement record write is paused, takeaway: the credential-to-old-endpoint exposure is reproduced.
Focused two-instance shared-store cache harness
- Authored Vitest harness starts two live MCP endpoints and exercises cache population, cross-instance removal, re-registration, listing, and execution; the takeaway is that it directly covers the reported process-local cache path.
Observed stale overlay cache execution output
- Captured `pnpm --filter @vendoai/vendo exec vitest run tests/tenant-connectors-process-local-cache.trex.test.ts` output exited 0 and records old endpoint calls after removal and replacement; the takeaway is that instance A serves stale connector state.
openApiConnector({ spec, baseUrl, headers, name }) turns an OpenAPI
document into guarded tools. It is reuse, not new machinery: the same
extractor `vendo sync` runs over a spec file, and the same HTTP dispatch
a host tool executes through.
Two factorings made that sharing possible:
- extractOpenApi's document half moved to the pure src/openapi-document.ts
(the binding-identity.ts precedent). sync/openapi.ts keeps node:fs and
the spec-file entry points; the connector is handed the document in
memory. Keeping it in sync/ would have dragged sync/common.ts and its
TypeScript compiler into the runtime entry, which the portability gate
forbids outright (FORBIDDEN_INPUTS: packages/actions/dist/sync/).
Route naming and extractedRisk moved alongside it into
binding-identity.ts, re-exported from sync/common.ts.
- registry.ts's HTTP leg — argument binding, path substitution, the tRPC
envelope, the fetch — is runtime/http-dispatch.ts now, used by the
registry and the connector both. The JSON accept/content-type envelope
moved inside fetchHostTool, so neither caller sets it.
McpAuthContext and McpHeadersResolver stay working as deprecated aliases
of ConnectorAuthContext / ConnectorHeadersResolver. Both connectors are
re-exported from @vendoai/vendo/server (and so from vendoai/server), and
docs-site/capabilities/connectors.mdx documents them — mcpConnector's
first page.
The test stands up a live HTTP fixture, points the connector at a spec
describing it, and executes through createActions: no stub on either
side. It proves the round trip, that baseUrl beats servers[0], that a
headers resolver sees the principal and grant, and that risk tracks the
method.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Attachments rode one message and ended with it. A non-image dropped in chat now lands in that user's own `/user/files/` — private, and still there next conversation — and the message carries only the reference, so a spreadsheet is stored once instead of on every turn. Images are the deliberate exception: they still ride inline, because that is how a model sees a picture. `POST /files` is the door (raw bytes under the file's own media type, 5 MiB cap, no 413 rung), and `vendo.putUserFile` is the same server-side write called from host code. Same name replaces — `/user` is last-write-wins — so re-sending a corrected export needs no version suffix. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The drawer had a write door and no way in. Two read-only tools — `vendo_user_files_list` and `vendo_user_files_read` — go on the ordinary guard-bound registry, so a file dropped last month is found and read in next month's conversation: the listing is the only thing that carries it across, and nothing volatile goes into the cacheable prompt. Both take a file NAME, never a path, and build the path themselves, so there is no caller-supplied path to climb out of `/user/files` with. A read comes back 200 lines at a time so a spreadsheet is walked rather than truncated mid-row, and a file that is not text answers with its type and size instead of mojibake. The prompt teaches the two things the descriptors cannot: an app gets a COPY of what it needs, and refreshing that copy when a newer file arrives is the agent's job on that turn. There is no automatic sync anywhere. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…t send `POST /files` was exempt from the wire's json-mutation CSRF floor and paid no toll for it, so with the ambient-cookie auth presets a hostile page could push files into a signed-in user's drawer cross-origin. The comment claimed the file's media type forced a preflight; it does not — our own client posts `.txt` as `text/plain`, which is CORS-safelisted, so `/apps/import`'s media-type allowlist would both admit the attack and break real uploads. The toll is now a required custom request header (`x-vendo-upload`): a browser cannot set one cross-origin without winning a preflight, and this wire answers none. It needs no secret and no per-session state. Also: the 5 MiB cap is checked against the declared Content-Length before the body is buffered, so an over-cap upload is no longer held in memory to be measured. The post-read check stays the real bound for a chunked body. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An outside agent connects through the MCP door AS the user, so it inherited `vendo_user_files_list` and `vendo_user_files_read` along with every other `vendo_*` tool. Files someone uploaded to talk to THIS product about are not material an external agent gets to read just because it authenticated as them. The door's `withholdTools` is the mechanism that fits: it is checked before the `vendo_` prefix bypass, so unlike the door menu it can actually hold back a runtime tool, and it holds on every leg of the mount. The in-product agent reads its own registry and is untouched. The outside-agent pin goes green because the code changed, not because the expectation moved; the new case asserts both directions so a change that re-exposes these fails saying why. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
31bce71 to
1b4267c
Compare
…users get them
A customer with its own MCP server or OpenAPI spec had one way in: add the
connector to createVendo({ connectors }) and redeploy — after which every tenant
on the deployment has it, because there was only ever one tool registry.
vendo.tenantConnectors is the dev-side API that ends that. register takes an org,
an MCP URL or an OpenAPI spec, and the token the customer pasted; it validates by
ACTUALLY CONNECTING and answers with the tools the server really advertised, or a
typed error. list, test and remove are the rest of the admin screen. No Vendo
UI, no console step.
Visibility follows the orgs the host already asserts, and it is STRUCTURAL: a run
asserting acme is served the shared registry plus Acme's own, and a run asserting
globex is served a registry Acme's connector was never in. No filter over a
combined set, so no filter to get wrong.
No store schema change. Registrations ride the generic records collection stamped
with their org, so the existing erase cascade reaches them like any other
subject-ref'd row; the token is vaulted in the store's encrypted secrets under a
tenant-scoped name and no public surface reads it back.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…live, and erasure takes it Two gaps behind the tenant-connector seam, closed. vendo doctor gains E-TENANT-001. A host whose source reaches vendo.tenantConnectors with neither VENDO_STORE_ENCRYPTION_KEY nor VENDO_API_KEY set is deploying a feature that works on every laptop and fails on the first credentialed registration in production, because the store keeps a secret encrypted or not at all. Static like the rest of doctor: a source marker and two env names, no store opened and no tenant server dialled — checking that one tenant's server still answers is tenantConnectors.test's job, at runtime. And the erase cascade now sweeps the tokens. vendo_secrets sat outside every selector for a stated reason — name-keyed HOST config that no subject could reach — and a tenant connector's vault name breaks that premise by carrying the org that owns it, the same way vendo_effects stopped being unreachable when it gained a subject. So an org-level erase takes its connector tokens with its registrations, and NOTHING else: a deployment's own API_TOKEN belongs to the deployment, not to any person. One name builder in @vendoai/core serves the write side and the sweep, so the two can never drift. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… stub S4 was built beside S2, so it read `openApiConnector` off the `@vendoai/actions` module object behind a locally-declared type: a named import of an export that did not exist yet failed the bundler. S2 has landed underneath, so the indirection goes and the import is ordinary. Before this, registering a `kind: "openapi"` tenant connector answered `not-implemented`. It now extracts the spec's operations and executes them, which the new seam tests prove against a live HTTP fixture: real spec, real listener, real request, real vaulted bearer token. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ot shadow a host tool Four defects in the tenant-connector overlay, and the first one reshapes the rest. One registry per ORG, never one per membership COMBINATION. Flattening every asserted org's connectors into a single registry meant two tenants who both called their connector "billing" composed the same tool name, and the registry answers that with a `conflict` throw that is deliberately never cached. Because the overlay awaits tenant descriptors on every listing, that throw took out the WHOLE toolset — host tools included — for a person whose only mistake was belonging to both. Kept apart, the collision cannot form; the merge is where the two meet, and it de-duplicates instead of building. Keying the cache on a plain org id rather than a joined list also removes the ambiguity that let one org literally named "a,b" be served the tools of orgs a and b. Execute now asks the BASE first, which is what its own comment always claimed and the opposite of what it did. A tenant server naming a tool after a host tool was the one that ran. The listing and the dispatch walk one order now, so the tool a person is offered is the tool that runs. And the vault ends up holding exactly what `register` VALIDATED with. A re-registration that pasted no token kept the old secret, so discovery ran against the new url unauthenticated while every later call shipped the previous tenant credential to it. Tokenless now clears, and the two paths agree. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…n chat The registry was right and chat was blind, which is the worst shape a feature can ship in: org A resolved its tenant's tools on every listing and its agent still answered exactly like org B's. Two things stood between them. The discovery hand searched `actions`, the SHARED registry — and a registry has no caller, so it cannot see a per-request overlay. The seam is gone; `find_tools` now scores THE TURN'S OWN LISTING, which is the only set that is true for the caller, and is already the set THE LAW projected and the agent menu curated. Then the call itself: `descriptorFor` resolved a chosen tool with NO ctx, so a tenant tool was offered to the model, picked by it, and came back "Unknown tool" from the very registry that had just offered it. It reads with the run's ctx now, exactly like `list()` beside it and for the same stated reason. A tokenless registration no longer asks the vault. The read throws before it looks for a row when the store has no key, so ONE credential-free connector took down every turn for every member of that org; the row says whether it vaulted anything, and a connector that needs no credential costs no read. And doctor stops greening a deployment whose next registration is refused. A Cloud key only proves a vault when Cloud is really the store — an explicitly passed createStore() wins over VENDO_API_KEY — so the key now counts only where nothing else claimed the seam. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The page told a BYO host to set VENDO_STORE_ENCRYPTION_KEY "in production", and both halves of that were wrong for them. The variable is read only where Vendo composes the store, so a host passing its own createStore() never receives it — and the refusal it warns about is not production-only, it fires in development on such a store too. The advice was therefore a dead end: set the variable, and a tokened registration is still refused. Now it says whose vault it is. Vendo's store takes the environment variable; a host-passed store carries its own encryption config, because an explicitly passed store owns its secrets. Tokenless registrations are called out as unaffected — they store no secret, so they need no vault at all. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
7cd84e7 to
9007cbb
Compare
| if (input.token === undefined) await deps.ops.secrets.delete(vaulted); | ||
| else await deps.ops.secrets.set(vaulted, input.token); | ||
| } | ||
| await records().put({ | ||
| id: rowId(input.org, input.name), | ||
| data: row as unknown as Json, | ||
| // The ownership stamp: an org id IS a row subject (§9.5), so the | ||
| // existing erase cascade's subject leg reaches these rows. | ||
| refs: { subject: input.org }, | ||
| }); |
There was a problem hiding this comment.
Replacement credential publication is non-atomic
register() updates the vaulted token before persisting the replacement row. If records().put() fails, the method returns an error while the old row remains active with its prior URL. Subsequent test() and overlay construction read that old URL together with the replacement token, sending the new bearer credential to the old endpoint. Persist the row and credential atomically, or restore the prior secret when the row write fails.
Artifacts
Executable tenant connector replacement failure reproduction
- Authored Node script that starts real local MCP endpoints, injects a record write failure after vault mutation, and asserts the Authorization header observed by the selected endpoint; it reproduces the old-URL/replacement-token mismatch.
Control log for successful tenant connector replacement
- Executed control run with a successful record write; it shows the replacement endpoint receives the replacement bearer token, establishing the expected matched state.
Failure log showing replacement token sent to old tenant connector endpoint
- Executed injected-failure run after an initial tokened registration; it shows the old persisted endpoint receives `Bearer replacement-token`, proving the mismatch.
| * the merge below is where the two meet, and it is a de-duplication, not a | ||
| * build. A plain org id is also unambiguous as a key, which a joined list of | ||
| * them would not be. */ | ||
| const cache = new Map<string, Promise<ToolRegistry | undefined>>(); |
There was a problem hiding this comment.
Connector revocation leaves peer overlays active
The connector registry cache belongs to one createTenantConnectors() composition. register() and remove() only clear the cache of the instance performing the mutation, so another Vendo instance sharing the store can continue listing and executing a connector it cached before that connector was removed or replaced. Use a store-shared revision/invalidation mechanism, or revalidate cached overlays against durable connector state before use.
Artifacts
Executable shared-store tenant connector cache reproduction
- Authored executable starts a live MCP server, composes two Vendo instances over one PGlite store, warms A, mutates via B, and asserts the observed stale listing and execution; the takeaway is that cross-instance cache invalidation is absent.
Connector listing and execution before cross-instance removal
- Executed baseline command from `/home/user/repo` shows instance A lists the registered connector, durable state contains `billing`, and a real MCP `tools/call` succeeds; the takeaway is the initial state is live and correctly wired.
Stale connector listing and execution after durable removal by another instance
- Executed mutation command from `/home/user/repo` shows B leaves `persistedRows: []`, but A still lists the connector and sends a real MCP `tools/call`; the takeaway is revocation is stale across instances.
Part of a 4-PR stack (
main→ S1 #1464 → S2 #1465 → S3 #1472 → S4 (this)). The merge unit is the tip — this PR is not meant to land alone.What it adds
One tenant brings its own tools, and only that tenant's users get them. A host registers an org's own MCP server or OpenAPI spec at runtime — no redeploy, no console, no UI.
Isolation is structural, not a filter. Each org that has registered anything gets its OWN actions registry, built over the shared connectors plus its own; a request is served the registry its asserted memberships select. Another tenant's connector is not withheld from that registry — it was never in it, so there is no filter to get wrong and no listing that could leak a name. The base registry is asked first, so a tenant server cannot shadow a host tool by naming one of its own after it.
Public surface
vendo.tenantConnectors.register({ org, name, kind: "mcp" | "openapi", url?, spec?, token? })— save-and-test in one call: it validates by actually connecting, so a registration that landed is a registration that worked, and the discovered tools are what you get back.list(org)/.remove(org, name)/.test(org, name)TenantConnectorInput,TenantConnectorResult,TenantConnectorSummary,TenantConnectorstenantsrow, only when a memberships seam exists (without one, no run can assert an org)production/troubleshooting/e-tenant-001capabilities/tenant-connectorsData handling
listandregisteranswer descriptors and metadata, never the credential.vendo_recordscollection: no store schema change, no migration.One real code change on top of the rebase
S4 was built in parallel with S2, so it stubbed S2's connector — reading
openApiConnectoroff the@vendoai/actionsmodule object behind a locally-declared type, because a named import of a then-nonexistent export broke the Turbopack build. S2 is underneath now, so that indirection is gone for an ordinary named import. Before the swap the openapi registration path answerednot-implemented; it now actually works.How it was tested
13 seam tests, no mocks on either side: real
node:httpMCP server speaking real JSON-RPC, real REST fixture for the OpenAPI path, real PGlite with a real encryption key, and tool listings read offvendo.guardedTools— the same registry chat, the MCP door and automations execute through. The new OpenAPI counterpart registers against a live in-test server and asserts the request really landed there carrying the vaulted bearer token.~690 blast-radius tests green.
🤖 Generated with Claude Code
Summary by cubic
Per-tenant connectors with structural isolation. Previously, connectors passed to createVendo({ connectors }) were global; now the host registers an org’s MCP server or OpenAPI spec at runtime and only that org’s users see and can call those tools.
@vendoai/vendo:vendo.tenantConnectors.register|list|test|remove(types exported:TenantConnectorInput|Result|Summary|TenantConnectors).registersave-tests by connecting and returns discovered tools; OpenAPI registrations now extract operations and execute.@vendoai/coreand are never returned. Re-registering without a token clears the old one; tokenless registrations skip vault reads. Erasure cascades delete only the erased org’s connector tokens via the shared name builder; host-owned secrets are untouched.find_toolssearches the turn’s listing (not the shared registry) so tenant tools are discoverable and callable in chat. The boot summary shows a “tenants” row only when a memberships seam exists..tenantConnectorsis wired without an encrypted vault; a Cloud key counts only when Cloud actually composes the store.Rollout
VENDO_STORE_ENCRYPTION_KEY(base64, 32 bytes) or useVENDO_API_KEYwhen Vendo composes the store; if you pass your owncreateStore, configure encryption there (the env var will not apply). Tokenless registrations work without a vault.Written for commit 9007cbb. Summary will update on new commits.