diff --git a/src/commands/auth/setup.test.ts b/src/commands/auth/setup.test.ts index fb16593..85b03fc 100644 --- a/src/commands/auth/setup.test.ts +++ b/src/commands/auth/setup.test.ts @@ -116,6 +116,28 @@ describe('runSetup', () => { expect(readAuth()?.tokens.accessToken).toBe('old-at'); // creds untouched }); + it('signs into the current target when the stored credential is for a different env', async () => { + // Credential is for dev; this run targets production - do not introspect cross-env, sign in fresh. + saveAuth(existingAuth); + process.env['AGENTAGE_SITE_FQDN'] = 'agentage.io'; + const { deps } = makeDeps(); + await runSetup({}, deps); + expect(deps.introspect).not.toHaveBeenCalled(); + expect(deps.register).toHaveBeenCalled(); + expect(readAuth()?.siteFqdn).toBe('agentage.io'); + expect(readAuth()?.clientId).toBe('client-1'); + }); + + it('signs into dev when a production credential meets a dev target', async () => { + saveAuth({ ...existingAuth, siteFqdn: 'agentage.io' }); + process.env['AGENTAGE_SITE_FQDN'] = 'dev.agentage.io'; + const { deps } = makeDeps(); + await runSetup({}, deps); + expect(deps.introspect).not.toHaveBeenCalled(); + expect(deps.register).toHaveBeenCalled(); + expect(readAuth()?.siteFqdn).toBe('dev.agentage.io'); + }); + it('auto-enters sign-in when the stored session is terminally expired', async () => { saveAuth(existingAuth); const { deps } = makeDeps(); diff --git a/src/commands/auth/setup.ts b/src/commands/auth/setup.ts index 3b6c43a..4ce7f10 100644 --- a/src/commands/auth/setup.ts +++ b/src/commands/auth/setup.ts @@ -11,6 +11,7 @@ import { type AuthState, } from '../../lib/fs/config.js'; import { AuthRequiredError, introspectToken } from '../../lib/auth/api.js'; +import { detectEnvMismatch } from '../../lib/auth/env-match.js'; import { buildAuthorizeUrl, exchangeCode, @@ -90,8 +91,17 @@ const disconnect = async (deps: SetupDeps): Promise => { }; // Validate the stored session instead of trusting mere token presence. Returns true to short-circuit -// (valid or a transient blip - creds intact), false to proceed into a fresh sign-in (truly expired). +// (valid or a transient blip - creds intact), false to proceed into a fresh sign-in (truly expired or +// the credential belongs to a different target than the current run). const alreadySignedIn = async (auth: AuthState, deps: SetupDeps): Promise => { + const mismatch = detectEnvMismatch(auth, siteFqdn()); + if (mismatch) { + console.log( + `Signed in to ${mismatch.credentialFqdn} (${mismatch.credentialEnv}), but this run targets ` + + `${mismatch.targetFqdn} (${mismatch.targetEnv}) - signing you into ${mismatch.targetEnv}.\n` + ); + return false; // proceed to a fresh sign-in against the current target + } try { await deps.introspect(auth, links(auth.siteFqdn)); console.log( diff --git a/src/commands/status/status.test.ts b/src/commands/status/status.test.ts index cd57754..8587bec 100644 --- a/src/commands/status/status.test.ts +++ b/src/commands/status/status.test.ts @@ -70,6 +70,27 @@ describe('printStatus', () => { expect(out).toContain('signed in (session active)'); }); + it('renders a neutral env-mismatch line naming both sides, never expired', () => { + const out = captureLines({ + ...baseReport, + auth: { + signedIn: false, + mismatch: { + credentialFqdn: 'dev.agentage.io', + credentialEnv: 'development', + targetFqdn: 'agentage.io', + targetEnv: 'production', + }, + note: 'signed in to dev.agentage.io - CLI targets agentage.io (production)', + }, + }); + expect(out).toContain('signed in to dev.agentage.io'); + expect(out).toContain('CLI targets agentage.io (production)'); + expect(out).not.toContain('session expired'); + expect(out).not.toContain('✗'); + expect(out).toContain('!'); + }); + it('prints the setup hint when signed out', () => { const out = captureLines({ ...baseReport, diff --git a/src/commands/status/status.ts b/src/commands/status/status.ts index c096439..1b149b6 100644 --- a/src/commands/status/status.ts +++ b/src/commands/status/status.ts @@ -21,6 +21,9 @@ const row = (label: string, value: string): void => { // access-token expiry, which misreads as "yesterday" near midnight in positive-UTC zones. // Introspection already proved the session is active server-side. const authLine = (auth: StatusReport['auth']): string => { + // Env mismatch: the credential is valid but for another target - neutral `!`, not the expired ✗. + if (auth.mismatch) + return `${chalk.yellow('!')} ${auth.note ?? 'signed in to another environment'}`; if (!auth.signedIn) return `${mark(false)} ${auth.note ?? 'not signed in'}`; // Transient: we hold a valid-looking token but could not re-verify - a non-terminal `~`, never ✗. if (auth.transient) return `${chalk.yellow('~')} ${auth.note ?? 'signed in'}`; diff --git a/src/lib/auth/env-match.test.ts b/src/lib/auth/env-match.test.ts new file mode 100644 index 0000000..55e591b --- /dev/null +++ b/src/lib/auth/env-match.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest'; +import { type AuthState } from '../fs/config.js'; +import { detectEnvMismatch } from './env-match.js'; + +const authFor = (siteFqdn: string): AuthState => ({ + siteFqdn, + clientId: 'c1', + tokens: { accessToken: 'at' }, +}); + +describe('detectEnvMismatch', () => { + it('returns null when the credential fqdn matches the target', () => { + expect(detectEnvMismatch(authFor('dev.agentage.io'), 'dev.agentage.io')).toBeNull(); + expect(detectEnvMismatch(authFor('agentage.io'), 'agentage.io')).toBeNull(); + }); + + it('flags a dev credential against a production target', () => { + expect(detectEnvMismatch(authFor('dev.agentage.io'), 'agentage.io')).toEqual({ + credentialFqdn: 'dev.agentage.io', + credentialEnv: 'development', + targetFqdn: 'agentage.io', + targetEnv: 'production', + }); + }); + + it('flags a production credential against a dev target', () => { + expect(detectEnvMismatch(authFor('agentage.io'), 'dev.agentage.io')).toEqual({ + credentialFqdn: 'agentage.io', + credentialEnv: 'production', + targetFqdn: 'dev.agentage.io', + targetEnv: 'development', + }); + }); + + it('classifies a localhost target as development', () => { + const m = detectEnvMismatch(authFor('agentage.io'), 'localhost:3000'); + expect(m?.targetEnv).toBe('development'); + expect(m?.credentialEnv).toBe('production'); + }); + + it('normalizes scheme and trailing slash before comparing', () => { + expect(detectEnvMismatch(authFor('https://agentage.io/'), 'agentage.io')).toBeNull(); + }); +}); diff --git a/src/lib/auth/env-match.ts b/src/lib/auth/env-match.ts new file mode 100644 index 0000000..30d52b4 --- /dev/null +++ b/src/lib/auth/env-match.ts @@ -0,0 +1,25 @@ +import { type AuthState } from '../fs/config.js'; +import { environment, normalizeFqdn, type Env } from '../net/origins.js'; + +export interface AuthEnvMismatch { + credentialFqdn: string; + credentialEnv: Env; + targetFqdn: string; + targetEnv: Env; +} + +// The stored credential belongs to the environment it was issued for (auth.siteFqdn); the CLI's +// current target comes from AGENTAGE_SITE_FQDN (production default). Introspecting a dev credential +// against production rejects it and misreads as "expired" - so detect the mismatch by normalized +// fqdn BEFORE any introspection. Returns null when the credential matches the current target. +export const detectEnvMismatch = (auth: AuthState, targetFqdn: string): AuthEnvMismatch | null => { + const credentialFqdn = normalizeFqdn(auth.siteFqdn); + const target = normalizeFqdn(targetFqdn); + if (credentialFqdn === target) return null; + return { + credentialFqdn, + credentialEnv: environment(credentialFqdn), + targetFqdn: target, + targetEnv: environment(target), + }; +}; diff --git a/src/lib/status/status-info.test.ts b/src/lib/status/status-info.test.ts index 471d58e..ff36ea7 100644 --- a/src/lib/status/status-info.test.ts +++ b/src/lib/status/status-info.test.ts @@ -173,6 +173,30 @@ describe('gatherStatus', () => { expect(report.auth.note).not.toContain('expired'); }); + it('reports an env mismatch (dev credential, prod target) instead of session expired', async () => { + // No fetch stub for get-session: a mismatch must never introspect cross-environment. + const report = await gatherStatus(auth, 'agentage.io'); + expect(report.auth.signedIn).toBe(false); + expect(report.auth.mismatch).toEqual({ + credentialFqdn: 'dev.agentage.io', + credentialEnv: 'development', + targetFqdn: 'agentage.io', + targetEnv: 'production', + }); + expect(report.auth.note).toContain('dev.agentage.io'); + expect(report.auth.note).toContain('agentage.io'); + expect(report.auth.note).not.toContain('session expired'); + }); + + it('reports an env mismatch in the reverse direction (prod credential, dev target)', async () => { + const prodAuth = { ...auth, siteFqdn: 'agentage.io' }; + const report = await gatherStatus(prodAuth, 'dev.agentage.io'); + expect(report.auth.signedIn).toBe(false); + expect(report.auth.mismatch?.credentialEnv).toBe('production'); + expect(report.auth.mismatch?.targetEnv).toBe('development'); + expect(report.auth.note).not.toContain('session expired'); + }); + it('reports the daemon as stopped when no pidfile is present', async () => { vi.stubGlobal( 'fetch', diff --git a/src/lib/status/status-info.ts b/src/lib/status/status-info.ts index b0ee55c..236a146 100644 --- a/src/lib/status/status-info.ts +++ b/src/lib/status/status-info.ts @@ -1,4 +1,5 @@ import { AuthRequiredError, introspectToken } from '../auth/api.js'; +import { detectEnvMismatch, type AuthEnvMismatch } from '../auth/env-match.js'; import { type AuthState } from '../fs/config.js'; import { health, syncStatus } from '../daemon/daemon-client.js'; import { fetchJsonUnref } from '../net/http.js'; @@ -33,7 +34,15 @@ export interface StatusReport { fqdn: string; env: Env; target: { fqdn: string; env: Env; reachable: boolean }; - auth: { signedIn: boolean; tokenExpiresAt?: string; note?: string; transient?: boolean }; + auth: { + signedIn: boolean; + tokenExpiresAt?: string; + note?: string; + transient?: boolean; + // Set when the stored credential's env differs from the current target: signedIn is false and the + // printer renders a neutral "signed in to X - CLI targets Y" line, never "session expired". + mismatch?: AuthEnvMismatch; + }; endpoint: { url: string; reachable: boolean }; update: UpdateInfo; daemon?: DaemonStatus; @@ -108,6 +117,16 @@ const classifyAuthError = (err: unknown): StatusReport['auth'] => ? { signedIn: false, note: 'session expired - run: agentage setup' } : { signedIn: true, transient: true, note: 'signed in (could not re-verify - temporary)' }; +// A credential issued for one environment, checked against another, is not expired - it belongs +// elsewhere. Name both sides and hint the two fixes, without introspecting cross-environment. +const mismatchAuth = (m: AuthEnvMismatch): StatusReport['auth'] => ({ + signedIn: false, + mismatch: m, + note: + `signed in to ${m.credentialFqdn} - CLI targets ${m.targetFqdn} (${m.targetEnv}); ` + + `run: agentage setup to sign into ${m.targetEnv}, or set AGENTAGE_SITE_FQDN=${m.credentialFqdn}`, +}); + export const gatherStatus = async (auth: AuthState | null, fqdn: string): Promise => { const target = links(fqdn); const env = environment(fqdn); @@ -133,6 +152,11 @@ export const gatherStatus = async (auth: AuthState | null, fqdn: string): Promis vaults: buildVaultStatuses(probe.sync, probe.status.running), }; if (!auth) return report; + const mismatch = detectEnvMismatch(auth, fqdn); + if (mismatch) { + report.auth = mismatchAuth(mismatch); + return report; + } try { const session = await introspectToken(auth, target); report.auth = { signedIn: true, tokenExpiresAt: session.expiresAt };