Skip to content

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
mainfrom
feat/agi-6072-router-login-org-picker
Open

whiskeylotus wants to merge 23 commits into
mainfrom
feat/agi-6072-router-login-org-picker

Conversation

@whiskeylotus

@whiskeylotus whiskeylotus commented Oct 29, 2026 •

Copy link
Copy Markdown

Claude

nullify auth login and nullify mcp serve fail 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:

  • Where to sign in. With a host named or saved, the CLI asks that instance (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.
  • One router login covers every listed instance. --host <instance> works afterwards without another login, and old per-instance logins for those instances are removed.
  • Dev router. router.dev.nullify.ai is a router like nullify.ai. Each router only accepts its own tenant origins from the org list; anything else is never contacted.
  • Host input. --host, NULLIFY_HOST and the config host are parsed as a URL authority. A fragment, userinfo or port is refused, so api.attacker.example#.nullify.ai no longer passes. An invalid env or config host is an error, not a fallback to another host.

Organizations:

  • Order. --org (name or id), NULLIFY_ORG, NULLIFY_GITHUB_OWNER_ID, the git remote's owner (worktrees and submodules included), your default (nullify org use or the last login), your only org. Never the lowest id.
  • Unavailable default stops the command and names it; in a terminal it asks. It never runs as another org.
  • Across instances. Signed in through the router, nullify org use can pick an org on another instance and plain commands move there. --installation and auth switch --installation stay as hidden aliases.
  • Upgrade. A v0.26.0 selection becomes the default. The selection is still written where v0.26.0 reads it.

MCP:

  • One server serves one instance. Every tool takes an optional org; list_organizations lists them; every result names its org.
  • The default for calls naming none is fixed when the server starts, so nullify org use in a terminal never moves a running server.
  • The org list is cached a few minutes and fetched again once when a call names an unknown org or the instance refuses an org as not hosted. A refused call is never replayed.

Token safety:

  • Credential and host writes run under a cross-process lock (gofrs/flock, Windows too). A refresh is saved only over the login it started from and never changes the org; concurrent rejections refresh once.
  • The token goes only to the API host over https: the generated client, both transports and MCP; never on a redirect hop. Auth calls and the GitHub Actions exchange do not follow redirects.

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

whiskeylotus and others added 2 commits October 29, 2026 17:04
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>
@whiskeylotus whiskeylotus added the minor Minor version updates (features) label Oct 29, 2026
whiskeylotus and others added 4 commits October 29, 2026 17:34
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
whiskeylotus marked this pull request as ready for review October 30, 2026 12:17
whiskeylotus and others added 2 commits October 30, 2026 09:07
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>
@whiskeylotus

Copy link
Copy Markdown
Author

Claude

Pushed 6567acb and bfce76f: nullify auth status now reports the effective host (--host, then NULLIFY_HOST, then the saved default) and errors on an invalid --host. Before, it always showed the saved default, which after a login at another host is the wrong tenant.

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:

  • auth status with an API token still prints "No host configured" for an invalid --host.
  • auth token and auth logout do not read NULLIFY_HOST.

@whiskeylotus
whiskeylotus marked this pull request as draft October 30, 2026 13:25
whiskeylotus and others added 11 commits October 30, 2026 13:22
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>
@whiskeylotus whiskeylotus changed the title feat(auth): [AGI-6072] log in at the router on router tenants and pick the org feat(auth): [AGI-6072] router login, org per command and per MCP call, on top of v0.26.0 Oct 30, 2026
whiskeylotus and others added 4 commits October 30, 2026 13:43
…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
whiskeylotus marked this pull request as ready for review November 2, 2026 20:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

minor Minor version updates (features)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants