feat: harden IAM config — hardcode bundle constants, simplify login UX
IAM issuer, client ID, and loopback redirect URI are now bundle constants. Users no longer configure IAM — `agos login` works with zero setup. `agos config` now only exposes AGOS_CLI_API_URL (optional, for local dev). Removed --issuer / --client-id / --host / --port from login help output. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
126
cli/agos.py
126
cli/agos.py
@@ -64,20 +64,22 @@ _AUTH_DIR = Path.home() / '.agos'
|
||||
_AUTH_FILE = _AUTH_DIR / 'auth.json'
|
||||
_CONFIG_FILE = _AUTH_DIR / 'config.json'
|
||||
|
||||
# Keys the CLI recognizes and their descriptions
|
||||
# ── Hardcoded bundle constants ─────────────────────────────────────────────────
|
||||
# IAM and client details are fixed for the CLI bundle — users never configure
|
||||
# these. The loopback redirect URI is the RFC 8252 standard for native/CLI apps.
|
||||
_BUNDLE_IAM_ISSUER = 'https://iam.armco.dev'
|
||||
_BUNDLE_IAM_CLIENT_ID = 'client_60250edc6189418ab3d7ee4d1577c0e7'
|
||||
_BUNDLE_IAM_CALLBACK_HOST = '127.0.0.1'
|
||||
_BUNDLE_IAM_CALLBACK_PORT = 8976
|
||||
_BUNDLE_IAM_REDIRECT_URI = f'http://{_BUNDLE_IAM_CALLBACK_HOST}:{_BUNDLE_IAM_CALLBACK_PORT}/callback'
|
||||
_BUNDLE_API_URL_PROD = 'https://agos.armco.dev'
|
||||
_BUNDLE_API_URL_LOCAL = 'http://localhost:2000'
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
# Keys the user can configure (IAM is not user-configurable — it's baked in)
|
||||
_CONFIG_SCHEMA: dict[str, dict] = {
|
||||
'AGOS_CLI_IAM_ISSUER': {
|
||||
'description': 'Armco IAM issuer URL (e.g. https://iam.armco.dev)',
|
||||
'required': True,
|
||||
'env_aliases': ['VITE_IAM_ISSUER', 'IAM_ISSUER'],
|
||||
},
|
||||
'AGOS_CLI_IAM_CLIENT_ID': {
|
||||
'description': 'OAuth client ID for the native/CLI app',
|
||||
'required': True,
|
||||
'env_aliases': ['VITE_IAM_DESKTOP_CLIENT_ID', 'VITE_IAM_CLIENT_ID'],
|
||||
},
|
||||
'AGOS_CLI_API_URL': {
|
||||
'description': 'AGOS API base URL (default: http://localhost:2000)',
|
||||
'description': f'AGOS API base URL (prod: {_BUNDLE_API_URL_PROD} | local dev: {_BUNDLE_API_URL_LOCAL})',
|
||||
'required': False,
|
||||
'env_aliases': ['AGOS_API_URL', 'VITE_API_URL'],
|
||||
},
|
||||
@@ -111,6 +113,11 @@ def _config_effective_value(key: str) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_api_url_from_config() -> str:
|
||||
"""Return the API URL: stored config > env alias > prod default."""
|
||||
return _config_effective_value('AGOS_CLI_API_URL') or _BUNDLE_API_URL_PROD
|
||||
|
||||
|
||||
def _utc_now_iso() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
@@ -307,21 +314,6 @@ def cli():
|
||||
|
||||
|
||||
@cli.command()
|
||||
@click.option('--issuer', default=None, help='Armco IAM issuer URL')
|
||||
@click.option('--client-id', default=None, help='IAM OAuth client ID for the CLI/native app')
|
||||
@click.option(
|
||||
'--host',
|
||||
default='127.0.0.1',
|
||||
show_default=True,
|
||||
help='Loopback host for the local callback listener',
|
||||
)
|
||||
@click.option(
|
||||
'--port',
|
||||
default=8976,
|
||||
show_default=True,
|
||||
type=int,
|
||||
help='Loopback port for the local callback listener',
|
||||
)
|
||||
@click.option(
|
||||
'--timeout',
|
||||
'timeout_seconds',
|
||||
@@ -330,32 +322,21 @@ def cli():
|
||||
type=int,
|
||||
help='Seconds to wait for the browser login callback',
|
||||
)
|
||||
@click.option('--scopes', default=None, help='Space or comma separated OAuth scopes')
|
||||
def login(issuer, client_id, host, port, timeout_seconds, scopes):
|
||||
"""Login with Armco IAM for Agos CLI."""
|
||||
# Hidden dev-only overrides — not shown in help
|
||||
@click.option('--issuer', default=None, hidden=True)
|
||||
@click.option('--client-id', default=None, hidden=True)
|
||||
@click.option('--host', default=None, hidden=True)
|
||||
@click.option('--port', default=None, type=int, hidden=True)
|
||||
def login(timeout_seconds, issuer, client_id, host, port):
|
||||
"""Login to Agos via Armco IAM (opens browser for authentication)."""
|
||||
correlation_id = f'cli_login_{uuid.uuid4().hex[:12]}'
|
||||
resolved_issuer = issuer or _config_effective_value('AGOS_CLI_IAM_ISSUER') or _first_env('VITE_IAM_ISSUER', 'IAM_ISSUER')
|
||||
resolved_client_id = client_id or _config_effective_value('AGOS_CLI_IAM_CLIENT_ID') or _first_env('VITE_IAM_CLIENT_ID')
|
||||
resolved_scopes = _parse_scopes(scopes or _first_env('AGOS_CLI_IAM_SCOPES'))
|
||||
redirect_uri = f'http://{host}:{port}/callback'
|
||||
|
||||
if not resolved_issuer:
|
||||
raise click.ClickException(
|
||||
'Missing IAM issuer. Run `agos config` to set it up, '
|
||||
'or set AGOS_CLI_IAM_ISSUER / VITE_IAM_ISSUER, or pass --issuer.'
|
||||
)
|
||||
|
||||
if not resolved_client_id:
|
||||
raise click.ClickException(
|
||||
'Missing CLI IAM client ID. Run `agos config` to set it up, '
|
||||
'or set AGOS_CLI_IAM_CLIENT_ID / VITE_IAM_DESKTOP_CLIENT_ID, or pass --client-id.'
|
||||
)
|
||||
|
||||
if os.getenv('AGOS_CLI_IAM_CLIENT_ID', '').strip() == '' and os.getenv('VITE_IAM_CLIENT_ID', '').strip():
|
||||
console.print(
|
||||
'[yellow]Using VITE_IAM_CLIENT_ID for CLI login. '
|
||||
'Ensure IAM allows the loopback redirect URI for this client.[/yellow]'
|
||||
)
|
||||
# Bundle constants — users never need to configure these
|
||||
resolved_issuer = issuer or _BUNDLE_IAM_ISSUER
|
||||
resolved_client_id = client_id or _BUNDLE_IAM_CLIENT_ID
|
||||
resolved_host = host or _BUNDLE_IAM_CALLBACK_HOST
|
||||
resolved_port = port or _BUNDLE_IAM_CALLBACK_PORT
|
||||
resolved_scopes = _parse_scopes(_first_env('AGOS_CLI_IAM_SCOPES'))
|
||||
redirect_uri = f'http://{resolved_host}:{resolved_port}/callback'
|
||||
|
||||
_emit_log(
|
||||
'INFO',
|
||||
@@ -376,8 +357,8 @@ def login(issuer, client_id, host, port, timeout_seconds, scopes):
|
||||
code_verifier = _pkce_verifier()
|
||||
code_challenge = _pkce_challenge(code_verifier)
|
||||
callback_event, callback_payload, thread = _start_callback_server(
|
||||
host=host,
|
||||
port=port,
|
||||
host=resolved_host,
|
||||
port=resolved_port,
|
||||
expected_state=state,
|
||||
timeout_seconds=timeout_seconds,
|
||||
correlation_id=correlation_id,
|
||||
@@ -474,8 +455,9 @@ def login(issuer, client_id, host, port, timeout_seconds, scopes):
|
||||
@click.option('--list', 'list_all', is_flag=True, default=False, help='Show current stored config.')
|
||||
@click.option('--unset', default=None, metavar='KEY', help='Remove a stored config key.')
|
||||
def config_command(assignment, list_all, unset):
|
||||
"""Configure CLI settings (IAM issuer, client ID, API URL, etc.).
|
||||
"""Configure CLI settings (API URL, etc.).
|
||||
|
||||
IAM auth details are bundled — you do not need to configure them.
|
||||
Run without arguments for interactive setup.
|
||||
Pass KEY=VALUE pairs to set specific values.
|
||||
Use --list to view current config or --unset KEY to remove a value.
|
||||
@@ -483,8 +465,7 @@ def config_command(assignment, list_all, unset):
|
||||
\b
|
||||
Examples:
|
||||
agos config
|
||||
agos config AGOS_CLI_IAM_ISSUER=https://iam.armco.dev
|
||||
agos config AGOS_CLI_IAM_CLIENT_ID=client_abc123
|
||||
agos config AGOS_CLI_API_URL=https://agos.armco.dev
|
||||
agos config --list
|
||||
agos config --unset AGOS_CLI_API_URL
|
||||
"""
|
||||
@@ -529,7 +510,7 @@ def config_command(assignment, list_all, unset):
|
||||
for pair in assignment:
|
||||
if '=' not in pair:
|
||||
raise click.ClickException(
|
||||
f'Invalid format: `{pair}`. Use KEY=VALUE (e.g. AGOS_CLI_IAM_ISSUER=https://iam.armco.dev)'
|
||||
f'Invalid format: `{pair}`. Use KEY=VALUE (e.g. AGOS_CLI_API_URL=https://agos.armco.dev)'
|
||||
)
|
||||
key, _, value = pair.partition('=')
|
||||
key = key.strip()
|
||||
@@ -541,22 +522,22 @@ def config_command(assignment, list_all, unset):
|
||||
console.print(f'[green]Set[/green] {key} = {cfg[key]}')
|
||||
return
|
||||
|
||||
# Interactive mode — check required keys and prompt for any that are missing
|
||||
console.print('[bold blue]Agos CLI Configuration Setup[/bold blue]')
|
||||
# Interactive mode
|
||||
console.print('[bold blue]Agos CLI Configuration[/bold blue]')
|
||||
console.print(
|
||||
f'[dim]Settings are saved to {_CONFIG_FILE}[/dim]\n'
|
||||
'[dim]Press Enter to keep an existing value.[/dim]\n'
|
||||
f'[dim]Settings saved to {_CONFIG_FILE}[/dim]\n'
|
||||
f'[dim]IAM auth is pre-configured (issuer: {_BUNDLE_IAM_ISSUER})[/dim]\n'
|
||||
'[dim]Press Enter to keep an existing value, or clear it to reset to default.[/dim]\n'
|
||||
)
|
||||
changed = False
|
||||
for key, meta in _CONFIG_SCHEMA.items():
|
||||
effective = _config_effective_value(key)
|
||||
stored = cfg.get(key, '')
|
||||
required_label = '[red]*required[/red]' if meta['required'] else '[dim]optional[/dim]'
|
||||
required_label = '[dim]optional[/dim]'
|
||||
prompt_default = stored or ''
|
||||
# Show current effective source if not stored directly
|
||||
if effective and not stored:
|
||||
console.print(
|
||||
f'[dim] {key} is currently satisfied by an environment variable/alias: {effective}[/dim]'
|
||||
f'[dim] {key} is currently set via environment: {effective}[/dim]'
|
||||
)
|
||||
display_default = f' [dim](current: {stored})[/dim]' if stored else ''
|
||||
console.print(f'\n[cyan]{key}[/cyan] {required_label}{display_default}')
|
||||
@@ -567,7 +548,6 @@ def config_command(assignment, list_all, unset):
|
||||
cfg[key] = new_value
|
||||
changed = True
|
||||
elif not new_value and stored:
|
||||
# User cleared the value
|
||||
del cfg[key]
|
||||
changed = True
|
||||
|
||||
@@ -577,18 +557,10 @@ def config_command(assignment, list_all, unset):
|
||||
else:
|
||||
console.print('\n[dim]No changes.[/dim]')
|
||||
|
||||
# Final summary
|
||||
console.print()
|
||||
all_ok = all(
|
||||
_config_effective_value(k)
|
||||
for k, m in _CONFIG_SCHEMA.items()
|
||||
if m['required']
|
||||
)
|
||||
if all_ok:
|
||||
console.print('[bold green]✓ All required settings are configured. Run `agos login` to authenticate.[/bold green]')
|
||||
else:
|
||||
missing = [k for k, m in _CONFIG_SCHEMA.items() if m['required'] and not _config_effective_value(k)]
|
||||
console.print(f'[yellow]⚠ Still missing required keys: {", ".join(missing)}[/yellow]')
|
||||
api_url = _resolve_api_url_from_config()
|
||||
console.print(f'[bold green]✓ Ready. API target: {api_url}[/bold green]')
|
||||
console.print('[dim]Run `agos login` to authenticate.[/dim]')
|
||||
|
||||
|
||||
@cli.group()
|
||||
|
||||
@@ -28,11 +28,14 @@ _MANUAL: Dict[str, Dict[str, object]] = {
|
||||
],
|
||||
},
|
||||
'login': {
|
||||
'summary': 'Authenticate the CLI with Armco IAM and cache tokens in `~/.agos/auth.json`.',
|
||||
'summary': (
|
||||
'Authenticate the CLI with Armco IAM via a browser-based PKCE flow. '
|
||||
'IAM provider details are pre-configured — just run `agos login`. '
|
||||
'Tokens are cached in ~/.agos/auth.json.'
|
||||
),
|
||||
'examples': [
|
||||
'agos login',
|
||||
'agos login --issuer https://iam.armco.dev --client-id client_abc123',
|
||||
'agos login --port 8976 --timeout 240',
|
||||
'agos login --timeout 240',
|
||||
],
|
||||
},
|
||||
'whoami': {
|
||||
@@ -57,20 +60,21 @@ _MANUAL: Dict[str, Dict[str, object]] = {
|
||||
},
|
||||
'config': {
|
||||
'summary': (
|
||||
'Configure CLI settings (IAM issuer, client ID, API URL) stored in ~/.agos/config.json. '
|
||||
'Configure CLI settings stored in ~/.agos/config.json. '
|
||||
'IAM auth details are pre-configured in the bundle — users only need to '
|
||||
'set AGOS_CLI_API_URL if targeting a non-default API endpoint. '
|
||||
'Run without arguments for an interactive guided setup. '
|
||||
'Pass KEY=VALUE pairs to set individual keys non-interactively.'
|
||||
),
|
||||
'examples': [
|
||||
'agos config',
|
||||
'agos config AGOS_CLI_IAM_ISSUER=https://iam.armco.dev',
|
||||
'agos config AGOS_CLI_IAM_CLIENT_ID=client_abc123',
|
||||
'agos config AGOS_CLI_API_URL=https://agos.armco.dev',
|
||||
'agos config --list',
|
||||
'agos config --unset AGOS_CLI_API_URL',
|
||||
],
|
||||
'notes': [
|
||||
'Config is stored at ~/.agos/config.json (mode 0600).',
|
||||
'Values set here override .env files but are overridden by explicit --flags.',
|
||||
'IAM issuer and client ID are hardcoded in the bundle — do not set them here.',
|
||||
'After configuring, run `agos login` to authenticate.',
|
||||
],
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user