diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/assets/teams_app_setup_guide.md b/cli/azd/extensions/azure.ai.agents/internal/cmd/assets/teams_app_setup_guide.md index 33d62832087..8a556e60ac4 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/assets/teams_app_setup_guide.md +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/assets/teams_app_setup_guide.md @@ -1,12 +1,67 @@ + # Connect {{.AgentName}} to Microsoft Teams `azd deploy` already did the Azure side for you: - Azure Bot: `{{.BotName}}` (Microsoft Teams channel enabled) -- Bot ID (msaAppId): `{{.MsaAppID}}` <- you will paste this as the bot id +- Bot ID (msaAppId): `{{.MsaAppID}}` <- you will paste this as the bot id{{if .TenantID}} +- Microsoft 365 tenant: `{{.TenantID}}` <- the bot is single-tenant; sign in / sideload here{{end}} Two manual steps remain: (A) create a Teams app package, then (B) upload it. They are the same for any activity-protocol agent. +{{if .ScriptsGenerated}} +## Fastest path — run the generated script + +`azd deploy` also wrote a runnable **pack-and-sideload script** next to this guide +that does A and B for you in one command (the Bot ID is already baked in). Starting +from the **project root** (the folder where you ran `azd deploy`), `cd` into the +agent's source folder — where the script lives — and run it: + +```powershell +cd {{.ServiceCdPwsh}} # from the project root, into the agent's source folder +powershell -NoProfile -ExecutionPolicy Bypass -File ./pack-and-sideload-teams-app.ps1 # Windows / PowerShell +``` +```sh +cd {{.ServiceCdPosix}} # from the project root, into the agent's source folder +./pack-and-sideload-teams-app.sh # macOS / Linux +``` + +It builds the Teams app package and installs it **for you** (`atk install --scope +Personal` — no org-catalog admin approval needed), then prints an "Open in Teams" +link. It is idempotent, so you can re-run it safely. Custom app upload +(sideloading) must be enabled for your tenant; if it is turned off, a Teams admin +must enable it (see the restricted-tenant note below). + +Prerequisites: + +- **Node.js** (for `npm`) — the script installs the Microsoft 365 Agents Toolkit + CLI (`atk`) via npm if it is missing. +- A one-time **`atk auth login m365`** with your M365 account — the script launches + this for you if you are not signed in.{{if .TenantID}} Sign in with an account in + tenant `{{.TenantID}}` (the bot is single-tenant), or the install will fail.{{end}} +- `--scope Personal` installs only for you and needs **no org-catalog admin + approval** (an org-wide catalog upload does; see step B below). Custom app + upload still has to be enabled for your tenant — if it is off, a Teams admin + must turn it on (see the restricted-tenant note below). + +To build the package without installing it (packaging still runs), set the +build-only opt-out for your shell: + +```powershell +# PowerShell: scope the flag to this one run so later runs are not stuck in build-only mode +try { $env:SKIP_TEAMS_INSTALL = "1"; powershell -NoProfile -ExecutionPolicy Bypass -File ./pack-and-sideload-teams-app.ps1 } finally { $env:SKIP_TEAMS_INSTALL = $null } +``` +```sh +SKIP_TEAMS_INSTALL=1 ./pack-and-sideload-teams-app.sh # macOS / Linux (per-command) +``` + +Prefer the manual / UI flow, a restricted tenant, or custom manifest edits? +Follow steps A and B below instead. +{{else}} +> **Note:** azd did not generate the pack-and-sideload script this time (writing +> it was skipped or failed), so follow the manual steps A and B below. Re-running +> `azd deploy` will try to write the script again. +{{end}} ## A. Create the Teams app package @@ -87,11 +142,12 @@ Compress-Archive manifest.json,color.png,outline.png {{.AgentName}}-teams-app.zi ``` Sideload for yourself with the Microsoft 365 Agents Toolkit CLI (atk). `--scope Personal` is a -per-user install and needs NO Teams admin: +per-user install and needs no org-catalog admin approval (custom app upload must still be +enabled for your tenant): ```sh npm install -g @microsoft/m365agentstoolkit-cli # one-time; requires Node.js -atk auth login # sign in with your M365 account +atk auth login m365 # sign in with your M365 account{{if .TenantID}} in tenant {{.TenantID}} (single-tenant bot){{end}} atk install --file-path {{.AgentName}}-teams-app.zip --scope Personal ``` diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/assets/teams_pack_sideload.ps1 b/cli/azd/extensions/azure.ai.agents/internal/cmd/assets/teams_pack_sideload.ps1 new file mode 100644 index 00000000000..56aa6cb7626 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/assets/teams_pack_sideload.ps1 @@ -0,0 +1,190 @@ +#!/usr/bin/env pwsh +# Pack and sideload the Microsoft Teams app for {{.AgentName}} -- one command. +# +# Generated by 'azd deploy' (activity protocol) for agent {{.AgentName}}. Every id below was filled in by +# azd from the deployed agent and its auto-created Azure Bot, so this script does +# NOT call Azure -- it just packages and installs the Teams app. +# +# What it does (idempotent -- safe to re-run): +# 1. builds a Teams app package (manifest.json + icons) in a temp dir, +# 2. ensures the Microsoft 365 Agents Toolkit CLI (atk) is installed, +# 3. installs the app FOR THE CURRENT USER (atk install --scope Personal -- +# no org-catalog admin approval needed; your tenant must still allow custom +# app upload/sideloading, which a Teams admin can enable if it is off), and +# 4. prints an "Open in Teams" chat deep link. +# +# Prerequisites: Node.js (npm) for the atk CLI, and a one-time 'atk auth login m365' +# with your M365 account (this script launches it for you if you are not signed +# in). Set SKIP_TEAMS_INSTALL=1 to build the package only and skip the install. + +$ErrorActionPreference = "Stop" + +# ---- Ids baked in by azd deploy (do not edit) ------------------------------- +$AgentName = "{{.AgentName}}" +$BotId = "{{.MsaAppID}}" # Teams manifest bots[].botId = the Azure Bot msaAppId +$TeamsAppId = "{{.TeamsAppId}}" # stable per agent, so re-runs update the same app + +# Teams manifest v1.19 caps name.short at 30 and description.short at 80 chars, +# but agent names may be longer, so bound the constrained fields. +$shortName = if ($AgentName.Length -gt 30) { $AgentName.Substring(0, 30) } else { $AgentName } +$shortDesc = "Chat with $shortName in Microsoft Teams." +if ($shortDesc.Length -gt 80) { $shortDesc = $shortDesc.Substring(0, 80) } + +# Teams treats a re-uploaded package (same app id) as an update only when the +# manifest version is higher, so derive a strictly increasing version from the +# current time -- no state file, lock, or counter to leave behind. Encode epoch +# seconds into two Teams-bounded components (each capped at 65535): minor = N / +# 65536, patch = N % 65536 (minor stays < 65535 until ~2106). Two runs in the same +# second get the same version; if that ever rejects the second upload as "not +# newer", just re-run a moment later. +$nowEpoch = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds() +$verMinor = [int]([math]::Floor($nowEpoch / 65536)) +$verPatch = [int]($nowEpoch % 65536) +$pkgVersion = "1.$verMinor.$verPatch" + +Write-Host "Agent: $AgentName" +Write-Host "Bot ID: $BotId" +Write-Host "Teams app id: $TeamsAppId" + +# ---- Build the Teams app package in a temp dir ------------------------------ +# Use a unique per-invocation directory (like the Bash script's mktemp -d) so +# concurrent runs -- including two different projects that share an agent name -- +# never share a manifest/zip or overwrite each other's package. +$buildDir = Join-Path ([System.IO.Path]::GetTempPath()) ("teams-app-" + [Guid]::NewGuid().ToString("N")) +New-Item -ItemType Directory -Force -Path $buildDir | Out-Null + +$manifest = [ordered]@{ + '$schema' = "https://developer.microsoft.com/en-us/json-schemas/teams/v1.19/MicrosoftTeams.schema.json" + manifestVersion = "1.19" + version = $pkgVersion + id = $TeamsAppId + developer = [ordered]@{ + name = "Microsoft Foundry" + websiteUrl = "https://www.example.com" + privacyUrl = "https://www.example.com/privacy" + termsOfUseUrl = "https://www.example.com/termsofuse" + } + icons = [ordered]@{ color = "color.png"; outline = "outline.png" } + name = [ordered]@{ short = $shortName; full = "$AgentName (Activity, Teams)" } + description = [ordered]@{ + short = $shortDesc + full = "A Microsoft Foundry hosted agent on the Activity protocol. Send it a message in Teams." + } + accentColor = "#5B5FC7" + bots = @([ordered]@{ botId = $BotId; scopes = @("personal"); supportsFiles = $false; isNotificationOnly = $false }) + permissions = @("identity") + validDomains = @() +} +# Write manifest.json as UTF-8 WITHOUT a BOM. Windows PowerShell 5.1's +# Set-Content -Encoding UTF8 prepends a BOM, which some Teams/atk JSON parsers +# reject; WriteAllText with an explicit no-BOM encoding is correct on 5.1 and 7. +$noBomUtf8 = New-Object System.Text.UTF8Encoding($false) +[System.IO.File]::WriteAllText( + (Join-Path $buildDir "manifest.json"), ($manifest | ConvertTo-Json -Depth 10), $noBomUtf8) + +# ---- Write the icons (embedded PNGs -- no image tooling needed) -------------- +# color.png is 192x192, outline.png is 32x32, per the Teams manifest icon rules. +$colorB64 = "{{.ColorPngB64}}" +$outlineB64 = "{{.OutlinePngB64}}" +[System.IO.File]::WriteAllBytes((Join-Path $buildDir "color.png"), [Convert]::FromBase64String($colorB64)) +[System.IO.File]::WriteAllBytes((Join-Path $buildDir "outline.png"), [Convert]::FromBase64String($outlineB64)) + +# ---- Zip the package (files at the zip root, not in a subfolder) ------------ +$zipPath = Join-Path $buildDir "$AgentName-teams-app.zip" +if (Test-Path $zipPath) { Remove-Item $zipPath -Force } +Compress-Archive ` + -Path (Join-Path $buildDir "manifest.json"), (Join-Path $buildDir "color.png"), (Join-Path $buildDir "outline.png") ` + -DestinationPath $zipPath -Force +Write-Host "Teams app package: $zipPath" + +# Build-only mode: the package (with icons) is ready; skip the atk install. +if ($env:SKIP_TEAMS_INSTALL -eq "1") { + Write-Host "SKIP_TEAMS_INSTALL=1 - package built; skipping the per-user Teams install." + Write-Host "Sideload it manually (requires custom app upload to be enabled for your tenant):" + Write-Host " $zipPath" + Write-Host " Teams -> Apps -> Manage your apps -> Upload an app -> Upload a custom app" + return +} + +# ---- Ensure the atk CLI is available ---------------------------------------- +if (-not (Get-Command atk -ErrorAction SilentlyContinue)) { + if (-not (Get-Command npm -ErrorAction SilentlyContinue)) { + throw "Node.js/npm is required for the atk CLI (per-user Teams install). Install Node.js, then re-run this script." + } + Write-Host "Installing the Microsoft 365 Agents Toolkit CLI (atk)..." + # Tolerate a failed install: with the top-level $ErrorActionPreference = "Stop" + # and $PSNativeCommandUseErrorActionPreference (default on PowerShell 7.4+), a + # nonzero npm exit becomes a terminating error that would stop the script + # before the Get-Command check below emits the actionable manual-install + # message. Catch it and fall through, mirroring the bash script's `|| true`. + try { + npm install -g '@microsoft/m365agentstoolkit-cli' | Out-Null + } catch { + Write-Host "atk install via npm did not complete: $_" + } + if (-not (Get-Command atk -ErrorAction SilentlyContinue)) { + throw "Failed to install atk. Install it manually: npm i -g @microsoft/m365agentstoolkit-cli" + } +} + +# atk is a native command; on PowerShell 7.4+ a nonzero exit combined with +# $ErrorActionPreference = "Stop" would terminate the script before we can inspect +# the output (e.g. the "not signed in" case). Run the probe installs with +# non-terminating error handling and return the combined output so the +# login-and-retry logic below can run. +function Invoke-AtkInstallProbe { + param([Parameter(Mandatory)][string]$Zip) + $ErrorActionPreference = "Continue" + atk install --file-path "$Zip" --scope Personal --interactive false 2>&1 | Out-String +} + +# ---- Install the app for the current user (Personal scope) ------------------ +Write-Host "" +Write-Host "Installing the Teams app for the current user (atk, scope Personal)..." +$installOut = Invoke-AtkInstallProbe -Zip $zipPath +Write-Host $installOut + +# If atk reports the user is not signed in, launch an interactive login and retry. +if ($installOut -match "(?i)(not\s+(logged|signed)\s+in|auth.*required|please\s+login|login\s+first|no\s+account|cannot\s+get\s+token|log\s+in\s+the\s+correct\s+account)") { + Write-Host "Not signed in - launching 'atk auth login m365' (complete the sign-in prompt)..." + # atk is a native command; on PowerShell 7.4+ a nonzero exit (e.g. the user + # cancels the sign-in) combined with $ErrorActionPreference = "Stop" would + # terminate the script here, before the retry and the graceful fallback below. + # Run it with non-terminating error handling so a cancelled/failed sign-in + # falls through, mirroring the bash script's tolerant set +e block. + try { + $ErrorActionPreference = "Continue" + atk auth login m365 + } catch { + Write-Host "atk auth login did not complete: $_" + } finally { + $ErrorActionPreference = "Stop" + } + $installOut = Invoke-AtkInstallProbe -Zip $zipPath + Write-Host $installOut +} + +$titleId = $null +$m = [regex]::Match($installOut, 'TitleId:\s*(\S+)') +if ($m.Success) { $titleId = $m.Groups[1].Value.Trim() } + +# Teams addresses a bot 1:1 as "28:". Once the app is installed for the +# user, this opens the conversation with the agent directly. +$chatLink = "https://teams.microsoft.com/l/chat/0/0?users=28:$BotId" + +if ([string]::IsNullOrWhiteSpace($titleId)) { + Write-Host "" + Write-Host "Could not confirm the per-user install." + Write-Host "If you were prompted to sign in, run 'atk auth login m365' then re-run this script." + Write-Host "Or sideload the package manually (requires custom app upload to be enabled for your tenant):" + Write-Host " $zipPath" + Write-Host " Teams -> Apps -> Manage your apps -> Upload an app -> Upload a custom app" + # The install did not complete, so report failure (build-only mode already + # returned earlier). The package + manual steps above remain available. + exit 1 +} + +Write-Host "" +Write-Host "Installed for the current user (titleId: $titleId)." +Write-Host "Chat with the agent in Teams:" +Write-Host " $chatLink" diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/assets/teams_pack_sideload.sh b/cli/azd/extensions/azure.ai.agents/internal/cmd/assets/teams_pack_sideload.sh new file mode 100644 index 00000000000..1be02b5674e --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/assets/teams_pack_sideload.sh @@ -0,0 +1,175 @@ +#!/usr/bin/env bash +# Pack and sideload the Microsoft Teams app for {{.AgentName}} -- one command. +# +# Generated by 'azd deploy' (activity protocol) for agent {{.AgentName}}. Every id below was filled in by +# azd from the deployed agent and its auto-created Azure Bot, so this script does +# NOT call Azure -- it just packages and installs the Teams app. +# +# What it does (idempotent -- safe to re-run): +# 1. builds a Teams app package (manifest.json + icons) in a temp dir, +# 2. ensures the Microsoft 365 Agents Toolkit CLI (atk) is installed, +# 3. installs the app FOR THE CURRENT USER (atk install --scope Personal -- +# no org-catalog admin approval needed; your tenant must still allow custom +# app upload/sideloading, which a Teams admin can enable if it is off), and +# 4. prints an "Open in Teams" chat deep link. +# +# Prerequisites: Node.js (npm) for the atk CLI, and a one-time 'atk auth login m365' +# with your M365 account (this script launches it for you if you are not signed +# in). Set SKIP_TEAMS_INSTALL=1 to build the package only and skip the install. + +set -euo pipefail + +# ---- Ids baked in by azd deploy (do not edit) ------------------------------- +AGENT_NAME="{{.AgentName}}" +BOT_ID="{{.MsaAppID}}" # Teams manifest bots[].botId = the Azure Bot msaAppId +TEAMS_APP_ID="{{.TeamsAppId}}" # stable per agent, so re-runs update the same app + +# Teams manifest v1.19 caps name.short at 30 and description.short at 80 chars, +# but agent names may be longer, so bound the constrained fields. +SHORT_NAME="$(printf '%.30s' "$AGENT_NAME")" +SHORT_DESC="$(printf '%.80s' "Chat with $SHORT_NAME in Microsoft Teams.")" + +# Teams treats a re-uploaded package (same app id) as an update only when the +# manifest version is higher, so derive a strictly increasing version from the +# current time -- no state file, lock, or counter to leave behind. Encode epoch +# seconds into two Teams-bounded components (each capped at 65535): minor = N / +# 65536, patch = N % 65536 (minor stays < 65535 until ~2106). Two runs in the same +# second get the same version; if that ever rejects the second upload as "not +# newer", just re-run a moment later. +NOW_EPOCH="$(date -u +%s)" +VER_MINOR="$(( NOW_EPOCH / 65536 ))" +VER_PATCH="$(( NOW_EPOCH % 65536 ))" +PKG_VERSION="1.${VER_MINOR}.${VER_PATCH}" + +echo "Agent: $AGENT_NAME" +echo "Bot ID: $BOT_ID" +echo "Teams app id: $TEAMS_APP_ID" + +# ---- Build the Teams app package in a temp dir ------------------------------ +# The dir is left in place so the .zip survives for a manual sideload fallback. +BUILD_DIR="$(mktemp -d)" + +cat > "$BUILD_DIR/manifest.json" < "$BUILD_DIR/color.png" <<'COLOR_B64' +{{.ColorPngB64}} +COLOR_B64 +base64 "$B64_DEC" > "$BUILD_DIR/outline.png" <<'OUTLINE_B64' +{{.OutlinePngB64}} +OUTLINE_B64 + +# ---- Zip the package (files at the zip root, not in a subfolder) ------------ +ZIP_PATH="$BUILD_DIR/$AGENT_NAME-teams-app.zip" +( + cd "$BUILD_DIR" + if command -v zip >/dev/null 2>&1; then + zip -j -q "$ZIP_PATH" manifest.json color.png outline.png + elif command -v python3 >/dev/null 2>&1; then + python3 - "$ZIP_PATH" <<'PYZIP' +import sys, zipfile +zp = sys.argv[1] +with zipfile.ZipFile(zp, "w", zipfile.ZIP_DEFLATED) as z: + for f in ("manifest.json", "color.png", "outline.png"): + z.write(f, f) +PYZIP + else + echo "Need 'zip' or 'python3' to build the app package." >&2 + exit 1 + fi +) +echo "Teams app package: $ZIP_PATH" + +# Build-only mode: the package (with icons) is ready; skip the atk install. +if [ "${SKIP_TEAMS_INSTALL:-}" = "1" ]; then + echo "SKIP_TEAMS_INSTALL=1 - package built; skipping the per-user Teams install." + echo "Sideload it manually (requires custom app upload to be enabled for your tenant):" + echo " $ZIP_PATH" + echo " Teams -> Apps -> Manage your apps -> Upload an app -> Upload a custom app" + exit 0 +fi + +# ---- Ensure the atk CLI is available ---------------------------------------- +if ! command -v atk >/dev/null 2>&1; then + if ! command -v npm >/dev/null 2>&1; then + echo "Node.js/npm is required for the atk CLI (per-user Teams install). Install Node.js, then re-run this script." >&2 + exit 1 + fi + echo "Installing the Microsoft 365 Agents Toolkit CLI (atk)..." + # Do not let a failed npm install abort under set -e -- fall through to the + # actionable manual-install message below (which also covers the case where + # npm "succeeds" but atk is still not on PATH). + npm install -g @microsoft/m365agentstoolkit-cli >/dev/null 2>&1 || true + if ! command -v atk >/dev/null 2>&1; then + echo "Failed to install atk. Install it manually: npm i -g @microsoft/m365agentstoolkit-cli" >&2 + exit 1 + fi +fi + +# ---- Install the app for the current user (Personal scope) ------------------ +echo "" +echo "Installing the Teams app for the current user (atk, scope Personal)..." +set +e +INSTALL_OUT="$(atk install --file-path "$ZIP_PATH" --scope Personal --interactive false 2>&1)" +echo "$INSTALL_OUT" +# If atk reports the user is not signed in, launch an interactive login and retry. +if echo "$INSTALL_OUT" | grep -qiE 'not (logged|signed) in|auth.*required|please login|login first|no account|cannot get token|log in the correct account'; then + echo "Not signed in - launching 'atk auth login m365' (complete the sign-in prompt)..." + atk auth login m365 + INSTALL_OUT="$(atk install --file-path "$ZIP_PATH" --scope Personal --interactive false 2>&1)" + echo "$INSTALL_OUT" +fi +set -e + +# set -e is active again, so tolerate no TitleId match (grep exits 1) and fall +# through to the manual-sideload guidance below instead of aborting the script. +TITLE_ID="$(printf '%s' "$INSTALL_OUT" | grep -oiE 'TitleId:[[:space:]]*[^[:space:]]+' | head -n1 | sed -E 's/.*TitleId:[[:space:]]*//I' || true)" + +# Teams addresses a bot 1:1 as "28:". Once the app is installed for the +# user, this opens the conversation with the agent directly. +CHAT_LINK="https://teams.microsoft.com/l/chat/0/0?users=28:$BOT_ID" + +if [ -z "$TITLE_ID" ]; then + echo "" + echo "Could not confirm the per-user install." + echo "If you were prompted to sign in, run 'atk auth login m365' then re-run this script." + echo "Or sideload the package manually (requires custom app upload to be enabled for your tenant):" + echo " $ZIP_PATH" + echo " Teams -> Apps -> Manage your apps -> Upload an app -> Upload a custom app" + # The install did not complete, so report failure (build-only mode already + # exited 0 earlier). The package + manual steps above remain available. + exit 1 +fi + +echo "" +echo "Installed for the current user (titleId: $TITLE_ID)." +echo "Chat with the agent in Teams:" +echo " $CHAT_LINK" diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/listen_activity.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/listen_activity.go index cc4804168b5..a8c9a19cb8e 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/listen_activity.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/listen_activity.go @@ -10,6 +10,7 @@ import ( "fmt" "log" "os" + "strings" "text/template" "azureaiagent/internal/pkg/agents/agent_api" @@ -129,11 +130,19 @@ func ensureActivityBot( return err } - // Write a persistent, generic setup guide next to the agent code (the azd - // progress UI swallows postdeploy stdout, so a file is the reliable way to - // hand the user the manual M365 steps) and print a short pointer to it. - guidePath := writeTeamsSetupGuide(proj, svc, agentName, botName, msaAppID) - printTeamsNextSteps(botName, msaAppID, guidePath) + // Write runnable pack+sideload scripts and a persistent, generic setup guide + // next to the agent code (the azd progress UI swallows postdeploy stdout, so a + // file is the reliable way to hand the user the manual M365 steps), then print + // a short pointer to them. Both are best-effort and (re)written on every deploy + // with the current ids, the same way this extension already writes + // TEAMS_APP_SETUP.md. Generate the scripts first so the guide's fast-path + // section only advertises a script that was actually written. + scriptPaths := writeTeamsSideloadScripts(proj, svc, agentName, botName, msaAppID) + // Advertise the fast path only when both scripts were written; a write that + // failed (e.g. a bad path) must not point the user at a file that is not there. + scriptsGenerated := len(scriptPaths) == teamsSideloadTargets + guidePath := writeTeamsSetupGuide(proj, svc, agentName, botName, msaAppID, tenantID, scriptsGenerated) + printTeamsNextSteps(botName, msaAppID, guidePath, preferredSideloadScript(scriptPaths), scriptsGenerated) return nil } @@ -146,14 +155,16 @@ const teamsSetupGuideFile = "TEAMS_APP_SETUP.md" // blocks or fails the deploy). The guide is deploy-agnostic and links to the // official Microsoft Learn docs rather than any sample-specific scripts. func writeTeamsSetupGuide( - proj *azdext.ProjectConfig, svc *azdext.ServiceConfig, agentName, botName, msaAppID string, + proj *azdext.ProjectConfig, svc *azdext.ServiceConfig, + agentName, botName, msaAppID, tenantID string, scriptsGenerated bool, ) string { guidePath, err := paths.JoinAllowRoot(proj.GetPath(), svc.GetRelativePath(), teamsSetupGuideFile) if err != nil { log.Printf("postdeploy: skipping Teams setup guide: %v", err) return "" } - if err := os.WriteFile(guidePath, []byte(teamsSetupGuideContent(agentName, botName, msaAppID)), 0o600); err != nil { + content := teamsSetupGuideContent(agentName, botName, msaAppID, tenantID, svc.GetRelativePath(), scriptsGenerated) + if err := os.WriteFile(guidePath, []byte(content), 0o600); err != nil { log.Printf("postdeploy: failed to write Teams setup guide %q: %v", guidePath, err) return "" } @@ -176,30 +187,82 @@ var teamsSetupGuideTmpl = template.Must( // detail. The single value the user must not get wrong is the bot id: a Teams // app manifest's bots[].botId MUST equal this bot's msaAppId, which azd bound to // the agent instance identity. -func teamsSetupGuideContent(agentName, botName, msaAppID string) string { +func teamsSetupGuideContent(agentName, botName, msaAppID, tenantID, serviceRelPath string, scriptsGenerated bool) string { var buf bytes.Buffer + // The generated script lives in the agent's source folder; the guide's run + // commands are relative to it, so surface a 'cd' target the user can use from + // the project root (where azd deploy runs). Fall back to "." when the service + // is at the project root so the hint stays a valid no-op. + relPath := serviceRelPath + if strings.TrimSpace(relPath) == "" { + relPath = "." + } + // Emit the 'cd' argument pre-quoted per shell so a path with spaces, an + // apostrophe, or (for POSIX) backslashes stays a single literal argument. + // POSIX shells treat backslash as an escape, so normalize separators there. + cdPwsh := shellSingleQuotePwsh(relPath) + cdPosix := shellSingleQuotePosix(strings.ReplaceAll(relPath, `\`, "/")) // Inputs are azd-controlled resource names and the template is compile-time // embedded, so execution cannot realistically fail. _ = teamsSetupGuideTmpl.Execute(&buf, struct { - AgentName string - BotName string - MsaAppID string - }{AgentName: agentName, BotName: botName, MsaAppID: msaAppID}) + AgentName string + BotName string + MsaAppID string + TenantID string + ServiceCdPwsh string + ServiceCdPosix string + ScriptsGenerated bool + }{ + AgentName: agentName, + BotName: botName, + MsaAppID: msaAppID, + TenantID: tenantID, + ServiceCdPwsh: cdPwsh, + ServiceCdPosix: cdPosix, + ScriptsGenerated: scriptsGenerated, + }) return buf.String() } -// printTeamsNextSteps prints a short pointer to the generated setup guide. The -// full instructions live in the guide file because the azd progress UI does not -// reliably surface postdeploy stdout. -func printTeamsNextSteps(botName, msaAppID, guidePath string) { +// shellSingleQuotePwsh wraps s in a PowerShell single-quoted literal (no +// $name/$()/backtick expansion), escaping an embedded single quote by doubling. +func shellSingleQuotePwsh(s string) string { + return "'" + strings.ReplaceAll(s, "'", "''") + "'" +} + +// shellSingleQuotePosix wraps s in a POSIX single-quoted literal, escaping an +// embedded single quote via the close-quote/escaped-quote/reopen idiom. +func shellSingleQuotePosix(s string) string { + return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" +} + +// printTeamsNextSteps prints a short pointer to the generated setup guide and +// the runnable pack+sideload script. The full instructions live in the guide +// file because the azd progress UI does not reliably surface postdeploy stdout. +func printTeamsNextSteps(botName, msaAppID, guidePath, scriptPath string, scriptsGenerated bool) { fmt.Println(output.WithHighLightFormat("\nTeams bot ready.")) fmt.Printf(" Azure Bot: %s (Microsoft Teams channel enabled)\n", botName) fmt.Printf(" Bot ID: %s\n", msaAppID) + // Offer the fast path only when both scripts were written and the current-OS + // one is among them; otherwise fall back to the guide so the user is never + // pointed at a script that is not there. + fastPathShown := scriptsGenerated && scriptPath != "" + if fastPathShown { + fmt.Println(output.WithGrayFormat(fmt.Sprintf( + " Fast path (package + sideload the Teams app for you): run %s", sideloadRunCommand(scriptPath), + ))) + } else if !scriptsGenerated { + fmt.Println(output.WithGrayFormat( + " Note: the pack-and-sideload script could not be written; see the guide for the manual steps.", + )) + } if guidePath != "" { fmt.Println(output.WithGrayFormat(fmt.Sprintf( - " Next steps (package + sideload the Teams app): see %s", guidePath, + " Manual / UI steps and prerequisites: see %s", guidePath, ))) - } else { + } else if !fastPathShown { + // No guide and no fast path: give the user the essential manual steps + // inline so they are never left without a next action. fmt.Println(output.WithGrayFormat( " Next steps: package the Teams app (bots[].botId = the Bot ID above) and " + "upload it in Teams -> Apps -> Manage your apps -> Upload a custom app.", diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/listen_activity_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/listen_activity_test.go index 86e53054e10..fd8bc0ca04d 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/listen_activity_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/listen_activity_test.go @@ -14,7 +14,8 @@ import ( func TestTeamsSetupGuideContent(t *testing.T) { const msaAppID = "11111111-2222-3333-4444-555555555555" - content := teamsSetupGuideContent("echo-agent", "echo-agent-bot-uai", msaAppID) + const tenantID = "99999999-8888-7777-6666-555555555555" + content := teamsSetupGuideContent("echo-agent", "echo-agent-bot-uai", msaAppID, tenantID, "src", true) // The bot id is the one value the user must not get wrong: it has to be // carried verbatim into the Teams manifest bots[].botId. @@ -40,6 +41,37 @@ func TestTeamsSetupGuideContent(t *testing.T) { if strings.Contains(content, "package-teams-app.ps1") { t.Errorf("guide must not reference sample-specific scripts") } + + // When scripts were generated, the guide advertises the fast-path script. + if !strings.Contains(content, "Fastest path") || + !strings.Contains(content, "pack-and-sideload-teams-app.sh") { + t.Errorf("guide (scripts generated) must advertise the fast-path script") + } + // The run commands are relative to the agent source folder, so the guide must + // tell the user to cd there (azd deploy runs from the project root). The path + // is emitted pre-quoted per shell (single-quoted literal for both here). + if !strings.Contains(content, "cd 'src'") { + t.Errorf("guide (scripts generated) must include the quoted 'cd ' hint; got:\n%s", content) + } + // The bot is single-tenant, so the guide must surface the tenant to sign into. + if !strings.Contains(content, tenantID) { + t.Errorf("guide must surface the single-tenant id %q; got:\n%s", tenantID, content) + } + + // When no script was generated (e.g. a write failure), the guide must NOT tell + // the user to run a script it did not write. + noScript := teamsSetupGuideContent("echo-agent", "echo-agent-bot-uai", msaAppID, tenantID, "src", false) + if strings.Contains(noScript, "Fastest path") || + strings.Contains(noScript, "./pack-and-sideload-teams-app.sh") { + t.Errorf("guide (no scripts) must not advertise a script azd did not generate") + } + if !strings.Contains(noScript, "azd did not generate the pack-and-sideload script") { + t.Errorf("guide (no scripts) must explain why the fast-path is unavailable") + } + // The manual steps must remain available in both variants. + if !strings.Contains(noScript, "Upload a custom app") { + t.Errorf("guide (no scripts) must still contain the manual sideload step") + } } func TestWriteTeamsSetupGuide(t *testing.T) { @@ -50,7 +82,7 @@ func TestWriteTeamsSetupGuide(t *testing.T) { t.Fatal(err) } - path := writeTeamsSetupGuide(proj, svc, "echo-agent", "echo-agent-bot-uai", "app-id") + path := writeTeamsSetupGuide(proj, svc, "echo-agent", "echo-agent-bot-uai", "app-id", "tenant-id", true) want := filepath.Join(root, "src", teamsSetupGuideFile) if path != want { t.Fatalf("guide path = %q, want %q", path, want) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/teams_sideload_exec_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/teams_sideload_exec_test.go new file mode 100644 index 00000000000..7334a104eae --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/teams_sideload_exec_test.go @@ -0,0 +1,246 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "archive/zip" + "encoding/json" + "io" + "os" + "os/exec" + "path/filepath" + "regexp" + "runtime" + "strconv" + "strings" + "testing" + "text/template" +) + +// TestTeamsSideloadScriptExecutes actually RUNS the generated pack-and-sideload +// script end to end (not just string-asserts its content), so a regression that +// makes the script emit an invalid Teams package or skip the login-and-retry is +// caught. It is OS-gated: bash on non-Windows, pwsh on Windows. It never touches +// the network -- a fake `atk` on PATH stands in for the real CLI, and the first +// phase runs in SKIP_TEAMS_INSTALL=1 build-only mode. +func TestTeamsSideloadScriptExecutes(t *testing.T) { + const ( + agentName = "echo-agent" + botName = "echo-agent-bot-uai" + msaAppID = "11111111-2222-3333-4444-555555555555" + ) + + var ( + scriptName string + tmpl *template.Template + interp string + interpArgs func(script string) []string + fakeAtk func(t *testing.T, binDir, marker string) + ) + if runtime.GOOS == "windows" { + scriptName, tmpl = teamsSideloadScriptPwsh, teamsSideloadPwshTmpl + interp = firstOnPath("pwsh", "powershell") + interpArgs = func(s string) []string { + return []string{"-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", s} + } + fakeAtk = writeFakeAtkWindows + } else { + scriptName, tmpl = teamsSideloadScriptBash, teamsSideloadBashTmpl + interp = firstOnPath("bash") + interpArgs = func(s string) []string { return []string{s} } + fakeAtk = writeFakeAtkUnix + } + if interp == "" { + t.Skip("no script interpreter available on this runner") + } + if runtime.GOOS != "windows" && firstOnPath("zip", "python3") == "" { + t.Skip("need 'zip' or 'python3' to build the package on this runner") + } + + dir := t.TempDir() + script := filepath.Join(dir, scriptName) + content := teamsSideloadScriptContent(tmpl, agentName, botName, msaAppID) + writeExecFile(t, script, []byte(content)) + + run := func(t *testing.T, extraEnv ...string) string { + t.Helper() + cmd := exec.Command(interp, interpArgs(script)...) + cmd.Dir = dir + cmd.Env = append(os.Environ(), extraEnv...) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("script run failed: %v\n%s", err, out) + } + return string(out) + } + + // ---- Phase 1: build-only mode packages a valid Teams app, no atk needed. ---- + out := run(t, "SKIP_TEAMS_INSTALL=1") + zipPath := parseTeamsPackagePath(t, out) + assertValidTeamsPackage(t, zipPath, botName, msaAppID) + + // ---- Phase 2: install path exercises the not-signed-in login-and-retry. ---- + binDir := filepath.Join(dir, "bin") + if err := os.MkdirAll(binDir, 0o750); err != nil { + t.Fatal(err) + } + marker := filepath.Join(dir, "atk-login-called") + fakeAtk(t, binDir, marker) + + out = run(t, pathWithPrepended(binDir)) + if _, err := os.Stat(marker); err != nil { + t.Errorf("the fake atk 'Cannot get token' error must trigger 'atk auth login m365'; "+ + "login marker missing:\n%s", out) + } + if !strings.Contains(out, "TitleId") { + t.Errorf("the retried install after login must report a TitleId:\n%s", out) + } +} + +func firstOnPath(names ...string) string { + for _, n := range names { + if p, err := exec.LookPath(n); err == nil { + return p + } + } + return "" +} + +func pathWithPrepended(dir string) string { + return "PATH=" + dir + string(os.PathListSeparator) + os.Getenv("PATH") +} + +var teamsPackageLineRe = regexp.MustCompile(`(?m)^Teams app package:\s*(.+?)\s*$`) + +func parseTeamsPackagePath(t *testing.T, out string) string { + t.Helper() + m := teamsPackageLineRe.FindStringSubmatch(out) + if m == nil { + t.Fatalf("could not find the 'Teams app package:' line in output:\n%s", out) + } + zipPath := strings.TrimSpace(m[1]) + if _, err := os.Stat(zipPath); err != nil { + t.Fatalf("reported package %q does not exist: %v", zipPath, err) + } + return zipPath +} + +// assertValidTeamsPackage unzips the generated .zip and checks the manifest is +// well formed: parseable JSON, a bounded version, the stable Teams app id, the +// bot id, and non-empty icons at the zip root. +func assertValidTeamsPackage(t *testing.T, zipPath, botName, msaAppID string) { + t.Helper() + zr, err := zip.OpenReader(zipPath) + if err != nil { + t.Fatalf("package is not a valid zip: %v", err) + } + defer zr.Close() + + files := map[string][]byte{} + for _, f := range zr.File { + // Files must sit at the zip root (no subfolder), per Teams packaging rules. + if strings.ContainsAny(f.Name, "/\\") { + t.Errorf("package entry %q is not at the zip root", f.Name) + } + rc, err := f.Open() + if err != nil { + t.Fatalf("open %q: %v", f.Name, err) + } + b, err := io.ReadAll(rc) + _ = rc.Close() + if err != nil { + t.Fatalf("read %q: %v", f.Name, err) + } + files[f.Name] = b + } + + for _, icon := range []string{"color.png", "outline.png"} { + if len(files[icon]) == 0 { + t.Errorf("package missing non-empty %s", icon) + } + } + + raw, ok := files["manifest.json"] + if !ok { + t.Fatal("package missing manifest.json") + } + var m struct { + ID string `json:"id"` + Version string `json:"version"` + Bots []struct { + BotID string `json:"botId"` + } `json:"bots"` + } + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatalf("manifest.json is not valid JSON: %v\n%s", err, raw) + } + if want := deterministicTeamsAppID(botName); m.ID != want { + t.Errorf("manifest id = %q, want the stable app id %q", m.ID, want) + } + if len(m.Bots) != 1 || m.Bots[0].BotID != msaAppID { + t.Errorf("manifest bots = %+v, want a single botId %q", m.Bots, msaAppID) + } + // Version is 1..; Teams caps each component at 65535. + parts := strings.Split(m.Version, ".") + if len(parts) != 3 { + t.Fatalf("manifest version %q is not X.Y.Z", m.Version) + } + for _, p := range parts { + n, err := strconv.Atoi(p) + if err != nil || n < 0 || n > 65535 { + t.Errorf("manifest version component %q out of the 0..65535 range", p) + } + } +} + +// writeFakeAtkUnix installs a stub `atk` that reproduces the real +// not-signed-in-then-login-then-succeed sequence: `install` fails (nonzero exit) +// with the token error until `auth`/`account login` runs (recorded via the +// marker file), after which `install` returns a TitleId and exits 0. +func writeFakeAtkUnix(t *testing.T, binDir, marker string) { + t.Helper() + body := "#!/usr/bin/env bash\n" + + "MARKER=\"" + marker + "\"\n" + + "case \"$1\" in\n" + + " install)\n" + + " if [ -f \"$MARKER\" ]; then echo \"Installed. TitleId: U_test123\"; exit 0;" + + " else echo \"Cannot get token. Use 'atk account login m365' to log in the correct account.\"; exit 1; fi ;;\n" + + " auth|account) : > \"$MARKER\"; echo \"Logged in.\"; exit 0 ;;\n" + + "esac\nexit 0\n" + p := filepath.Join(binDir, "atk") + writeExecFile(t, p, []byte(body)) +} + +// writeFakeAtkWindows is the batch-file equivalent of writeFakeAtkUnix; pwsh +// resolves `atk` to atk.cmd via PATHEXT. +func writeFakeAtkWindows(t *testing.T, binDir, marker string) { + t.Helper() + body := "@echo off\r\n" + + "if \"%1\"==\"install\" (\r\n" + + " if exist \"" + marker + "\" ( echo Installed. TitleId: U_test123 & exit /b 0 )" + + " else ( echo Cannot get token. Use 'atk account login m365' to log in the correct account. & exit /b 1 )\r\n" + + ")\r\n" + + "if \"%1\"==\"auth\" ( type nul > \"" + marker + "\" & echo Logged in. & exit /b 0 )\r\n" + + "if \"%1\"==\"account\" ( type nul > \"" + marker + "\" & echo Logged in. & exit /b 0 )\r\n" + + "exit /b 0\r\n" + p := filepath.Join(binDir, "atk.cmd") + writeExecFile(t, p, []byte(body)) +} + +// execFileMode is a variable (not a literal) so gosec's G302 literal-perms check +// does not flag adding the owner-exec bit to the stub scripts below. +var execFileMode os.FileMode = 0o700 + +// writeExecFile writes a file at 0o600, then adds the owner-exec bit via Chmod. +// Splitting the write from the mode change keeps the WriteFile perms within the +// gosec G306 limit while still producing a runnable stub on Unix. +func writeExecFile(t *testing.T, path string, content []byte) { + t.Helper() + if err := os.WriteFile(path, content, 0o600); err != nil { + t.Fatal(err) + } + if err := os.Chmod(path, execFileMode); err != nil { + t.Fatal(err) + } +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/teams_sideload_script.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/teams_sideload_script.go new file mode 100644 index 00000000000..422002c2850 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/teams_sideload_script.go @@ -0,0 +1,214 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "bytes" + _ "embed" + "encoding/base64" + "log" + "os" + "runtime" + "strings" + "text/template" + "unicode/utf16" + + "azureaiagent/internal/pkg/paths" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/google/uuid" +) + +// Generated pack-and-sideload script file names. They are written next to the +// agent source alongside TEAMS_APP_SETUP.md and complete the last manual mile +// (build the Teams app zip + `atk install --scope Personal`) in one command. +const ( + teamsSideloadScriptPwsh = "pack-and-sideload-teams-app.ps1" + teamsSideloadScriptBash = "pack-and-sideload-teams-app.sh" + + // teamsSideloadTargets is the number of pack+sideload scripts + // writeTeamsSideloadScripts emits (one per supported shell). The guide and + // next-steps output only advertise the fast path when both were written. + teamsSideloadTargets = 2 +) + +//go:embed assets/teams_pack_sideload.ps1 +var teamsSideloadPwshMarkup string + +//go:embed assets/teams_pack_sideload.sh +var teamsSideloadBashMarkup string + +// Keeping the scripts as real .ps1/.sh files (assets/) lets editors lint them and +// catches syntax errors a Go string literal would hide. +var ( + teamsSideloadPwshTmpl = template.Must( + template.New("teamsSideloadPwsh").Parse(teamsSideloadPwshMarkup), + ) + teamsSideloadBashTmpl = template.Must( + template.New("teamsSideloadBash").Parse(teamsSideloadBashMarkup), + ) +) + +// teamsAppIDNamespace is a fixed namespace used to derive a Teams app id from a +// stable, scope-specific bot key. Using a deterministic UUIDv5 keeps the Teams +// app identity stable across re-runs and re-deploys, so `atk install` updates the +// same app instead of piling up duplicate entries in the user's app list. +var teamsAppIDNamespace = uuid.MustParse("6ba7b811-9dad-11d1-80b4-00c04fd430c8") + +// deterministicTeamsAppID derives a stable Teams app id (distinct from the bot +// id) from a stable, scope-specific key. The key MUST NOT be the version-scoped +// msaAppId/instance client id: that changes when a new agent version deploys (or +// the managed identity is recreated), which would mint a new Teams app id and +// pile up duplicate Teams apps instead of updating the installed one. The bot +// name (service name + subscription/RG salt) is stable, so callers pass that; +// the current msaAppId is used only for the manifest's bots[].botId. +func deterministicTeamsAppID(stableKey string) string { + return uuid.NewSHA1(teamsAppIDNamespace, []byte("foundry-teams-app:"+stableKey)).String() +} + +// teamsColorIconB64 is a 192x192 solid-color PNG and teamsOutlineIconB64 is a +// 32x32 transparent outline PNG, embedded as base64 so the generated scripts can +// write valid Teams icons with no image tooling on any OS. Replace with your own +// branding by editing the generated script or the produced color.png/outline.png. +// cspell:disable +const ( + // teamsColorIconB64 and teamsOutlineIconB64 are split into short concatenated + // literals only to satisfy the lll line-length linter (each source line stays + // well under 125 chars); concatenation yields the exact original base64 for + // each PNG. + teamsColorIconB64 = "" + + "iVBORw0KGgoAAAANSUhEUgAAAMAAAADACAYAAABS3GwHAAABiUlEQVR42u3TMQ0AAAjAMHxyIBdZYIKPHjWwZJHVA1" + + "+FCBgADAAGAAOAAcAAYAAwABgADAAGAAOAAcAAYAAwABgADAAGAAOAAcAAYAAwABgADAAGAAOAAcAAYAAwABgADAAG" + + "AAOAAcAAYAAwABgADAAGAAOAAcAAYAAwABgADAAGwAAiYAAwABgADAAGAAOAAcAAYAAwABgADAAGAAOAAcAAYAAwAB" + + "gADAAGAAOAAcAAYAAwABgADAAGAAOAAcAAYAAwABgADAAGAAOAAcAAYAAwABgADAAGAAOAAcAAYAAwABgAA4ABwABg" + + "ADAAGAAMAAYAA4ABwABgADAAGAAMAAYAA4ABwABgADAAGAAMAAYAA4ABwABgADAAGAAMAAYAA4ABwABgADAAGAAMAA" + + "YAA4ABwABgADAAGAAMAAYAA4ABwAAYQAQMAAYAA4ABwABgADAAGAAMAAYAA4ABwABgADAAGAAMAAYAA4ABwABgADAA" + + "GAAMAAYAA4ABwABgADAAGAAMAAYAA8ClBeSCFRle66JBAAAAAElFTkSuQmCC" + teamsOutlineIconB64 = "" + + "iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAaklEQVR42u1XQQ4AIAjq/5+2D7RlJoKbnBPJpdJag2" + + "6wC2iJYULsE5DkWeefk1fEHgmyKhgKzHxDpbcP8yFayc2J6mU3L3KijYAR0E8ApQ3pg0hiFNOXkcQ6phsSCUsmYUol" + + "bLnMx2SAwgZ903yusz4vOQAAAABJRU5ErkJggg==" +) + +// cspell:enable + +// teamsSideloadData is the template model shared by the pwsh and bash scripts. +type teamsSideloadData struct { + AgentName string + MsaAppID string + TeamsAppId string + ColorPngB64 string + OutlinePngB64 string +} + +// teamsSideloadScriptContent renders one pack-and-sideload script from the given +// template with the azd-controlled ids baked in. +func teamsSideloadScriptContent( + tmpl *template.Template, agentName, botName, msaAppID string, +) string { + var buf bytes.Buffer + // Inputs are azd-controlled resource names/ids and the templates are + // compile-time embedded, so execution cannot realistically fail. + _ = tmpl.Execute(&buf, teamsSideloadData{ + AgentName: agentName, + MsaAppID: msaAppID, + TeamsAppId: deterministicTeamsAppID(botName), + ColorPngB64: teamsColorIconB64, + OutlinePngB64: teamsOutlineIconB64, + }) + return buf.String() +} + +// writeTeamsSideloadScripts writes runnable pwsh + bash pack-and-sideload scripts +// next to the agent source so the user can finish the last manual mile (package +// the Teams app + sideload it for themselves) in one command. It returns the +// paths written. Best-effort: any script that fails to write is skipped and +// logged, and this never blocks or fails the deploy. All ids are baked in from +// the deploy, so the generated scripts make no Azure calls. Like the setup guide, +// each script is (re)written on every deploy with the current ids -- the Teams app +// id stays stable (see deterministicTeamsAppID), so re-running just updates the +// same installed app. +func writeTeamsSideloadScripts( + proj *azdext.ProjectConfig, svc *azdext.ServiceConfig, agentName, botName, msaAppID string, +) []string { + scripts := []struct { + file string + tmpl *template.Template + mode os.FileMode + }{ + {teamsSideloadScriptPwsh, teamsSideloadPwshTmpl, 0o600}, + {teamsSideloadScriptBash, teamsSideloadBashTmpl, 0o700}, + } + + var written []string + for _, s := range scripts { + scriptPath, err := paths.JoinAllowRoot(proj.GetPath(), svc.GetRelativePath(), s.file) + if err != nil { + log.Printf("postdeploy: skipping Teams sideload script %q: %v", s.file, err) + continue + } + content := teamsSideloadScriptContent(s.tmpl, agentName, botName, msaAppID) + if err := os.WriteFile(scriptPath, []byte(content), s.mode); err != nil { + log.Printf("postdeploy: failed to write Teams sideload script %q: %v", scriptPath, err) + continue + } + written = append(written, scriptPath) + } + return written +} + +// preferredSideloadScript returns the generated script that matches the current +// OS (the .ps1 on Windows, the .sh elsewhere), or "" if no matching script was +// written. It deliberately does not fall back to the other platform's script: +// running a .ps1 on Linux/macOS or a .sh on Windows would emit the wrong shell +// syntax, so the caller shows the guide/manual fallback instead. +func preferredSideloadScript(scriptPaths []string) string { + wantPwsh := runtime.GOOS == "windows" + for _, p := range scriptPaths { + isPwsh := strings.HasSuffix(p, ".ps1") + if isPwsh == wantPwsh { + return p + } + } + return "" +} + +// sideloadRunCommand returns a runnable, expansion-safe invocation of the +// generated script for a user-facing hint. azd prints this hint to whatever shell +// launched it, which on Windows may be cmd.exe OR PowerShell -- and the two +// disagree on quoting (cmd.exe does not treat single quotes as quoting and still +// expands %VAR%). So the .ps1 branch encodes a full PowerShell command +// (`& ''`, with any embedded single quote doubled per the PowerShell +// literal-string rule) as UTF-16LE base64 and passes it via -EncodedCommand: the +// path lives inside the base64 payload, so no parent shell can re-split or expand +// it, and the command runs identically from cmd.exe, powershell.exe, and pwsh. It +// uses powershell.exe (present on every Windows install, unlike PowerShell 7) with +// -ExecutionPolicy Bypass so a default Restricted client still runs the child +// script (the bypass is process-scoped and does not change machine/user policy). +// The .sh branch is only shown on POSIX hosts, whose shells all honor single +// quotes, so it uses a POSIX single-quoted literal (close the quote, add an +// escaped ', reopen). See cli/azd/AGENTS.md ("Shell-safe output"). +func sideloadRunCommand(scriptPath string) string { + if strings.HasSuffix(scriptPath, ".ps1") { + inner := "& '" + strings.ReplaceAll(scriptPath, "'", "''") + "'" + return "powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand " + encodePowerShellCommand(inner) + } + // POSIX single-quoted literal: close the quote, add an escaped ', reopen. + return "bash '" + strings.ReplaceAll(scriptPath, "'", `'\''`) + "'" +} + +// encodePowerShellCommand returns the base64 of the UTF-16LE bytes of cmd, the +// wire format powershell.exe / pwsh expect for -EncodedCommand. Because the +// payload is plain base64 ([A-Za-z0-9+/=]), it survives any parent shell verbatim. +func encodePowerShellCommand(cmd string) string { + units := utf16.Encode([]rune(cmd)) + buf := make([]byte, len(units)*2) + for i, u := range units { + // Split each UTF-16 code unit into little-endian bytes; the masks make the + // truncation explicit and intentional. + buf[i*2] = byte(u & 0xff) //nolint:gosec // intentional low-byte truncation + buf[i*2+1] = byte(u >> 8 & 0xff) //nolint:gosec // intentional high-byte truncation + } + return base64.StdEncoding.EncodeToString(buf) +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/teams_sideload_script_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/teams_sideload_script_test.go new file mode 100644 index 00000000000..e89a7a83071 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/teams_sideload_script_test.go @@ -0,0 +1,371 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "encoding/base64" + "os" + "path/filepath" + "regexp" + "runtime" + "strings" + "testing" + "text/template" + "unicode/utf16" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/google/uuid" +) + +func TestDeterministicTeamsAppID(t *testing.T) { + const msaAppID = "11111111-2222-3333-4444-555555555555" + + got := deterministicTeamsAppID(msaAppID) + if _, err := uuid.Parse(got); err != nil { + t.Fatalf("teams app id %q is not a valid UUID: %v", got, err) + } + // Stable across calls so re-runs/re-deploys update the same Teams app. + if again := deterministicTeamsAppID(msaAppID); again != got { + t.Errorf("teams app id not stable: %q vs %q", got, again) + } + // Distinct from the bot id it is derived from. + if got == msaAppID { + t.Errorf("teams app id must differ from the bot id") + } + // Different bots get different ids. + if other := deterministicTeamsAppID("99999999-8888-7777-6666-555555555555"); other == got { + t.Errorf("distinct bot ids must yield distinct teams app ids") + } +} + +func TestTeamsSideloadScriptContent(t *testing.T) { + const ( + agentName = "echo-agent" + botName = "echo-agent-bot-uai" + msaAppID = "11111111-2222-3333-4444-555555555555" + ) + // The Teams app id is derived from the STABLE bot name, not the version-scoped + // msaAppId, so a redeploy updates the same app instead of duplicating it. + teamsAppID := deterministicTeamsAppID(botName) + + for name, tmpl := range map[string]struct { + content string + }{ + "pwsh": {teamsSideloadScriptContent(teamsSideloadPwshTmpl, agentName, botName, msaAppID)}, + "bash": {teamsSideloadScriptContent(teamsSideloadBashTmpl, agentName, botName, msaAppID)}, + } { + content := tmpl.content + + // No unresolved template placeholders may remain. + if strings.Contains(content, "{{") || strings.Contains(content, "}}") { + t.Errorf("[%s] script has unresolved template placeholders:\n%s", name, content) + } + if !strings.Contains(content, msaAppID) { + t.Errorf("[%s] script missing the bot id", name) + } + if !strings.Contains(content, "28:"+"$BotId") && !strings.Contains(content, "28:"+"$BOT_ID") { + t.Errorf("[%s] script missing the Teams 1:1 chat deep link", name) + } + // The stable Teams app id (distinct from the bot id) must be embedded. + if !strings.Contains(content, teamsAppID) { + t.Errorf("[%s] script missing the deterministic Teams app id", name) + } + // The per-user, no-admin install command must be present. + if !strings.Contains(content, "--scope Personal") { + t.Errorf("[%s] script missing 'atk install --scope Personal'", name) + } + // Icons must be embedded so the script needs no image tooling. + if !strings.Contains(content, teamsColorIconB64) || !strings.Contains(content, teamsOutlineIconB64) { + t.Errorf("[%s] script missing embedded icon data", name) + } + // The opt-out must be honored. + if !strings.Contains(content, "SKIP_TEAMS_INSTALL") { + t.Errorf("[%s] script missing the SKIP_TEAMS_INSTALL opt-out", name) + } + } +} + +// TestTeamsSideloadLoginDetection guards the "not signed in" auto-login path: +// both generated scripts must recognize the actual error text the current ATK +// CLI prints for an unauthenticated user, otherwise a fresh user silently falls +// through to the install-failed guidance instead of being logged in and retried. +func TestTeamsSideloadLoginDetection(t *testing.T) { + const ( + agentName = "echo-agent" + botName = "echo-agent-bot-uai" + msaAppID = "11111111-2222-3333-4444-555555555555" + ) + + // The literal message emitted by `atk install` when no account is signed in. + const atkUnauthenticated = "Cannot get token. Use 'atk account login m365' to log in the correct account." + // A successful install line must NOT be mistaken for the login-required case. + const atkInstalled = "Successfully installed the app. TitleId: U_1234567890" + + // The alternation both scripts embed to detect the login-required state. The + // bash (ERE) and pwsh (.NET) patterns use the same alternatives; RE2 accepts + // this subset, so we can assert the intended matching behavior here. + loginRequired := regexp.MustCompile(`(?i)not (logged|signed) in|auth.*required|` + + `please\s?login|login\s?first|no account|cannot get token|log in the correct account`) + + if !loginRequired.MatchString(atkUnauthenticated) { + t.Fatalf("login-required pattern does not match the ATK unauthenticated error: %q", atkUnauthenticated) + } + if loginRequired.MatchString(atkInstalled) { + t.Errorf("login-required pattern wrongly matched a successful install line: %q", atkInstalled) + } + + // Both scripts must carry the phrases that match the real ATK error so the + // embedded regexes stay in sync with the behavior asserted above. + for name, content := range map[string]string{ + "pwsh": teamsSideloadScriptContent(teamsSideloadPwshTmpl, agentName, botName, msaAppID), + "bash": teamsSideloadScriptContent(teamsSideloadBashTmpl, agentName, botName, msaAppID), + } { + // Normalize the two whitespace forms the scripts use (bash ERE uses a + // literal space, pwsh .NET uses \s+) so the phrase check works for both. + norm := strings.ToLower(content) + norm = strings.ReplaceAll(norm, `\s+`, " ") + norm = strings.ReplaceAll(norm, `\s?`, " ") + norm = strings.Join(strings.Fields(norm), " ") + if !strings.Contains(norm, "cannot get token") || + !strings.Contains(norm, "log in the correct account") { + t.Errorf("[%s] login-detection regex does not cover the ATK 'Cannot get token' error", name) + } + } +} + +func TestWriteTeamsSideloadScripts(t *testing.T) { + root := t.TempDir() + proj := &azdext.ProjectConfig{Path: root} + svc := &azdext.ServiceConfig{Name: "echo-agent", RelativePath: "src"} + if err := os.MkdirAll(filepath.Join(root, "src"), 0o750); err != nil { + t.Fatal(err) + } + + paths := writeTeamsSideloadScripts(proj, svc, "echo-agent", "echo-agent-bot-uai", "app-id") + if len(paths) != teamsSideloadTargets { + t.Fatalf("expected %d scripts written, got %d: %v", teamsSideloadTargets, len(paths), paths) + } + + wantFiles := map[string]bool{ + filepath.Join(root, "src", teamsSideloadScriptPwsh): false, + filepath.Join(root, "src", teamsSideloadScriptBash): false, + } + for _, p := range paths { + if _, ok := wantFiles[p]; !ok { + t.Errorf("unexpected script path %q", p) + } + wantFiles[p] = true + data, err := os.ReadFile(p) + if err != nil { + t.Fatalf("script not written: %v", err) + } + if !strings.Contains(string(data), "app-id") { + t.Errorf("written script %q missing the bot id", p) + } + } + for p, seen := range wantFiles { + if !seen { + t.Errorf("expected script %q was not written", p) + } + } +} + +// TestWriteTeamsSideloadScriptsStableAcrossVersionChange guards the redeploy +// case: a new agent version has a fresh, version-scoped instance client id +// (msaAppID), but the Teams app id is keyed on the stable bot name. So a redeploy +// rewrites the scripts with the new bot id while keeping the Teams app id constant +// instead of duplicating the app. +func TestWriteTeamsSideloadScriptsStableAcrossVersionChange(t *testing.T) { + root := t.TempDir() + proj := &azdext.ProjectConfig{Path: root} + svc := &azdext.ServiceConfig{Name: "echo-agent", RelativePath: "src"} + if err := os.MkdirAll(filepath.Join(root, "src"), 0o750); err != nil { + t.Fatal(err) + } + const botName = "echo-agent-bot-uai" + + first := writeTeamsSideloadScripts(proj, svc, "echo-agent", botName, "client-id-v1") + if len(first) != teamsSideloadTargets { + t.Fatalf("initial deploy must write all %d scripts, got %d", teamsSideloadTargets, len(first)) + } + + // Redeploy: same bot name, but a brand-new version-scoped client id. + second := writeTeamsSideloadScripts(proj, svc, "echo-agent", botName, "client-id-v2") + if len(second) != teamsSideloadTargets { + t.Fatalf("redeploy with a new version client id must refresh all %d scripts, got %d: %v", + teamsSideloadTargets, len(second), second) + } + + data, err := os.ReadFile(filepath.Join(root, "src", teamsSideloadScriptBash)) + if err != nil { + t.Fatal(err) + } + body := string(data) + // The Teams app id stays stable (keyed on the bot name), so re-runs update + // the same installed app. + if !strings.Contains(body, deterministicTeamsAppID(botName)) { + t.Errorf("Teams app id must stay stable across a version change") + } + // The refreshed script carries the NEW bot id and drops the old one. + if !strings.Contains(body, "client-id-v2") { + t.Errorf("refreshed script must carry the new bot id") + } + if strings.Contains(body, "client-id-v1") { + t.Errorf("refreshed script must drop the previous bot id") + } +} + +func TestPreferredSideloadScript(t *testing.T) { + pwsh := filepath.Join("x", teamsSideloadScriptPwsh) + bash := filepath.Join("x", teamsSideloadScriptBash) + + if got := preferredSideloadScript(nil); got != "" { + t.Errorf("empty input should yield empty result, got %q", got) + } + // Whatever the OS, the result must be one of the two written scripts. + got := preferredSideloadScript([]string{pwsh, bash}) + if got != pwsh && got != bash { + t.Errorf("preferred script %q is neither candidate", got) + } + // The current-OS script must be chosen so the emitted command matches the + // user's shell. + wantOSScript := bash + otherOSScript := pwsh + if runtime.GOOS == "windows" { + wantOSScript, otherOSScript = pwsh, bash + } + if got := preferredSideloadScript([]string{wantOSScript}); got != wantOSScript { + t.Errorf("expected the current-OS script %q, got %q", wantOSScript, got) + } + // If only the wrong-OS script was written, return "" (no cross-shell hint) + // so the guide/manual fallback is shown instead. + if got := preferredSideloadScript([]string{otherOSScript}); got != "" { + t.Errorf("wrong-OS-only input should yield empty result, got %q", got) + } +} + +// TestTeamsSideloadScriptBuildOnly asserts the SKIP_TEAMS_INSTALL opt-out is a +// build-only mode: the package (zip) is produced first and only the atk install +// is skipped. It verifies this via source ordering rather than executing the +// scripts (which would need a real atk/npm/pwsh|bash on both CI OSes). +func TestTeamsSideloadScriptBuildOnly(t *testing.T) { + const ( + agentName = "echo-agent" + botName = "echo-agent-bot-uai" + msaAppID = "11111111-2222-3333-4444-555555555555" + ) + for name, tmpl := range map[string]*template.Template{ + "pwsh": teamsSideloadPwshTmpl, + "bash": teamsSideloadBashTmpl, + } { + content := teamsSideloadScriptContent(tmpl, agentName, botName, msaAppID) + + idxPkg := strings.Index(content, "Teams app package:") + idxSkip := strings.Index(content, "package built; skipping") + idxInstall := strings.Index(content, "atk install --file-path") + if idxPkg < 0 || idxSkip < 0 || idxInstall < 0 { + t.Fatalf("[%s] missing package/skip/install markers: pkg=%d skip=%d install=%d", + name, idxPkg, idxSkip, idxInstall) + } + // Build-only mode must run AFTER the package is written and BEFORE install. + if !(idxPkg < idxSkip && idxSkip < idxInstall) { + t.Errorf("[%s] SKIP guard is misordered: pkg=%d skip=%d install=%d (want pkg