teams-cli 0.1.9-alpha.1

A Microsoft Teams CLI and TUI designed equally for humans and agents
Documentation
# Fixed read-only helper. Input is JSON data; no user text is evaluated as code.
[Console]::InputEncoding = New-Object Text.UTF8Encoding($false)
[Console]::OutputEncoding = New-Object Text.UTF8Encoding($false)
try {
    $requestData = [Console]::In.ReadToEnd()
    if ($requestData.Length -gt 4096) { throw 'Invalid request' }
    $inputData = $requestData | ConvertFrom-Json
    if ($inputData.operation -ne 'probe' -or ($inputData.port -isnot [int] -and $inputData.port -isnot [long]) -or
        $inputData.port -lt 1024 -or $inputData.port -gt 65535) { throw 'Invalid request' }
    $Port = [int]$inputData.port
} catch {
    [Console]::Out.WriteLine('{"error":{"kind":"invalid_input","message":"Expected a probe request with a port between 1024 and 65535"}}')
    exit 1
}

$ErrorActionPreference = 'Stop'
$ProgressPreference = 'SilentlyContinue'

function Read-LocalJson([string]$Route) {
    $request = [Net.HttpWebRequest]::Create("http://127.0.0.1:$Port$Route")
    $request.Proxy = $null
    $request.AllowAutoRedirect = $false
    $request.Timeout = 5000
    $request.ReadWriteTimeout = 5000
    $request.UserAgent = 'TeamsCLI-DesktopProbe/0.1'
    $response = $request.GetResponse()
    try {
        if ([int]$response.StatusCode -ne 200) { throw 'Unexpected HTTP status' }
        $reader = New-Object IO.StreamReader($response.GetResponseStream())
        try {
            $buffer = New-Object char[] 1048577
            $length = $reader.ReadBlock($buffer, 0, $buffer.Length)
            if ($length -eq 0 -or $length -gt 1048576) { throw 'Invalid metadata size' }
            $document = -join $buffer[0..($length-1)]
            $start = $document.TrimStart()
            if (($Route -eq '/json/version' -and -not $start.StartsWith('{')) -or
                ($Route -eq '/json/list' -and -not $start.StartsWith('['))) { throw 'Invalid metadata shape' }
            $parsed = $document | ConvertFrom-Json
            return $parsed
        } finally { $reader.Dispose() }
    } finally { $response.Dispose() }
}

function Test-TeamsOwner([int]$OwnerId) {
    # Process ancestry only; never read command lines, titles, caches or credentials.
    $seen = @{}
    for ($i = 0; $i -lt 32 -and $OwnerId -gt 0; $i++) {
        if ($seen.ContainsKey($OwnerId)) { break }
        $seen[$OwnerId] = $true
        $process = Get-CimInstance Win32_Process -Filter "ProcessId=$OwnerId"
        if (-not $process) { break }
        if ($process.Name -ieq 'ms-teams.exe') {
            if ($script:teamsExecutables -inotcontains $process.ExecutablePath) { return $false }
            $owner = Invoke-CimMethod -InputObject $process -MethodName GetOwnerSid
            return $owner.Sid -eq $script:currentSid
        }
        $OwnerId = [int]$process.ParentProcessId
    }
    return $false
}

$result = [ordered]@{
    schema = 'teams-desktop-metadata-v1'
    checked_utc = [DateTime]::UtcNow.ToString('o')
    port = $Port
    status = 'no_listener'
    loopback_only = $null
    teams_owned = $null
    version = $null
    browser_websocket_advertised = $false
    targets = @()
}
try {
    $script:currentSid = [Security.Principal.WindowsIdentity]::GetCurrent().User.Value
    $script:teamsExecutables = @(Get-AppxPackage -Name MSTeams | ForEach-Object {
        Join-Path $_.InstallLocation 'ms-teams.exe'
    })
    $listeners = @(Get-NetTCPConnection -State Listen -ErrorAction Stop |
        Where-Object LocalPort -eq $Port)
    if ($listeners.Count) {
        $result.loopback_only = @($listeners | Where-Object {
            $_.LocalAddress -notin @('127.0.0.1','::1')
        }).Count -eq 0
        $owned = @($listeners | ForEach-Object { Test-TeamsOwner $_.OwningProcess })
        $result.teams_owned = ($owned -notcontains $false)
        if (-not $result.loopback_only) { $result.status = 'unsafe_listener_refused' }
        elseif (-not $result.teams_owned) { $result.status = 'non_teams_listener_refused' }
        else {
            $version = Read-LocalJson '/json/version'
            $targets = @(Read-LocalJson '/json/list')
            $result.version = [ordered]@{
                browser = [string]$version.Browser
                protocol = [string]$version.'Protocol-Version'
            }
            $result.browser_websocket_advertised = [bool]$version.webSocketDebuggerUrl
            $result.targets = @($targets | ForEach-Object {
                $isTeams = $false
                $origin = '[other origin omitted]'
                $scheme = ''
                try {
                    $uri = [Uri]$_.url
                    $scheme = $uri.Scheme
                    $isTeams = ($uri.Scheme -eq 'https' -and $uri.DnsSafeHost -in @('teams.microsoft.com','teams.cloud.microsoft','teams.live.com') -and $uri.Port -eq 443)
                    if ($isTeams -or ($uri.Scheme -eq 'https' -and $uri.DnsSafeHost -in @('login.microsoftonline.com','login.live.com') -and $uri.Port -eq 443)) {
                        $origin = 'https://' + $uri.DnsSafeHost
                    } elseif ($uri.Scheme -in @('file','blob','about')) { $origin = $uri.Scheme + ':' }
                } catch { }
                # No target titles, URLs, IDs or WebSocket URLs leave this function.
                [ordered]@{
                    type = [string]$_.type
                    url_scheme = $scheme
                    url_origin = $origin
                    teams_origin = $isTeams
                    websocket_advertised = [bool]$_.webSocketDebuggerUrl
                }
            })
            $result.status = 'metadata_ok'
        }
    }
} catch { $result.status = 'metadata_probe_failed' }
$result | ConvertTo-Json -Depth 8 -Compress