-
Notifications
You must be signed in to change notification settings - Fork 332
Add pack+sideload Teams app script to activity-protocol postdeploy (#9172) #9188
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
v1212
wants to merge
36
commits into
Azure:main
Choose a base branch
from
v1212:users/wujia/activity-teams-sideload-9172
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from 32 commits
Commits
Show all changes
36 commits
Select commit
Hold shift + click to select a range
4f6a5e9
Add pack+sideload Teams app script to activity-protocol postdeploy (#…
Copilot e236622
Suppress local invoke hint for websocket agents
Copilot e7a4c35
Revert "Suppress local invoke hint for websocket agents"
Copilot b70acb6
Fix golangci-lint lll violation in teams_sideload_script.go
Copilot 41006fd
Silence cspell for base64 icon constants in teams_sideload_script.go
Copilot 451f2bf
Address Copilot review: build-only mode, Teams field limits, safe hints
Copilot 1408d80
Avoid gosec G101 false positive in sideload run-command test
Copilot 24d5454
Rename test var to clear gosec G101 (pw substring match)
Copilot 2757e1d
Address 2nd Copilot review: manifest version, scopes, exit codes, quo…
Copilot 1c0f3ae
Bound Teams manifest version components to the 65535 limit
Copilot 1b677f5
Fix PS7.4+ native-error abort and qualify the no-admin claims
Copilot dc89ae6
Enforce script mode on rewrite and show build-only opt-out per shell
Copilot f5b4726
Re-chunk embedded base64 icons under the 125-char Go line limit
Copilot 6b2de0a
Do not clobber user-owned files sharing the sideload script name
Copilot ba95231
Reject non-regular files, unique pwsh temp dir, guide fast-path condi…
Copilot f85eb4e
Enable the fast path only when all sideload scripts were written
Copilot cd21bb5
Always give a next action; scope the build-only flag in PowerShell
Copilot 9291d75
fix(agents): guarantee monotonic Teams version and drop unrunnable sc…
Copilot d445022
fix(agents): harden version state file and use full atk login command
Copilot c1c9eb5
fix(agents): detect ATK 'Cannot get token' unauthenticated error
Copilot b54f5cc
fix(agents): scope generated Teams artifacts per agent
Copilot e64b0c9
fix(agents): reword comment to satisfy cspell
Copilot 4d647d7
Key Teams sideload ownership on the stable bot name; add execution tests
Copilot 5400a6e
Make the run hint shell-agnostic and stop the ps1 auto-opening a browser
Copilot f0ee571
Harden generated-file writes and make the run hint portable to Window…
Copilot b0997f5
fix(activity): key Teams script ownership on agent name; lock version…
Copilot 6b72194
test(activity): use a unique sentinel in the refresh assertion
Copilot 188a1a9
fix(activity): expansion-safe run hint, safer version lock, cd hint i…
Copilot 445345f
fix(activity): Windows execution-policy bypass, shell-safe cd, faithf…
Copilot 695c969
fix(agents): harden Teams sideload guide/scripts (round 21 review)
Copilot f122be1
fix(agents): shell-independent run hint + verifiable version-lock (ro…
Copilot 1a51292
fix(agents): transactional pair write, agent-scoped legacy guide, loc…
Copilot a58ff27
fix(agents): consistent guide cd + tolerate npm install failure (roun…
Copilot 2292c80
Slim down Teams pack+sideload script generation
Copilot a27b1ac
Correct stale comments/docs after slim-down; drop dead field
Copilot 718c633
Guard atk auth login in the PowerShell sideload script
Copilot File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
253 changes: 253 additions & 0 deletions
253
cli/azd/extensions/azure.ai.agents/internal/cmd/assets/teams_pack_sideload.ps1
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,253 @@ | ||
| #!/usr/bin/env pwsh | ||
|
v1212 marked this conversation as resolved.
|
||
| # 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 only treats a re-uploaded package (same app id) as an update when the | ||
| # manifest version is higher, so we need a strictly increasing version on every | ||
| # run -- even two runs in the same second. Persist a monotonic build number next | ||
| # to this script (seeded from wall-clock seconds, then always at least | ||
| # last + 1) and encode it into two bounded components (Teams caps each at 65535): | ||
| # minor = N / 65536, patch = N % 65536. N ~ epoch keeps minor < 65535 until ~2106. | ||
| # Serialize the read-increment-write of the counter so two concurrent runs cannot | ||
| # compute the same version. New-Item -ItemType Directory fails if the directory | ||
| # already exists, giving an atomic create-or-fail lock. We reclaim a lock ONLY | ||
| # when it is verifiably stale -- a live holder finishes the counter update in | ||
| # milliseconds and always leaves a fresh timestamp marker, so a marker older than | ||
| # $lockStaleSeconds means the holder crashed and the lock is safe to break; a fresh | ||
| # lock (a live holder) is never touched. If we still cannot acquire it within the | ||
| # timeout we FAIL with cleanup guidance rather than proceeding without | ||
| # serialization (which would let two runs pick the same version and have Teams | ||
| # reject the later update as not newer). The lock's own timestamp marker is the | ||
| # only file we ever write into it, and release removes just that marker and then | ||
| # the (now empty) directory. try/finally guarantees release (PowerShell runs | ||
| # finally blocks even on 'exit'). | ||
| $stateFile = Join-Path $PSScriptRoot ".teams-app-version-$TeamsAppId" | ||
| $lockDir = "$stateFile.lock" | ||
| $lockMarker = Join-Path $lockDir "created" | ||
| $lockStaleSeconds = 120 | ||
| $versionLocked = $false | ||
| try { | ||
| $lockWait = 0 | ||
| while ($lockWait -lt 100) { | ||
| try { | ||
| New-Item -ItemType Directory -Path $lockDir -ErrorAction Stop | Out-Null | ||
| # Stamp the acquisition time so a later run can tell a crashed holder | ||
| # from a live one. Best-effort: a missing marker is treated as unknown | ||
| # age (not stale) below. | ||
| $nowStamp = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds() | ||
| Set-Content -LiteralPath $lockMarker -Value ([string]$nowStamp) -NoNewline -ErrorAction SilentlyContinue | ||
| $versionLocked = $true | ||
| break | ||
| } catch { | ||
| # The lock is held. Break it only if its marker proves it is stale | ||
| # (holder crashed); otherwise keep waiting for the live holder. | ||
| $lockCreated = $null | ||
| $rawMarker = (Get-Content -LiteralPath $lockMarker -Raw -ErrorAction SilentlyContinue) -replace '[^0-9]', '' | ||
| if ($rawMarker) { $lockCreated = [int64]$rawMarker } | ||
| $nowCheck = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds() | ||
| if ($null -ne $lockCreated -and ($nowCheck - $lockCreated) -ge $lockStaleSeconds) { | ||
| Remove-Item -LiteralPath $lockMarker -Force -ErrorAction SilentlyContinue | ||
| try { [System.IO.Directory]::Delete($lockDir, $false) } catch { } | ||
| continue | ||
| } | ||
| $lockWait++ | ||
| Start-Sleep -Milliseconds 100 | ||
| } | ||
| } | ||
| if (-not $versionLocked) { | ||
| Write-Error ("Could not acquire the manifest version lock:`n $lockDir`n" + | ||
| "Another pack run may be in progress. If none is, delete that directory and re-run.") | ||
| exit 1 | ||
| } | ||
|
|
||
| # We hold the lock, so read-increment-persist the monotonic counter under it. | ||
| $nowEpoch = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds() | ||
| $lastN = [int64]0 | ||
| # Only read the counter from a plain regular file -- never follow a symlink | ||
| # or reparse point, and ignore directories. | ||
| $stateItem = Get-Item -LiteralPath $stateFile -Force -ErrorAction SilentlyContinue | ||
| $stateIsLink = $stateItem -and ($stateItem.LinkType -or ($stateItem.Attributes -band [IO.FileAttributes]::ReparsePoint)) | ||
| $stateIsDir = $stateItem -and $stateItem.PSIsContainer | ||
| if ($stateItem -and -not $stateIsLink -and -not $stateIsDir) { | ||
| $raw = (Get-Content -LiteralPath $stateFile -Raw -ErrorAction SilentlyContinue) -replace '[^0-9]', '' | ||
| if ($raw) { $lastN = [int64]$raw } | ||
| } | ||
| $verN = [int64][math]::Max($nowEpoch, $lastN + 1) | ||
| # Persist the counter, but never overwrite a pre-existing symlink/reparse | ||
| # point or directory (a planted link could redirect the write to an | ||
| # arbitrary file). Write to a temp file and move it into place so the link | ||
| # itself is replaced. | ||
| if (-not $stateIsLink -and -not $stateIsDir) { | ||
| $stateTmp = "$stateFile.$PID.tmp" | ||
| Set-Content -LiteralPath $stateTmp -Value ([string]$verN) -NoNewline -ErrorAction SilentlyContinue | ||
| Move-Item -LiteralPath $stateTmp -Destination $stateFile -Force -ErrorAction SilentlyContinue | ||
| } | ||
| } finally { | ||
| if ($versionLocked) { | ||
| # Remove our own marker, then the (now empty) lock directory with a | ||
| # non-recursive delete that fails safely if anything else is inside. | ||
| Remove-Item -LiteralPath $lockMarker -Force -ErrorAction SilentlyContinue | ||
| try { [System.IO.Directory]::Delete($lockDir, $false) } catch { } | ||
| } | ||
| } | ||
| $verMinor = [int]([math]::Floor($verN / 65536)) | ||
| $verPatch = [int]($verN % 65536) | ||
| $pkgVersion = "1.$verMinor.$verPatch" | ||
| if ($verMinor -gt 65535 -or $verPatch -gt 65535) { | ||
| Write-Error "Computed manifest version $pkgVersion exceeds the Teams component limit (65535)." | ||
| exit 1 | ||
| } | ||
|
|
||
| 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)..." | ||
| npm install -g '@microsoft/m365agentstoolkit-cli' | Out-Null | ||
|
v1212 marked this conversation as resolved.
Outdated
|
||
| 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 auth login m365 | ||
|
v1212 marked this conversation as resolved.
Outdated
|
||
| $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:<botId>". 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" | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.