Skip to content
Open
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
329 changes: 329 additions & 0 deletions src/azure-cli/azure/cli/command_modules/acs/_azure_plugin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,329 @@
# --------------------------------------------------------------------------------------------
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License. See License.txt in the project root for license information.
# --------------------------------------------------------------------------------------------

"""Native, host-owned installation of the full Azure plugin, after caller consent."""

import json
import os
import re
import shutil
import signal
import subprocess
import sys

from knack.log import get_logger

from azure.cli.core.azclierror import (
ClientRequestError, InvalidArgumentValueError, ResourceNotFoundError, ValidationError,
)

logger = get_logger(__name__)

HOST_IDS = ('claude-code', 'github-copilot', 'codex')
HOST_LABELS = {'claude-code': 'Claude Code', 'github-copilot': 'GitHub Copilot CLI', 'codex': 'Codex CLI'}
_EXECUTABLES = {'claude-code': 'claude', 'github-copilot': 'copilot', 'codex': 'codex'}
_LIFECYCLE_GUIDANCE = {
'claude-code': 'Update: claude plugin update azure@claude-plugins-official --scope user\n'
'Remove: claude plugin uninstall azure@claude-plugins-official --scope user',
'github-copilot': 'Update: copilot plugin update azure@azure-skills\n'
'Remove: copilot plugin uninstall azure@azure-skills',
'codex': 'Update (marketplace-wide; affects other installed plugins from azure-skills): '
'codex plugin marketplace upgrade azure-skills\n'

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Make Codex update guidance conditional on the marketplace source

install_plugin() intentionally reuses an existing marketplace named azure-skills, including a local marketplace, but a successful install always prints this Git-only update command. I reproduced this with native Codex 0.146.0: register an inert local azure-skills marketplace, install azure@azure-skills through this PR's helper, then run the exact printed command. Installation succeeds; the update command exits 1 with:

Error: marketplace `azure-skills` is not configured as a Git marketplace

For Git-backed marketplaces this command does refresh installed plugin caches; that behavior is correct. The issue is specifically the unconditional advice after an installation from a supported existing local source.

Please make the guidance source-aware, or explicitly qualify the Git-only command rather than presenting it as applicable to every successful Codex installation. Add a local-marketplace case to the lifecycle-guidance coverage.

'Remove: codex plugin remove azure@azure-skills',
}


def validate_plugin_options(install_azure_plugin=None, plugin_hosts=None):
"""Validate consent and normalize explicit hosts before binary installation."""
if install_azure_plugin is not True:
if plugin_hosts is not None:
raise InvalidArgumentValueError('--plugin-hosts requires --install-azure-plugin true.')
return None
if not plugin_hosts or any(host not in HOST_IDS for host in plugin_hosts):
raise InvalidArgumentValueError(
'--install-azure-plugin true requires --plugin-hosts with one or more of: ' + ', '.join(HOST_IDS))
if _under_sudo():
raise InvalidArgumentValueError(
'Azure plugin setup cannot run under sudo. Run as the intended host user with writable binary paths.')
return [host for host in HOST_IDS if host in plugin_hosts]


def _under_sudo():
return bool(os.environ.get('SUDO_USER') or os.environ.get('SUDO_UID'))


def maybe_install_azure_plugin(cmd, install_azure_plugin=None, plugin_hosts=None):
"""Hint after both binaries succeed; only explicit flags authorize setup."""
hosts = validate_plugin_options(install_azure_plugin, plugin_hosts)
if install_azure_plugin is False:
return
if install_azure_plugin is None:
if (_under_sudo() or not sys.stdin.isatty() or
cmd.cli_ctx.config.getboolean('core', 'disable_confirm_prompt', fallback=False)):
return
logger.warning('For optional Azure plugin setup, use --install-azure-plugin true --plugin-hosts <host>.')
return
_disclose_setup()
_install_selected_hosts(hosts)


def _install_selected_hosts(hosts):
failures = []
outcomes = []
for host in hosts:
try:
installed = install_plugin(host)
except KeyboardInterrupt:
outcomes.append(f'{HOST_LABELS[host]}: interrupted; native state is uncertain.')
# Cancellation must disclose partial state even with --only-show-errors.
print(_setup_summary(outcomes), file=sys.stderr)
raise
except (ClientRequestError, ResourceNotFoundError, ValidationError) as ex:
failures.append(ex)
outcomes.append(f'{HOST_LABELS[host]}: failed. {ex}')
else:
status = ('installed; authentication/activation and hook trust may still be required' if installed else
'Azure already reported; plugin install skipped (a marketplace may have been added)')
outcome = f'{HOST_LABELS[host]}: {status}.'
outcomes.append(outcome)
print(outcome, file=sys.stderr)
if installed:
print(_LIFECYCLE_GUIDANCE[host], file=sys.stderr)
if failures:
raise type(failures[0])(_setup_summary(outcomes)) from None


def _setup_summary(outcomes):
summary = '\n'.join(outcomes)
return ('Azure plugin setup did not complete for all selected hosts. '
f'kubectl and kubelogin remain installed.\n{summary}\n'
"Inspect and recover using each host's native plugin commands. "
'No automatic retry or rollback was attempted.')


def _disclose_setup():
# Consent context must remain visible even with --only-show-errors.
print(
'Installing the full Azure plugin (skills, MCP configuration and hooks) in native user/global scope, '
'not repository scope. New installs require an installed host CLI and Node.js 22+ with npx on PATH. '
'Azure authentication, MCP activation, hook trust and sovereign-cloud setup may still be required.\n'
'Reported existing Azure plugins, including disabled ones, stay unchanged. Inventory absence authorizes '
'native installation and enablement, including changes to hidden/stale preferences or registrations.\n'
'@azure/mcp@latest is not pinned by the plugin version. Host policy, marketplace sources/pins and updates '
'remain authoritative. Azure CLI installs no prerequisites and performs no Azure login or resource operations.',
file=sys.stderr,
)


def install_plugin(host_id: str) -> bool:
"""Install after consent; False means Azure was found and no plugin install ran.

A marketplace may have been added before an existing installation became visible.
Native inventory can hide stale registrations; consent covers native install-and-enable.
"""
if host_id not in HOST_IDS:
raise InvalidArgumentValueError(f'Unsupported Azure plugin host: {host_id}. Choose from {", ".join(HOST_IDS)}.')
executable = shutil.which(_EXECUTABLES[host_id])
if not executable:
raise ResourceNotFoundError(_failure(host_id, 'prerequisite check',
f'Install {_EXECUTABLES[host_id]} and make it available on PATH.'))
args = ['plugin', 'list', '--available', '--json'] if host_id == 'codex' else ['plugin', 'list', '--json']

@FumingZhang FumingZhang Sep 24, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Check host compatibility before redacted JSON inventory

This checks that the executable exists but assumes it supports the required plugin JSON commands. Reproduced with GitHub Copilot CLI 1.0.75 against head 93e2513a: plugin list --json rejects --json, and the actual installer reports only exit 1 plus [sensitive inventory output suppressed]. The host is installed, but users receive no actionable indication that they need to upgrade it.

Please check/document the required host versions or capabilities and provide safe upgrade guidance for unsupported-command/option failures, while keeping inventory payloads redacted. A regression test for an installed host lacking the required JSON command would cover this path.

inventory = _inventory(host_id, [executable, *args], 'plugin inventory')
if _azure_registered(host_id, inventory):
return False

if host_id == 'github-copilot':
_check_copilot_mcp(executable)
if host_id == 'claude-code':
marketplace = 'claude-plugins-official'
source = 'anthropics/claude-plugins-official'
flags = ['--scope', 'user']
else:
marketplace = 'azure-skills'
source = 'microsoft/azure-skills'
flags = ['--json'] if host_id == 'codex' else []

markets = _inventory(host_id, [executable, 'plugin', 'marketplace', 'list', '--json'], 'marketplace inventory')
if host_id == 'codex':
markets = markets.get('marketplaces') if isinstance(markets, dict) else None
origin = 'root' if host_id == 'codex' else 'source'
if not isinstance(markets, list) or any(
not isinstance(row, dict) or not _text(row.get('name')) or not _text(row.get(origin))
for row in markets):
raise ValidationError(_failure(host_id, 'marketplace inventory', 'Unrecognized native JSON schema.'))
_check_runtime(host_id)
if not any(row['name'] == marketplace for row in markets):
_run(host_id, [executable, 'plugin', 'marketplace', 'add', source, *flags], 'marketplace add')
# Adding a catalog can expose installed or live plugins hidden from the first inventory.
inventory = _inventory(host_id, [executable, *args], 'plugin inventory')
if _azure_registered(host_id, inventory):
return False
action = 'add' if host_id == 'codex' else 'install'
_run(host_id, [executable, 'plugin', action, f'azure@{marketplace}', *flags], 'plugin install')
return True


def _check_runtime(host_id):
node = shutil.which('node')
if not node or not shutil.which('npx'):
raise ResourceNotFoundError(_failure(host_id, 'runtime prerequisite check',
'Install Node.js 22 or later with node and npx available on PATH.'))
result = _run(host_id, [node, '--version'], 'Node.js version check')
version = re.fullmatch(r'v(\d+)\.\d+\.\d+', result.stdout.strip())
if not version or int(version[1]) < 22:
raise ValidationError(_failure(host_id, 'runtime prerequisite check',
'Node.js 22 or later is required for a new Azure plugin installation. '
f'Reported version: {_diagnostic(result.stdout)}'))


def _text(value):
return isinstance(value, str) and bool(value.strip())


def _azure_registered(host_id, inventory):
error = _failure(host_id, 'plugin inventory', 'Unrecognized native JSON schema.')
if host_id == 'codex':
if not isinstance(inventory, dict) or not all(
isinstance(inventory.get(key), list) for key in ('installed', 'available')):
raise ValidationError(error)
for key, installed in (('installed', True), ('available', False)):
for row in inventory[key]:
if (not isinstance(row, dict) or not _text(row.get('name')) or
not _text(row.get('marketplaceName')) or not isinstance(row.get('enabled'), bool)):
raise ValidationError(error)
if (row.get('installed') is not installed or
row.get('pluginId') != f"{row['name']}@{row['marketplaceName']}"):
raise ValidationError(error)
return any(row['name'] == 'azure' for row in inventory['installed'])

identity = 'id' if host_id == 'claude-code' else 'name'
origin = 'scope' if host_id == 'claude-code' else 'source'
if not isinstance(inventory, list) or any(
not isinstance(row, dict) or not _text(row.get(identity)) or
not _text(row.get(origin)) or not isinstance(row.get('enabled'), bool)
for row in inventory):
raise ValidationError(error)
return any(row[identity].split('@', 1)[0] == 'azure' for row in inventory)


def _check_copilot_mcp(executable):
inventory = _inventory('github-copilot', [executable, 'mcp', 'list', '--json'], 'MCP inventory')
servers = inventory.get('mcpServers') if isinstance(inventory, dict) else None
if not isinstance(servers, dict) or any(
not isinstance(row, dict) or not _text(row.get('source')) or not isinstance(row.get('enabled'), bool)
for row in servers.values()):
raise ValidationError(_failure('github-copilot', 'MCP inventory', 'Unrecognized native JSON schema.'))
if 'azure' in servers:
raise ValidationError(_failure('github-copilot', 'MCP inventory',
'An existing azure MCP server could be shadowed by the Azure plugin. '
'Resolve this collision manually before installing.'))


def _inventory(host_id, argv, operation):
# All inventories can carry credentials: Git URLs as well as MCP args/env.
result = _run(host_id, argv, operation, sensitive=True)
# A warning may mean the host skipped unreadable configuration, not an empty inventory.
if result.stderr.strip():
detail = f'Native inventory warning: {_diagnostic(result.stderr, sensitive=True)}'
raise ValidationError(_failure(host_id, operation, detail))
try:
return json.loads(result.stdout, object_pairs_hook=_unique_object)
except (ValueError, RecursionError):
detail = 'Invalid native JSON (malformed, too deeply nested or duplicate object members).'
raise ValidationError(_failure(host_id, operation, detail)) from None


def _unique_object(pairs):
result = {}
for key, value in pairs:
if key in result:
# Do not put potentially sensitive keys or values in exception chains.
raise ValueError('duplicate object member')
result[key] = value
return result


def _diagnostic(value, *, sensitive=False):
if sensitive:
return '[sensitive inventory output suppressed]'
if isinstance(value, bytes):
value = value.decode('utf-8', errors='replace')
value = (value or '').strip()
return value[:1500] + (' [truncated]' if len(value) > 1500 else '')


def _failure(host_id, operation, detail):
return (f'{HOST_LABELS[host_id]}: {operation} failed. {detail} '
"Inspect and recover using the host's native plugin commands; "
'no automatic retry or rollback was attempted.')


def _stop_process_tree(process):
# Own a POSIX session, or target only the Windows launcher's descendant tree.
# This is not containment for a host that deliberately detaches its children.
uncertain = False
output = None
try:
if sys.platform == 'win32':
taskkill = os.path.join(os.environ['SystemRoot'], 'System32', 'taskkill.exe')
result = subprocess.run([taskkill, '/PID', str(process.pid), '/T', '/F'],
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
timeout=5, check=False)
uncertain = result.returncode != 0
else:
try:
os.killpg(process.pid, signal.SIGKILL)
except ProcessLookupError:
pass
except (OSError, subprocess.TimeoutExpired, KeyError):
uncertain = True
try:
process.kill()
Comment thread
FumingZhang marked this conversation as resolved.
output = process.communicate(timeout=5)
except (OSError, subprocess.TimeoutExpired):
uncertain = True
try:
process.wait(timeout=5)
except (OSError, subprocess.TimeoutExpired):
pass
if uncertain:
print('Native process-tree cleanup could not be confirmed; native state is uncertain. '
"Inspect the host's running processes and recover using its native plugin commands.", file=sys.stderr)
return output


def _run(host_id, argv, operation, timeout=300, *, sensitive=False):
try:
windows = sys.platform == 'win32'
process = subprocess.Popen(argv, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
encoding='utf-8', errors='replace', start_new_session=not windows,
creationflags=subprocess.CREATE_NEW_PROCESS_GROUP if windows else 0)
try:
stdout, stderr = process.communicate(timeout=timeout)
except (subprocess.TimeoutExpired, KeyboardInterrupt) as ex:
output = _stop_process_tree(process)
if isinstance(ex, subprocess.TimeoutExpired) and output is not None:
# Windows reader threads only return partial output after EOF.
ex.stdout, ex.stderr = output
raise
finally:
# POSIX has no pipe-reader threads. On Windows communicate owns and
# closes its pipes; closing them here after failed cleanup can block.
if not windows:
process.stdout.close()
process.stderr.close()
result = subprocess.CompletedProcess(argv, process.returncode, stdout, stderr)
except subprocess.TimeoutExpired as ex:
detail = (f'timed out after {timeout}s. {_diagnostic(ex.stdout, sensitive=sensitive)} '
f'{_diagnostic(ex.stderr, sensitive=sensitive)}')
raise ClientRequestError(_failure(host_id, operation, detail)) from (None if sensitive else ex)
except OSError as ex:
detail = _diagnostic(str(ex), sensitive=sensitive)
raise ClientRequestError(_failure(host_id, operation, detail)) from (None if sensitive else ex)
if result.returncode:
detail = (f'exit {result.returncode}. {_diagnostic(result.stdout, sensitive=sensitive)} '
f'{_diagnostic(result.stderr, sensitive=sensitive)}')
raise ClientRequestError(_failure(host_id, operation, detail))
return result
31 changes: 30 additions & 1 deletion src/azure-cli/azure/cli/command_modules/acs/_help.py
Original file line number Diff line number Diff line change
Expand Up @@ -1479,7 +1479,36 @@

helps["aks install-cli"] = """
type: command
short-summary: Download and install kubectl, the Kubernetes command-line tool. Download and install kubelogin, a client-go credential (exec) plugin implementing azure authentication.
short-summary: Download and install kubectl and kubelogin. Optionally set up the full Azure plugin for supported AI CLI hosts.
long-summary: |
The optional full Azure plugin adds skills, MCP configuration and hooks for Claude Code, GitHub Copilot CLI
or Codex CLI in native user/global scope, not repository scope. New installations require an installed host
and Node.js 22+ with node and npx on PATH. Azure authentication, MCP activation, hook trust and sovereign-cloud
setup may still be needed. Azure CLI installs no prerequisites and performs no Azure login or resource operations.

Installs kubectl first, then kubelogin. Plugin setup runs only after both succeed and requires
--install-azure-plugin true with --plugin-hosts; it never prompts. Omission performs no setup or host/runtime
probes and may show one opt-in hint on TTY stdin. False, non-TTY stdin, sudo, core.disable_confirm_prompt=true
and --only-show-errors suppress that hint. Explicit setup under sudo is rejected; run as the intended host
user with writable --install-location and --kubelogin-install-location paths.

Reported existing Azure plugins, including disabled ones, stay unchanged. Inventory absence authorizes native
installation and enablement, including changes to hidden/stale disable preferences or registrations. A marketplace
may be added before an existing plugin becomes visible and installation is skipped. Host policy, marketplace
sources/pins and updates remain authoritative; existing sources are not repointed. The MCP runtime uses
@azure/mcp@latest, which is not pinned by the plugin version and may change its requirements.

Native update/remove commands are shown after new installations; hosts own the plugin lifecycle, with no Azure CLI
updater. Selected hosts are attempted independently. Failures return nonzero but leave kubectl, kubelogin and any
successful plugin installations in place. No automatic retry or rollback occurs; recover with native plugin commands.
--gh-token is used only for kubelogin downloads, not passed to plugin hosts.
examples:
- name: Install binaries only, without any Azure plugin hint or setup
text: az aks install-cli --install-azure-plugin false
- name: Install binaries and explicitly authorize full Azure plugin setup for Codex CLI
text: az aks install-cli --install-azure-plugin true --plugin-hosts codex
- name: Install binaries and authorize full Azure plugin setup for Claude Code and GitHub Copilot CLI
text: az aks install-cli --install-azure-plugin --plugin-hosts claude-code github-copilot
"""

helps["aks install-desktop"] = """
Expand Down
Loading
Loading