Repository navigation
feat(auth): [AGI-6072] router login, org per command and per MCP call, on top of v0.26.0 - #170
Open
whiskeylotus wants to merge 23 commits into
Open
whiskeylotus wants to merge 23 commits into
whiskeylotus wants to merge 23 commits into
Conversation
Router tenants run CLI login on the router, not on their own pool. Login now asks the tenant (GET /auth/cli/config) where to sign in and runs session, redeem and refresh there. A 404 is a stack that predates the endpoint and keeps its own login; any other failure, or an answer that is neither the tenant nor a known router, fails closed. Auth calls no longer follow redirects, so a 307 cannot replay a redemption secret elsewhere. Credentials are stored per auth host, with a hosts.json binding each API host to its auth host; old credential files keyed by the tenant keep working. Refresh no longer saves the server's default org over the stored scope. Plan-Step: 1/5 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Both client transports set the Authorization header on every request they carried, redirect hops included, so a 3xx from the API sent the user's token to wherever it pointed. The bearer now goes only on an https request to the client's own API host that the caller made; redirects, other hosts, other ports and plain http go out without it. A 401 from such a request no longer triggers a forced refresh. Plan-Step: 2/5 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A router login covers every org the user has on a host, and with none named, multi-org requests fail with ErrNoTenantID. Old logins also saved the server's default org (the lowest id) as if the user had chosen it. Commands now run as the first of: --org (name or id), NULLIFY_ORG, NULLIFY_GITHUB_OWNER_ID, the pick saved by `nullify org use` if the login still covers it, the git remote's owner if it names one org, the only org. Otherwise a terminal prompts and remembers the answer, and anything else fails with the choices. The saved query_parameters are never a pick. The org list is the router's (or a legacy tenant's) /admin/installations, kept to installations whose nullifyBaseUrl is this host; orgs not routed yet and orgs on other hosts are reported apart. An API token names no org unless asked, so the server takes the token's own. `auth status`, `auth config` and `whoami` show the effective host, auth host and org. Also from the wave 1 review: store read errors are surfaced instead of "not authenticated", `auth switch` drops hosts whose login is gone, and a router login removes the tenant's previous login. Plan-Step: 3/5 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`nullify mcp serve` resolved one org at startup and failed outright on a multi-org login. It now starts on auth alone. Every tool takes an optional org argument and list_organizations lists the choices; a call uses its org argument, then serve --org, then the CLI's chain, and returns the choices when that is still ambiguous. Resource reads follow the same rule. The scope rides in the request context and is set on each request by a transport, so concurrent calls for different orgs cannot cross. Plan-Step: 4/5 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Plan-Step: 5/5 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- MCP refuses an org argument that is blank or not a name or id instead of running the call as the default org; a numeric id is taken as that id. - The org list retries once with a force-refreshed token when the router rejects the stored one, so a revoked or skewed token no longer breaks every MCP call until its stored expiry. - An ambiguous --org settled at the prompt no longer replaces the remembered pick, and API-token commands no longer read hosts.json. - `org list` reports a NULLIFY_GITHUB_OWNER_ID scope, and has help text. - README: NULLIFY_ORG, API-token org rules, and the real host order. - Tests for serve --org as the MCP default. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
whiskeylotus
marked this pull request as ready for review
October 30, 2026 12:17
Logged-in `auth status` read the host from the saved config only, so `--host` and NULLIFY_HOST were ignored. After a router login the saved default is the router, and status for a tenant showed the router with no org. Use the same resolution as every other command (flag, env, config). Found running the branch against dev radiant through the dev router. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Resolving the effective host made a mistyped --host print "No host configured" and exit 0. Return the invalid-host error instead; with no host anywhere, status still prints the setup hint. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Author
|
Pushed Found while running this branch on dev (details in the PR body and https://linear.app/nullify-ai/issue/AGI-6072/climcp-login-fails-on-router-tenants-log-in-at-the-router-pick-the-org). No review comments were open. Left open, all of them pre-existing:
|
whiskeylotus
marked this pull request as draft
October 30, 2026 13:25
Adopts #171's model: a router login stored under the router key with a selected org (DataHost, Installation, QueryParameters, which v0.26.0 also reads), and the router as the default host. On top of it: - Router environments: prod (nullify.ai) and dev (router.dev.nullify.ai), each accepting only its own tenant origins from the org list. - Login on a tenant asks it where to sign in (/auth/cli/config); a router login binds every tenant it lists and retires their old tenant logins. - One org list and one picker (Org) for login, 'org use', 'auth switch --installation' (hidden alias) and the per-command resolver; every choice saves through SelectOrg. - Org precedence: --org, NULLIFY_ORG, NULLIFY_GITHUB_OWNER_ID, git remote owner, saved default (an unavailable one is an error, never another org), only org. A v0.26.0 selection is taken over as the default. - Host input parsed as a URL authority: fragment, userinfo and port are rejected (api.attacker.example#.nullify.ai no longer passes). - Credential and host writes under a cross-process lock; a refresh is saved only over the login it started from and never changes the org; one refresh per auth host at a time in a process. - MCP: data host fixed per server, default org fixed at start, per-call org from a cached list refetched once on a miss, results name the org. Plan-Step: 5/9 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An invalid NULLIFY_HOST or config host was logged and skipped, so a command ran against the next host in line with the user's token. Both are now errors. Tests cover the fragment, userinfo and port hosts at the command level, the v0.26.0 router login upgrade (its selection kept as the default and refused, not replaced, once unavailable), the credential lock, one refresh for concurrent rejections, a refresh never overwriting a logout or a newer login, the dev router aliases, and auth status noting an API token that overrides the login. Plan-Step: 5/9 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…redirect The generated API client set Authorization on every request and followed redirects with the default policy, so a request outside its API host, or a redirect within the same domain, carried the user's token. It now sets the bearer only on an https request to the API host it was built for and strips it on every redirect hop; the template generates the same. The GitHub Actions token exchange no longer follows redirects, since its body carries the GitHub token. Plan-Step: 6/9 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- The git remote owner (and the MCP repository) was read only from a .git directory, so a linked worktree or a submodule found none and the saved default won in a checkout of the other org. .git files are followed to the real git dir, and a worktree to its main repository's config. - 'nullify org use' naming an org on another tenant of the router saved the default but did not bind that tenant to the router, so plain commands did not move. It now binds it first. - Login with an unreadable hosts.json fails before the browser opens, instead of after saving credentials it then cannot bind. - gofrs/flock is a direct dependency. Tests: owner in a clone, a worktree and a submodule; org use moving the router's data host; the hidden auth switch --installation alias; an invalid config host; whoami's hosts and org. Plan-Step: 7/9 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…sted refusals Covers the MCP server end to end through the real auth, refresh and client code: the default org it settles at start survives a terminal 'nullify org use' and every result names its org; twenty calls across two orgs during a token rejection each keep their org and the router refreshes once; a call the tenant refuses as not hosted is not replayed and the cached org list is fetched again; an org on another tenant is named with its host and an unknown name is refused. Plan-Step: 8/9 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…grade README: where 'nullify auth login' signs in (router, or wherever the saved instance says), one router login covering every listed instance, --org at login, the org order with the repo owner before the default and an unavailable default stopping the command, 'org use' across instances, the MCP server's fixed default and per-call org, the API token host rule, and an upgrade note (drop the NULLIFY_TOKEN workaround, upgrade every copy, restart MCP). Replaces the --installation examples with --org and 'org use'. 'mcp serve --help' lists NULLIFY_GITHUB_OWNER_ID in the order. Plan-Step: 9/9 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- A failed check of whether the router login covers a tenant (router down, refresh failed) is reported as such, not as "not authenticated". - auth status and auth config return an invalid NULLIFY_HOST or config host as an error instead of "no host configured". - A router org reported pending or unavailable is never usable, even when its base URL names this host. - Answering the org prompt remembers the org for that tenant without moving the default instance (RememberOrg); 'org use', login and 'auth switch --installation' still move it (SelectOrg). - 'org list --org <org on another tenant>' agrees with what a command does. - The no-org-selected hint no longer suggests 'org use', which cannot run without a selection. Plan-Step: 7/9 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
'nullify org use' and --org on the router could match an org on another tenant that the router still reports pending or unavailable, and save it as the default, so commands failed until the user switched back. Such an org is now never matched there. Plan-Step: 7/9 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Tests: the 'auth switch --installation' alias saving through the same writer as 'org use' (and restoring --host); 'org list --org <org elsewhere>' on and off the router; RememberOrg moving the router selection only on its own tenant; auth status and auth config reporting an invalid host with an API token and from the config; a command surfacing a failed router coverage check; unavailable orgs on other tenants. The concurrent MCP test no longer calls require from goroutines. Docs: SelectOrg's comment names RememberOrg for the prompt; README says --org moves instance only when the command's host is the router; 'org' help lists the full order. Plan-Step: 7/9 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ge check - An org the router reports pending or unavailable is reported as not ready yet, on this host or another, instead of advising --host for a host that would refuse it too. - Whether a router login covers a tenant is asked only of routers whose domain can hold it, so a stale dev router login is never blamed for a production tenant. - auth status with no selected data host points to 'nullify auth login'. Plan-Step: 7/9 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…he old instance 'nullify org use' moving to another instance printed "Using X on <new>" while NULLIFY_HOST (or --host) kept plain commands on the old one. It now says the env or flag still points there. Plan-Step: 7/9 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- The org use note compares NULLIFY_HOST in its sanitized form, so a pasted URL, upper case or a bare name of the same tenant no longer claims commands stay elsewhere; --host, which lasts one command, never triggers it. - Pending orgs read "not ready yet" everywhere, including the no-orgs error and 'org list' help. - Tests: the note in every spelling and its silent cases; 'org list' run as a command on the router selecting an org elsewhere; auth status's login hint without a data host; the unavailable-org message; the coverage-check stub restored after its test. Plan-Step: 7/9 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A --host on 'nullify org use' hid the note that NULLIFY_HOST still pins later commands to the old instance. Tests now also cover a router pin and a saved host (no note) and --host with NULLIFY_HOST (note). The MCP list_organizations description says "not ready yet" like the CLI. Plan-Step: 7/9 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CI's golangci-lint flagged the deferred Unlock in withStoreLock. The error is deliberately discarded: the lock file closes with the process. Plan-Step: 5/9 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
whiskeylotus
marked this pull request as ready for review
November 2, 2026 20:01
tim-thacker-nullify
approved these changes
Nov 4, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
nullify auth loginandnullify mcp servefail for customers on router tenants (AGI-6072). A customer with two orgs on one instance needs one login and one MCP server that can use both. Rebuilt on top of v0.26.0 (#171): its router login stays, and this adds what it lacks.Login:
GET /auth/cli/config). A router instance hands the login to the router; an instance that signs in itself keeps its own login; nothing saved goes to the router. Answers naming anything but the instance or a known router fail closed.--host <instance>works afterwards without another login, and old per-instance logins for those instances are removed.router.dev.nullify.aiis a router likenullify.ai. Each router only accepts its own tenant origins from the org list; anything else is never contacted.--host,NULLIFY_HOSTand the config host are parsed as a URL authority. A fragment, userinfo or port is refused, soapi.attacker.example#.nullify.aino longer passes. An invalid env or config host is an error, not a fallback to another host.Organizations:
--org(name or id),NULLIFY_ORG,NULLIFY_GITHUB_OWNER_ID, the git remote's owner (worktrees and submodules included), your default (nullify org useor the last login), your only org. Never the lowest id.nullify org usecan pick an org on another instance and plain commands move there.--installationandauth switch --installationstay as hidden aliases.MCP:
org;list_organizationslists them; every result names its org.nullify org usein a terminal never moves a running server.Token safety:
gofrs/flock, Windows too). A refresh is saved only over the login it started from and never changes the org; concurrent rejections refresh once.Companion server side: https://github.com/Nullify-Platform/nullify/pull/17026 (CLI config discovery, old-binary refusal, and the stack org-boundary check). Go-live waits for both.
Evidence: unit and race tests for all of the above (22 packages). A live run on a dev stack with two test orgs, upgrade runs from v0.25.4 and v0.26.0, and a prod check are open before merge.
plan: agi-6072-router-login
🤖 Generated with Claude Code