Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions src/commands/auth/setup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
12 changes: 11 additions & 1 deletion src/commands/auth/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -90,8 +91,17 @@ const disconnect = async (deps: SetupDeps): Promise<void> => {
};

// 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<boolean> => {
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(
Expand Down
21 changes: 21 additions & 0 deletions src/commands/status/status.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
3 changes: 3 additions & 0 deletions src/commands/status/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'}`;
Expand Down
44 changes: 44 additions & 0 deletions src/lib/auth/env-match.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
25 changes: 25 additions & 0 deletions src/lib/auth/env-match.ts
Original file line number Diff line number Diff line change
@@ -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),
};
};
24 changes: 24 additions & 0 deletions src/lib/status/status-info.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
26 changes: 25 additions & 1 deletion src/lib/status/status-info.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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<StatusReport> => {
const target = links(fqdn);
const env = environment(fqdn);
Expand All @@ -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 };
Expand Down