diff --git a/pipelines/azure-build-publish-all.yml b/pipelines/azure-build-publish-all.yml index 673f23d13a..c42f447ad0 100644 --- a/pipelines/azure-build-publish-all.yml +++ b/pipelines/azure-build-publish-all.yml @@ -972,6 +972,17 @@ stages: artifact: vscode-shell displayName: "Download vscode-shell" + - pwsh: | + $agentDir = "$(Pipeline.Workspace)/agent-server-win32-x64" + node "$agentDir/tools/copilotRuntime.mjs" verify-feed --non-interactive + if ($LASTEXITCODE -ne 0) { + Write-Error "The exact Copilot runtime packages are unavailable through the TypeAgent npm feed." + exit $LASTEXITCODE + } + displayName: "Verify Copilot runtime packages in TypeAgent feed" + env: + TYPEAGENT_FEED_TOKEN: $(System.AccessToken) + # Set up internal npm registry (required by org policy). - bash: | echo "registry=$INSTALL_REGISTRY" > .npmrc diff --git a/ts/packages/aiclient/src/copilotModels.ts b/ts/packages/aiclient/src/copilotModels.ts index 09b35931e1..e87bf4fdd2 100644 --- a/ts/packages/aiclient/src/copilotModels.ts +++ b/ts/packages/aiclient/src/copilotModels.ts @@ -160,6 +160,12 @@ export interface CopilotClientOptions { } function findCopilotPath(): string { + const configuredPath = + process.env.TYPEAGENT_COPILOT_CLI_PATH ?? process.env.COPILOT_CLI_PATH; + if (configuredPath) { + debug(`Using configured copilot CLI: ${configuredPath}`); + return configuredPath; + } try { const isWindows = process.platform === "win32"; const command = isWindows ? "where copilot" : "which copilot"; @@ -207,7 +213,7 @@ async function getClient( `Failed to start GitHub Copilot CLI client (${target}). ` + (cliUrl ? `Ensure a Copilot CLI server is running and reachable at '${cliUrl}'.\n` - : `Ensure 'copilot' is installed and authenticated (try 'copilot auth login').\n`) + + : `Run 'node typeagent-serve.mjs setup --provider copilot', or install and authenticate a compatible 'copilot' CLI.\n`) + `Underlying error: ${err instanceof Error ? err.message : String(err)}`, ); } diff --git a/ts/tools/installers/common/package-feed.json b/ts/tools/installers/common/package-feed.json new file mode 100644 index 0000000000..8a55f041cb --- /dev/null +++ b/ts/tools/installers/common/package-feed.json @@ -0,0 +1,4 @@ +{ + "registry": "https://pkgs.dev.azure.com/msctoproj/AI_Systems/_packaging/typeagent-feed/npm/registry/", + "azureDevOpsResource": "499b84ac-1321-427f-aa17-267ca6975798" +} diff --git a/ts/tools/installers/common/register-plugin.mjs b/ts/tools/installers/common/register-plugin.mjs index ef07c0db95..029bdf8f45 100644 --- a/ts/tools/installers/common/register-plugin.mjs +++ b/ts/tools/installers/common/register-plugin.mjs @@ -6,6 +6,9 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +const scriptPath = fileURLToPath(import.meta.url); function parseArgs(argv) { const opts = { @@ -113,6 +116,12 @@ function hasListedEntry(output, identifier) { return findListedEntry(output, identifier) !== undefined; } +export function listedEntryState(output, identifier) { + const entry = findListedEntry(output, identifier); + if (!entry) return "absent"; + return /\(disabled\)/i.test(entry) ? "disabled" : "enabled"; +} + function quoteCmdArgument(value) { return `"${value.replace(/%/g, "%%").replace(/"/g, '""')}"`; } @@ -501,7 +510,11 @@ function installPlugin(opts, logger) { logger, ); const pluginIdentifier = `${opts.pluginName}@${opts.marketplaceName}`; - if (hasListedEntry(pluginListResult.output, pluginIdentifier)) { + const pluginState = listedEntryState( + pluginListResult.output, + pluginIdentifier, + ); + if (pluginState === "enabled") { const update = runCopilot( opts.copilotPath, ["plugin", "update", pluginIdentifier], @@ -521,6 +534,11 @@ function installPlugin(opts, logger) { retryUpdateFromCleanSnapshot(opts, logger, pluginIdentifier); } } else { + if (pluginState === "disabled") { + logger.write( + `Plugin '${pluginIdentifier}' is available but disabled; installing it to enable the plugin.`, + ); + } runCopilot( opts.copilotPath, ["plugin", "install", pluginIdentifier], @@ -533,9 +551,13 @@ function installPlugin(opts, logger) { ["plugin", "list"], logger, ); - if (!hasListedEntry(verifyListResult.output, pluginIdentifier)) { + const verifiedState = listedEntryState( + verifyListResult.output, + pluginIdentifier, + ); + if (verifiedState !== "enabled") { throw new Error( - `Plugin verification failed: '${pluginIdentifier}' not found in copilot plugin list.`, + `Plugin verification failed: '${pluginIdentifier}' is ${verifiedState}.`, ); } @@ -580,10 +602,12 @@ function main() { process.exit(0); } -try { - main(); -} catch (error) { - const message = error instanceof Error ? error.message : String(error); - console.error(`[TypeAgent] Registration failed: ${message}`); - process.exit(1); +if (path.resolve(process.argv[1] ?? "") === scriptPath) { + try { + main(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error(`[TypeAgent] Registration failed: ${message}`); + process.exit(1); + } } diff --git a/ts/tools/installers/wix/TypeAgent-AgentServer.wxs b/ts/tools/installers/wix/TypeAgent-AgentServer.wxs index 90bdec4006..c95e4d41a9 100644 --- a/ts/tools/installers/wix/TypeAgent-AgentServer.wxs +++ b/ts/tools/installers/wix/TypeAgent-AgentServer.wxs @@ -186,6 +186,7 @@ + @@ -264,6 +265,10 @@ + + + @@ -449,17 +454,16 @@ Impersonate="yes" /> + Value=""[WindowsFolder]System32\WindowsPowerShell\v1.0\powershell.exe" -ExecutionPolicy Bypass -NoProfile -NonInteractive -File "[TYPEAGENTROOT]install-prereqs.ps1" -Provider "[PROVIDER]" -AgentServerDir "[INSTALLFOLDER]." -PluginInstallDir "[TYPEAGENTROOT]." -LogPath "[LocalAppDataFolder]TypeAgent\logs\msi-install-prereqs.log"" /> + + + + NOT REMOVE~="ALL" NOT REMOVE~="ALL" NOT REMOVE~="ALL" @@ -695,6 +709,7 @@ (NOT REMOVE~="ALL") AND (SHELL="1") (NOT REMOVE~="ALL") AND (SHELL="1") (NOT REMOVE~="ALL") AND (SHELL="1") + (NOT Installed) AND (PROVIDER="COPILOT") AND (UILevel >= 4) REMOVE~="ALL" REMOVE~="ALL" REMOVE~="ALL" diff --git a/ts/tools/installers/wix/install-prereqs.ps1 b/ts/tools/installers/wix/install-prereqs.ps1 index fd4517eac8..550902bb40 100644 --- a/ts/tools/installers/wix/install-prereqs.ps1 +++ b/ts/tools/installers/wix/install-prereqs.ps1 @@ -4,28 +4,28 @@ <# .SYNOPSIS Provisions the runtime prerequisites the external agent-server variant needs: - the Claude Code and GitHub Copilot CLIs (resolved from PATH at runtime), plus - a Node.js >= 22 check. Mirrors the external-CLI step in install-typeagent.ps1 - so an MSI install matches the standalone installer. + Claude Code from PATH, the TypeAgent-managed GitHub Copilot runtime, and a + Node.js >= 22 check. Mirrors install-typeagent.ps1 so MSI and standalone + installs use the same runtime contract. .DESCRIPTION The MSI ships the 'external' agent-server variant, which prunes the bundled - Claude/Copilot runtimes and expects `claude` and `copilot` on PATH. This - script installs them per-user via `npm i -g` when missing. It is intentionally - lightweight (no winget/admin/UAC): npm global installs land in the user's npm - prefix, so it runs impersonated as the installing user. Every failure is - non-fatal (logged + warned) so a prerequisite hiccup never rolls back the MSI; - the agent-server install itself succeeds and the user is told what to fix. + Claude/Copilot runtimes. Claude remains a per-user global prerequisite. + Copilot is installed only for the Copilot provider into TypeAgent's versioned + per-user runtime cache by the deployed setup command. Every failure is + non-fatal (logged + warned) so a prerequisite hiccup never rolls back the + MSI; the agent-server install itself succeeds and setup remains retryable. .PARAMETER LogPath Optional log file; the script also writes to stdout (captured by the MSI log). #> param( [string]$LogPath, - # Authoritative Azure Artifacts npm registry the CLIs are pulled through - # (public npmjs is blocked by policy; this feed proxies it as upstream). - # Overridable so the MSI can bake an org-specific feed if needed. - [string]$FeedRegistry = "https://pkgs.dev.azure.com/msctoproj/AI_Systems/_packaging/typeagent-feed/npm/registry/" + [ValidateSet("AISYSTEMS", "COPILOT")] + [string]$Provider = "AISYSTEMS", + [string]$AgentServerDir = "$env:LOCALAPPDATA\TypeAgent\agent-server", + [string]$PluginInstallDir = "$env:LOCALAPPDATA\TypeAgent", + [string]$FeedRegistry = "" ) $ErrorActionPreference = "Continue" @@ -71,8 +71,8 @@ function Resolve-AzCmd { } # Mint a short-lived Azure DevOps bearer token for the feed (feedAuth.ts -# pattern). Non-interactive when an `az` session exists; attempts a one-time -# `az login` (browser) fallback otherwise. Returns the token or $null. +# pattern). Uses an existing `az` session only; interactive sign-in is deferred +# to the post-install setup command so silent MSI installs never block. function Get-FeedToken([string]$azCmd) { if (-not $azCmd) { return $null } function Invoke-AzToken { @@ -84,14 +84,6 @@ function Get-FeedToken([string]$azCmd) { } catch { } return $null } - $token = Invoke-AzToken - if ($token) { return $token } - Write-Log " No active 'az' session; attempting 'az login' for feed access." - try { - & $azCmd login --only-show-errors 2>&1 | ForEach-Object { Write-Log " $_" } - } catch { - Write-Log " WARNING: 'az login' failed: $($_.Exception.Message)" - } return (Invoke-AzToken) } @@ -99,11 +91,11 @@ function Get-FeedToken([string]$azCmd) { # the feed. Returns the file path; caller removes its directory when done. function New-TransientNpmrc([string]$registry, [string]$token) { $authKey = $registry -replace '^https:', '' - $base = $registry -replace 'registry/?$', '' + $baseAuthKey = $authKey -replace 'registry/?$', '' $dir = Join-Path $env:TEMP ("ta-npmauth-" + [guid]::NewGuid().ToString('N')) New-Item -ItemType Directory -Force -Path $dir | Out-Null $file = Join-Path $dir ".npmrc" - $content = "$($base):_authToken=$token`n$($authKey):_authToken=$token`n$($authKey):always-auth=true`n" + $content = "registry=$registry`n$($baseAuthKey):_authToken=$token`n$($authKey):_authToken=$token`n$($authKey):always-auth=true`n" Set-Content -Path $file -Value $content -NoNewline -Encoding ascii return $file } @@ -116,29 +108,88 @@ function Install-Cli([string]$command, [string]$package, [string]$friendly, [str Write-Log " $friendly already on PATH: $((Get-Command $command).Source)" return } - Write-Log " Installing $friendly (npm i -g $package)" - $npmArgs = @("install", "-g", $package) - if ($registry -and $userconfig) { - $npmArgs += @("--registry", $registry, "--userconfig", $userconfig) + Write-Log " Installing $friendly from $registry" + if (-not $registry -or -not $userconfig) { + Write-Log " WARNING: authenticated TypeAgent feed access is required to install $friendly. npm was not invoked." + return } + $npmArgs = @("install", "-g", $package, "--registry", $registry, "--userconfig", $userconfig) try { & npm @npmArgs 2>&1 | ForEach-Object { Write-Log " $_" } if ($LASTEXITCODE -ne 0) { - Write-Log " WARNING: '$package' install exited with code $LASTEXITCODE. Install it manually: npm i -g $package" + Write-Log " WARNING: '$package' install exited with code $LASTEXITCODE. Re-run TypeAgent setup after authenticating to the package feed." return } } catch { - Write-Log " WARNING: '$package' install failed: $($_.Exception.Message). Install it manually: npm i -g $package" + Write-Log " WARNING: '$package' install failed: $($_.Exception.Message). Re-run TypeAgent setup after authenticating to the package feed." return } if (Test-Command $command) { Write-Log " ${friendly}: $((Get-Command $command).Source)" } else { - Write-Log " WARNING: '$command' not found on PATH after install (open a new session, or install manually: npm i -g $package)." + Write-Log " WARNING: '$command' not found on PATH after install. Open a new session and retry TypeAgent setup." + } +} + +function Get-RuntimeManifest { + $manifestPath = Join-Path $AgentServerDir "copilot-runtime.json" + if (-not (Test-Path $manifestPath)) { + return $null + } + try { + return Get-Content -Raw -Path $manifestPath | ConvertFrom-Json + } catch { + Write-Log " WARNING: could not read Copilot runtime manifest: $($_.Exception.Message)" + return $null + } +} + +function Invoke-CopilotRuntimeSetup([string]$nodeExe) { + $serve = Join-Path $AgentServerDir "typeagent-serve.mjs" + if (-not (Test-Path $serve)) { + Write-Log " WARNING: Copilot setup launcher not found at $serve." + return $null + } + Write-Log " Installing the SDK-compatible Copilot runtime from the TypeAgent package feed." + & $nodeExe $serve setup --provider copilot --runtime-only --non-interactive 2>&1 | + ForEach-Object { Write-Log " $_" } + if ($LASTEXITCODE -ne 0) { + Write-Log " WARNING: Copilot runtime setup is incomplete. Run: node `"$serve`" setup --provider copilot" + return $null + } + $runtimeTool = Join-Path $AgentServerDir "tools\copilotRuntime.mjs" + $pathOutput = & $nodeExe $runtimeTool path 2>$null | Select-Object -First 1 + if ($LASTEXITCODE -eq 0 -and $pathOutput -and (Test-Path $pathOutput)) { + Write-Log " Copilot runtime: $pathOutput" + return [string]$pathOutput } + Write-Log " WARNING: Copilot runtime was installed but its executable could not be resolved." + return $null } -Write-Log "Provisioning external-CLI prerequisites (claude, copilot)." +function Invoke-PluginRegistration([string]$copilotPath) { + $registerScript = Join-Path $PluginInstallDir "register-plugin.ps1" + if (-not (Test-Path $registerScript)) { + Write-Log " WARNING: plugin registration script not found at $registerScript." + return + } + $previousPath = $env:COPILOT_CLI_PATH + try { + if ($copilotPath) { + $env:COPILOT_CLI_PATH = $copilotPath + } + Write-Log " Registering the TypeAgent Copilot plugin after CLI/runtime resolution." + & $registerScript -InstallDir $PluginInstallDir -LogPath (Join-Path $env:LOCALAPPDATA "TypeAgent\logs\msi-register-plugin.log") 2>&1 | + ForEach-Object { Write-Log " $_" } + if ($LASTEXITCODE -ne 0) { + Write-Log " WARNING: Copilot plugin registration is deferred until a usable CLI is available." + } + } finally { + $env:COPILOT_CLI_PATH = $previousPath + } +} + +Write-Log "Provisioning external runtime prerequisites for provider $Provider." # Resolve node from an MSI service context (refreshes PATH + probes managers), # so a bare `node`/`npm` on the interactive PATH is found here too. @@ -156,39 +207,47 @@ if (-not $nodeExe) { } } -# --- External CLIs (claude, copilot) ----------------------------------------- +# --- External runtimes ------------------------------------------------------- if (-not (Test-Command npm)) { - Write-Log " WARNING: npm (ships with Node.js) was not found; cannot install claude/copilot. Install Node.js >= 22, then run: npm i -g @anthropic-ai/claude-code @github/copilot" + Write-Log " WARNING: npm (ships with Node.js) was not found; cannot install Claude or the managed Copilot runtime. Install Node.js >= 22 and retry TypeAgent setup." } else { # Authenticate to the Azure feed non-interactively (public npmjs is blocked # by policy; the feed proxies it). The interactive broker tokenHelper in the # user's ~/.npmrc cannot run in the installer service (session 0), so we mint # a bearer token via the Azure CLI and pass a transient auth config instead. - $registry = $null + $manifest = Get-RuntimeManifest + $registry = if ($FeedRegistry) { $FeedRegistry } elseif ($manifest -and $manifest.registry) { [string]$manifest.registry } else { $null } $userconfig = $null $azCmd = Resolve-AzCmd if (-not $azCmd) { - Write-Log " WARNING: Azure CLI ('az') not found; cannot authenticate to the package feed. CLIs may fail to install. Install az + run 'az login', then: npm i -g @anthropic-ai/claude-code @github/copilot" + Write-Log " WARNING: Azure CLI ('az') not found; authenticated TypeAgent feed installation is unavailable. npm will not use another registry." } else { $token = Get-FeedToken $azCmd - if ($token) { - $registry = $FeedRegistry + if ($token -and $registry) { $userconfig = New-TransientNpmrc $registry $token Write-Log " Feed auth ready (registry: $registry)" } else { - Write-Log " WARNING: could not obtain a feed access token from 'az'. CLIs may fail to install. Run 'az login', then: npm i -g @anthropic-ai/claude-code @github/copilot" + Write-Log " WARNING: TypeAgent feed authentication or configuration is unavailable. npm will not use another registry." } } try { Install-Cli "claude" "@anthropic-ai/claude-code" "Claude Code CLI" $registry $userconfig - Install-Cli "copilot" "@github/copilot" "GitHub Copilot CLI" $registry $userconfig } finally { if ($userconfig) { try { Remove-Item (Split-Path -Parent $userconfig) -Recurse -Force -ErrorAction SilentlyContinue } catch { } } } - Write-Log " NOTE: both CLIs require a one-time sign-in (run 'claude' and 'copilot' once) before agent actions work." +} + +$copilotPath = $null +if ($Provider -eq "COPILOT" -and $nodeExe) { + $copilotPath = Invoke-CopilotRuntimeSetup $nodeExe +} +Invoke-PluginRegistration $copilotPath + +if ($Provider -eq "COPILOT") { + Write-Log " NOTE: complete GitHub sign-in with: node `"$AgentServerDir\typeagent-serve.mjs`" setup --provider copilot" } Write-Log "Prerequisite provisioning complete." diff --git a/ts/tools/installers/wix/launch-copilot-setup.ps1 b/ts/tools/installers/wix/launch-copilot-setup.ps1 new file mode 100644 index 0000000000..0f3545cf11 --- /dev/null +++ b/ts/tools/installers/wix/launch-copilot-setup.ps1 @@ -0,0 +1,62 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +param( + [Parameter(Mandatory = $true)] + [string]$ServePath, + [string]$LogPath = "$env:LOCALAPPDATA\TypeAgent\logs\copilot-setup-launch.log" +) + +$ErrorActionPreference = "Stop" + +. (Join-Path $PSScriptRoot "resolve-node.ps1") + +function Write-LaunchLog([string]$Message) { + try { + $directory = Split-Path -Parent $LogPath + if ($directory -and -not (Test-Path $directory)) { + New-Item -ItemType Directory -Path $directory -Force | Out-Null + } + Add-Content -Path $LogPath -Value ("{0} {1}" -f (Get-Date -Format "s"), $Message) + } catch { } +} + +try { + $nodeExe = Resolve-NodeExe + if (-not $nodeExe) { + Write-LaunchLog "Node.js >= 22 was not found; Copilot setup was not launched." + exit 0 + } + if (-not (Test-Path $ServePath)) { + Write-LaunchLog "TypeAgent setup launcher was not found at $ServePath." + exit 0 + } + + $escapedNode = $nodeExe.Replace("'", "''") + $escapedServe = $ServePath.Replace("'", "''") + $setupCommand = @" +`$Host.UI.RawUI.WindowTitle = 'TypeAgent GitHub Copilot Setup' +& '$escapedNode' '$escapedServe' setup --provider copilot --device-code +if (`$LASTEXITCODE -eq 0) { + Write-Host '' + Write-Host 'GitHub Copilot setup completed successfully.' -ForegroundColor Green +} else { + Write-Host '' + Write-Host 'GitHub Copilot setup is incomplete. Run this command again to retry:' -ForegroundColor Yellow + Write-Host "& '$escapedNode' '$escapedServe' setup --provider copilot --device-code" +} +Read-Host 'Press Enter to close' +"@ + $encodedCommand = [Convert]::ToBase64String( + [Text.Encoding]::Unicode.GetBytes($setupCommand) + ) + $powershellExe = Join-Path $PSHOME "powershell.exe" + Start-Process -FilePath $powershellExe -ArgumentList @( + "-NoProfile", + "-ExecutionPolicy", "Bypass", + "-EncodedCommand", $encodedCommand + ) | Out-Null + Write-LaunchLog "Started interactive GitHub Copilot setup." +} catch { + Write-LaunchLog "Could not launch GitHub Copilot setup: $($_.Exception.Message)" +} diff --git a/ts/tools/scripts/bundleAgentServer.mjs b/ts/tools/scripts/bundleAgentServer.mjs index 1e509c47bf..3111979ceb 100644 --- a/ts/tools/scripts/bundleAgentServer.mjs +++ b/ts/tools/scripts/bundleAgentServer.mjs @@ -7,6 +7,7 @@ import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { bundleProfileAgents } from "./bundleProductAgents.mjs"; +import { writeCopilotRuntimeManifest } from "./copilotRuntimeManifest.mjs"; import { bundleEntry, copyFile, @@ -232,7 +233,12 @@ export async function bundleAgentServer(args) { path.join(scriptsDir, "typeagent-serve.mjs"), path.join(out, "typeagent-serve.mjs"), ); + writeCopilotRuntimeManifest(path.join(out, "copilot-runtime.json"), { + platform: args.platform, + arch: args.arch, + }); for (const script of [ + "copilotRuntime.mjs", "getKeys.mjs", "generate-selfhost-config.mjs", "setup-devtunnel.mjs", @@ -268,7 +274,7 @@ export async function bundleAgentServer(args) { if (args.externalCli) { fs.writeFileSync( path.join(out, ".typeagent-external-cli"), - "claude,copilot must be on PATH\n", + "claude must be on PATH; copilot uses the TypeAgent managed runtime\n", ); } diff --git a/ts/tools/scripts/copilotRuntime.mjs b/ts/tools/scripts/copilotRuntime.mjs new file mode 100644 index 0000000000..9478d20edd --- /dev/null +++ b/ts/tools/scripts/copilotRuntime.mjs @@ -0,0 +1,722 @@ +#!/usr/bin/env node +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const scriptPath = fileURLToPath(import.meta.url); +const scriptDir = path.dirname(scriptPath); +const defaultArtifactDir = + path.basename(scriptDir).toLowerCase() === "tools" + ? path.dirname(scriptDir) + : path.resolve(scriptDir, "..", ".."); + +function parseArgs(argv) { + const options = { + command: "setup", + artifactDir: defaultArtifactDir, + interactive: true, + login: true, + deviceCode: false, + host: "https://github.com", + }; + let commandSet = false; + for (let i = 2; i < argv.length; i++) { + const value = argv[i]; + if (!value.startsWith("-") && !commandSet) { + options.command = value; + commandSet = true; + } else if (value === "--artifact-dir") { + options.artifactDir = path.resolve(argv[++i]); + } else if (value === "--manifest") { + options.manifestPath = path.resolve(argv[++i]); + } else if (value === "--runtime-root") { + options.runtimeRoot = path.resolve(argv[++i]); + } else if (value === "--non-interactive") { + options.interactive = false; + } else if (value === "--runtime-only" || value === "--skip-login") { + options.login = false; + } else if (value === "--device-code") { + options.deviceCode = true; + } else if (value === "--host") { + options.host = argv[++i]; + } else if (value === "--help") { + options.command = "help"; + } else { + throw new Error(`Unknown argument: ${value}`); + } + } + return options; +} + +export function readCopilotRuntimeManifest(manifestPath) { + const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")); + const required = [ + "sdkVersion", + "cliPackage", + "cliVersion", + "platformPackage", + "platformVersion", + "registry", + "azureDevOpsResource", + ]; + for (const key of required) { + if (typeof manifest[key] !== "string" || manifest[key].length === 0) { + throw new Error( + `Invalid Copilot runtime manifest: missing ${key}.`, + ); + } + } + if (manifest.cliVersion !== manifest.platformVersion) { + throw new Error( + "Invalid Copilot runtime manifest: CLI and platform versions differ.", + ); + } + if (!manifest.registry.startsWith("https://")) { + throw new Error( + "Invalid Copilot runtime manifest: registry must use HTTPS.", + ); + } + return manifest; +} + +export function defaultRuntimeRoot(env = process.env) { + if (env.TYPEAGENT_RUNTIME_ROOT) { + return path.resolve(env.TYPEAGENT_RUNTIME_ROOT); + } + if (process.platform === "win32" && env.LOCALAPPDATA) { + return path.join(env.LOCALAPPDATA, "TypeAgent", "runtimes"); + } + return path.join( + env.XDG_DATA_HOME || path.join(os.homedir(), ".local", "share"), + "TypeAgent", + "runtimes", + ); +} + +export function managedRuntimeDirectory(manifest, runtimeRoot) { + return path.join( + runtimeRoot ?? defaultRuntimeRoot(), + "copilot", + manifest.cliVersion, + ); +} + +function platformPackageDirectory(runtimeDir, manifest) { + return path.join( + runtimeDir, + "node_modules", + ...manifest.platformPackage.split("/"), + ); +} + +export function resolveInstalledCopilotPath(runtimeDir, manifest) { + const packageDir = platformPackageDirectory(runtimeDir, manifest); + const packageJsonPath = path.join(packageDir, "package.json"); + if (!fs.existsSync(packageJsonPath)) { + return undefined; + } + try { + const metadata = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")); + if ( + metadata.name !== manifest.platformPackage || + metadata.version !== manifest.platformVersion || + typeof metadata.bin !== "object" || + metadata.bin === null + ) { + return undefined; + } + const target = Object.values(metadata.bin).find( + (value) => typeof value === "string", + ); + if (typeof target !== "string") { + return undefined; + } + const executable = path.resolve(packageDir, target); + return fs.existsSync(executable) ? executable : undefined; + } catch { + return undefined; + } +} + +function run(command, args, options = {}) { + const isWindowsShim = + process.platform === "win32" && /\.(?:cmd|bat)$/i.test(command); + return spawnSync(command, args, { + encoding: options.encoding, + env: options.env ?? process.env, + stdio: options.stdio, + windowsHide: options.windowsHide ?? false, + shell: isWindowsShim, + }); +} + +export function npmInvocation( + npmCommand, + platform = process.platform, + nodeExecutable = process.execPath, +) { + if (platform !== "win32" || !/\.(?:cmd|bat)$/i.test(npmCommand)) { + return { command: npmCommand, argsPrefix: [] }; + } + + const npmDirectory = path.dirname(npmCommand); + const npmCli = path.join( + npmDirectory, + "node_modules", + "npm", + "bin", + "npm-cli.js", + ); + if (!fs.existsSync(npmCli)) { + throw new Error(`Could not find npm-cli.js next to ${npmCommand}.`); + } + + const bundledNode = path.join(npmDirectory, "node.exe"); + return { + command: fs.existsSync(bundledNode) ? bundledNode : nodeExecutable, + argsPrefix: [npmCli], + }; +} + +function resolveCommand(name) { + const lookup = + process.platform === "win32" + ? run("where.exe", [name], { encoding: "utf8" }) + : run("which", [name], { encoding: "utf8" }); + if (lookup.status !== 0) { + return undefined; + } + return lookup.stdout + .split(/\r?\n/) + .map((entry) => entry.trim()) + .find(Boolean); +} + +function runNpm(args, options = {}) { + const npm = + resolveCommand(process.platform === "win32" ? "npm.cmd" : "npm") ?? + (process.platform === "win32" ? "npm.cmd" : "npm"); + const invocation = npmInvocation(npm); + return run( + invocation.command, + [...invocation.argsPrefix, ...args], + options, + ); +} + +export function globalCopilotPackageVersion(npmRoot) { + const packageJson = path.join( + npmRoot, + "@github", + "copilot", + "package.json", + ); + if (!fs.existsSync(packageJson)) { + return undefined; + } + try { + const metadata = JSON.parse(fs.readFileSync(packageJson, "utf8")); + return metadata.name === "@github/copilot" && + typeof metadata.version === "string" + ? metadata.version + : undefined; + } catch { + return undefined; + } +} + +export function npmInstallArgs(manifest, runtimeDir, userconfig) { + return [ + "install", + "--prefix", + runtimeDir, + "--omit=dev", + "--no-save", + "--package-lock=false", + "--registry", + manifest.registry, + "--userconfig", + userconfig, + `${manifest.cliPackage}@${manifest.cliVersion}`, + ]; +} + +export function transientNpmrcContent(registry, token) { + const normalized = registry.endsWith("/") ? registry : `${registry}/`; + const authKey = normalized.replace(/^https:/, ""); + const baseAuthKey = authKey.replace(/registry\/?$/, ""); + return ( + `registry=${normalized}\n` + + `${baseAuthKey}:_authToken=${token}\n` + + `${authKey}:_authToken=${token}\n` + + `${authKey}:always-auth=true\n` + ); +} + +function writeTransientNpmrc(registry, token) { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "ta-npmauth-")); + const userconfig = path.join(directory, ".npmrc"); + fs.writeFileSync(userconfig, transientNpmrcContent(registry, token), { + encoding: "utf8", + mode: 0o600, + }); + return userconfig; +} + +function acquireFeedToken(manifest, interactive) { + if (process.env.TYPEAGENT_FEED_TOKEN) { + return process.env.TYPEAGENT_FEED_TOKEN; + } + const getToken = () => + run( + process.platform === "win32" ? "az.cmd" : "az", + [ + "account", + "get-access-token", + "--resource", + manifest.azureDevOpsResource, + "--output", + "json", + "--only-show-errors", + ], + { encoding: "utf8", windowsHide: true }, + ); + + let result = getToken(); + if (result.status !== 0 && interactive) { + console.log( + "TypeAgent package feed sign-in is required before downloading GitHub Copilot.", + ); + const login = run( + process.platform === "win32" ? "az.cmd" : "az", + ["login", "--only-show-errors"], + { stdio: "inherit" }, + ); + if (login.status === 0) { + result = getToken(); + } + } + if (result.status !== 0) { + throw new Error( + "Could not authenticate to the TypeAgent package feed. Install Azure CLI, run 'az login', and retry.", + ); + } + try { + const token = JSON.parse(result.stdout).accessToken; + if (typeof token === "string" && token.length > 0) { + return token; + } + } catch {} + throw new Error( + "Azure CLI did not return a usable TypeAgent package feed token.", + ); +} + +export function npmViewArgs(manifest, packageName, version, userconfig) { + return [ + "view", + `${packageName}@${version}`, + "version", + "--registry", + manifest.registry, + "--userconfig", + userconfig, + ]; +} + +function verifyFeedPackage(manifest, packageName, version, userconfig) { + const result = runNpm( + npmViewArgs(manifest, packageName, version, userconfig), + { encoding: "utf8", windowsHide: true }, + ); + if (result.status !== 0 || result.stdout.trim() !== version) { + const detail = `${result.stderr ?? result.stdout ?? ""}`.trim(); + throw new Error( + `The TypeAgent package feed could not resolve ${packageName}@${version}${detail ? `: ${detail}` : "."}`, + ); + } +} + +function verifyCopilotRuntimeFeed(manifest, interactive) { + const token = acquireFeedToken(manifest, interactive); + const userconfig = writeTransientNpmrc(manifest.registry, token); + try { + verifyFeedPackage( + manifest, + manifest.cliPackage, + manifest.cliVersion, + userconfig, + ); + verifyFeedPackage( + manifest, + manifest.platformPackage, + manifest.platformVersion, + userconfig, + ); + console.log( + `Verified ${manifest.cliPackage}@${manifest.cliVersion} and ${manifest.platformPackage}@${manifest.platformVersion} through the TypeAgent package feed.`, + ); + } finally { + fs.rmSync(path.dirname(userconfig), { recursive: true, force: true }); + } +} + +export function setupStatePath(runtimeRoot = defaultRuntimeRoot()) { + return path.join(path.dirname(runtimeRoot), "setup", "copilot.json"); +} + +function writeSetupState(runtimeRoot, state) { + const statePath = setupStatePath(runtimeRoot); + fs.mkdirSync(path.dirname(statePath), { recursive: true }); + fs.writeFileSync( + statePath, + `${JSON.stringify({ updatedAt: new Date().toISOString(), ...state }, null, 2)}\n`, + ); +} + +export function installManagedCopilotRuntime( + manifest, + { runtimeRoot = defaultRuntimeRoot(), interactive = true } = {}, +) { + const finalDirectory = managedRuntimeDirectory(manifest, runtimeRoot); + const existing = resolveInstalledCopilotPath(finalDirectory, manifest); + if (existing) { + return existing; + } + + const token = acquireFeedToken(manifest, interactive); + const parent = path.dirname(finalDirectory); + fs.mkdirSync(parent, { recursive: true }); + const temporaryDirectory = fs.mkdtempSync( + path.join(parent, `.tmp-${manifest.cliVersion}-`), + ); + const userconfig = writeTransientNpmrc(manifest.registry, token); + try { + console.log( + `Installing ${manifest.cliPackage}@${manifest.cliVersion} from the TypeAgent package feed...`, + ); + const result = runNpm( + npmInstallArgs(manifest, temporaryDirectory, userconfig), + { stdio: "inherit" }, + ); + if (result.status !== 0) { + throw new Error( + `npm exited with code ${result.status ?? "unknown"} while installing the Copilot runtime.`, + ); + } + const executable = resolveInstalledCopilotPath( + temporaryDirectory, + manifest, + ); + if (!executable) { + throw new Error( + `The TypeAgent feed install did not produce ${manifest.platformPackage}@${manifest.platformVersion}.`, + ); + } + const backupDirectory = `${finalDirectory}.backup-${process.pid}-${Date.now()}`; + let previousMoved = false; + let adopted = false; + try { + if (fs.existsSync(finalDirectory)) { + fs.renameSync(finalDirectory, backupDirectory); + previousMoved = true; + } + fs.renameSync(temporaryDirectory, finalDirectory); + adopted = true; + const installed = resolveInstalledCopilotPath( + finalDirectory, + manifest, + ); + if (!installed) { + throw new Error( + "Installed Copilot runtime could not be verified.", + ); + } + if (previousMoved) { + fs.rmSync(backupDirectory, { recursive: true, force: true }); + } + writeSetupState(runtimeRoot, { + status: "installed", + cliVersion: manifest.cliVersion, + cliPath: installed, + }); + return installed; + } catch (error) { + if (adopted) { + fs.rmSync(finalDirectory, { recursive: true, force: true }); + } + if (previousMoved && fs.existsSync(backupDirectory)) { + fs.renameSync(backupDirectory, finalDirectory); + } + throw error; + } + } catch (error) { + writeSetupState(runtimeRoot, { + status: + error instanceof Error && error.message.includes("authenticate") + ? "feed-auth-required" + : "feed-unavailable", + cliVersion: manifest.cliVersion, + message: error instanceof Error ? error.message : String(error), + }); + throw error; + } finally { + fs.rmSync(path.dirname(userconfig), { recursive: true, force: true }); + fs.rmSync(temporaryDirectory, { recursive: true, force: true }); + } +} + +function resolveExactSystemCopilot(manifest) { + const explicit = + process.env.TYPEAGENT_COPILOT_CLI_PATH ?? process.env.COPILOT_CLI_PATH; + if (explicit && fs.existsSync(explicit)) { + return explicit; + } + const system = resolveCommand("copilot"); + if (!system) { + return undefined; + } + const npmRoot = runNpm(["root", "-g"], { + encoding: "utf8", + windowsHide: true, + }); + if (npmRoot.status !== 0) { + return undefined; + } + return globalCopilotPackageVersion(npmRoot.stdout.trim()) === + manifest.cliVersion + ? system + : undefined; +} + +export function resolveCopilotRuntime( + manifest, + { runtimeRoot = defaultRuntimeRoot() } = {}, +) { + return ( + resolveInstalledCopilotPath( + managedRuntimeDirectory(manifest, runtimeRoot), + manifest, + ) ?? resolveExactSystemCopilot(manifest) + ); +} + +async function inspectCopilot(executable) { + let sdk; + try { + sdk = await import("@github/copilot-sdk"); + } catch { + const sourceSdk = path.resolve( + scriptDir, + "..", + "..", + "packages", + "agentServer", + "bundledRuntime", + "node_modules", + "@github", + "copilot-sdk", + "dist", + "index.js", + ); + sdk = await import(pathToFileURL(sourceSdk).href); + } + const { CopilotClient, RuntimeConnection } = sdk; + const client = new CopilotClient({ + connection: RuntimeConnection.forStdio({ path: executable }), + }); + try { + await client.start(); + const runtime = await client.getStatus(); + const auth = await client.getAuthStatus(); + const models = auth.isAuthenticated ? await client.listModels() : []; + return { + runtime, + auth, + models: models.filter( + (model) => model.policy?.state !== "disabled", + ), + }; + } finally { + await client.stop().catch(() => {}); + } +} + +function runLogin(executable, options) { + const args = ["login"]; + if (options.deviceCode) { + args.push("--device-code"); + } + if (options.host && options.host !== "https://github.com") { + args.push("--host", options.host); + } + return run(executable, args, { stdio: "inherit" }).status === 0; +} + +async function setupCopilot(manifest, options) { + const runtimeRoot = options.runtimeRoot ?? defaultRuntimeRoot(); + let executable = resolveCopilotRuntime(manifest, { runtimeRoot }); + if (!executable) { + executable = installManagedCopilotRuntime(manifest, { + runtimeRoot, + interactive: options.interactive, + }); + } + console.log(`GitHub Copilot runtime: ${executable}`); + + if (!options.login) { + return 0; + } + + let status = await inspectCopilot(executable); + if (!status.auth.isAuthenticated) { + if (!options.interactive) { + writeSetupState(runtimeRoot, { + status: "auth-required", + cliVersion: manifest.cliVersion, + cliPath: executable, + }); + console.error( + "GitHub Copilot sign-in is required. Run 'node typeagent-serve.mjs setup --provider copilot'.", + ); + return 2; + } + console.log("Starting GitHub Copilot sign-in..."); + if (!runLogin(executable, options)) { + writeSetupState(runtimeRoot, { + status: "auth-cancelled", + cliVersion: manifest.cliVersion, + cliPath: executable, + }); + return 2; + } + status = await inspectCopilot(executable); + } + + if (!status.auth.isAuthenticated) { + writeSetupState(runtimeRoot, { + status: "auth-required", + cliVersion: manifest.cliVersion, + cliPath: executable, + }); + console.error("GitHub Copilot still reports as not authenticated."); + return 2; + } + if (status.models.length === 0) { + writeSetupState(runtimeRoot, { + status: "model-unavailable", + cliVersion: manifest.cliVersion, + cliPath: executable, + login: status.auth.login, + }); + console.error( + "GitHub Copilot authentication succeeded, but no enabled models are available.", + ); + return 2; + } + + writeSetupState(runtimeRoot, { + status: "ready", + cliVersion: manifest.cliVersion, + cliPath: executable, + login: status.auth.login, + authType: status.auth.authType, + models: status.models.map((model) => model.id), + }); + console.log( + `GitHub Copilot is ready${status.auth.login ? ` for ${status.auth.login}` : ""} (${status.models.length} model${status.models.length === 1 ? "" : "s"} available).`, + ); + return 0; +} + +function printHelp() { + console.log( + [ + "Usage: node copilotRuntime.mjs [setup|install|path|status|verify-feed] [options]", + "", + "Options:", + " --artifact-dir ", + " --manifest ", + " --runtime-root ", + " --runtime-only", + " --non-interactive", + " --device-code", + " --host ", + ].join("\n"), + ); +} + +async function main() { + const options = parseArgs(process.argv); + if (options.command === "help") { + printHelp(); + return 0; + } + const manifestPath = + options.manifestPath ?? + path.join(options.artifactDir, "copilot-runtime.json"); + const manifest = readCopilotRuntimeManifest(manifestPath); + const runtimeRoot = options.runtimeRoot ?? defaultRuntimeRoot(); + + switch (options.command) { + case "setup": + return setupCopilot(manifest, { ...options, runtimeRoot }); + case "install": { + const executable = installManagedCopilotRuntime(manifest, { + runtimeRoot, + interactive: options.interactive, + }); + console.log(executable); + return 0; + } + case "path": { + const executable = resolveCopilotRuntime(manifest, { runtimeRoot }); + if (!executable) { + return 1; + } + console.log(executable); + return 0; + } + case "status": { + const executable = resolveCopilotRuntime(manifest, { runtimeRoot }); + if (!executable) { + console.log("GitHub Copilot runtime is not installed."); + return 1; + } + const status = await inspectCopilot(executable); + console.log( + JSON.stringify( + { + cliPath: executable, + cliVersion: manifest.cliVersion, + runtime: status.runtime, + auth: status.auth, + models: status.models.map((model) => model.id), + }, + null, + 2, + ), + ); + return status.auth.isAuthenticated ? 0 : 2; + } + case "verify-feed": + verifyCopilotRuntimeFeed(manifest, options.interactive); + return 0; + default: + throw new Error(`Unknown command '${options.command}'.`); + } +} + +if (path.resolve(process.argv[1] ?? "") === scriptPath) { + main() + .then((code) => process.exit(code)) + .catch((error) => { + console.error(error instanceof Error ? error.message : error); + process.exit(1); + }); +} diff --git a/ts/tools/scripts/copilotRuntimeManifest.mjs b/ts/tools/scripts/copilotRuntimeManifest.mjs new file mode 100644 index 0000000000..3f4925bd7c --- /dev/null +++ b/ts/tools/scripts/copilotRuntimeManifest.mjs @@ -0,0 +1,122 @@ +#!/usr/bin/env node +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const scriptsDir = path.dirname(fileURLToPath(import.meta.url)); +const tsRoot = path.resolve(scriptsDir, "..", ".."); + +function readPackage(packageJsonPath, expectedName) { + if (!fs.existsSync(packageJsonPath)) { + throw new Error(`Could not locate package.json for ${expectedName}.`); + } + const metadata = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")); + if (metadata.name !== expectedName) { + throw new Error( + `Expected ${expectedName} metadata at ${packageJsonPath}.`, + ); + } + return { path: packageJsonPath, metadata }; +} + +export function copilotPlatformPackage(platform, arch) { + const normalizedArch = arch === "ia32" ? "x64" : arch; + const suffix = + platform === "linux" + ? `linux-${normalizedArch}` + : `${platform}-${normalizedArch}`; + return `@github/copilot-${suffix}`; +} + +export function createCopilotRuntimeManifest({ + platform = process.platform, + arch = process.arch, + registry, +} = {}) { + const sdkLink = path.join( + tsRoot, + "packages", + "agentServer", + "bundledRuntime", + "node_modules", + "@github", + "copilot-sdk", + ); + const sdkDirectory = fs.realpathSync(sdkLink); + const sdk = readPackage( + path.join(sdkDirectory, "package.json"), + "@github/copilot-sdk", + ).metadata; + const cliRequirement = sdk.dependencies?.["@github/copilot"]; + if (typeof sdk.version !== "string" || typeof cliRequirement !== "string") { + throw new Error( + "@github/copilot-sdk must declare an @github/copilot dependency.", + ); + } + + const cli = readPackage( + path.join(path.dirname(sdkDirectory), "copilot", "package.json"), + "@github/copilot", + ).metadata; + const cliVersion = cli.version; + if ( + typeof cliVersion !== "string" || + !/^\d+\.\d+\.\d+(?:[-+].+)?$/.test(cliVersion) + ) { + throw new Error( + "The resolved @github/copilot version is not concrete.", + ); + } + + const platformPackage = copilotPlatformPackage(platform, arch); + const platformVersion = cli.optionalDependencies?.[platformPackage]; + if (platformVersion !== cliVersion) { + throw new Error( + `${platformPackage} must resolve to ${cliVersion}; found ${platformVersion ?? "nothing"}.`, + ); + } + + const feedConfig = JSON.parse( + fs.readFileSync( + path.join( + tsRoot, + "tools", + "installers", + "common", + "package-feed.json", + ), + "utf8", + ), + ); + const resolvedRegistry = + registry ?? process.env.TYPEAGENT_FEED_REGISTRY ?? feedConfig.registry; + if ( + typeof resolvedRegistry !== "string" || + !resolvedRegistry.startsWith("https://") + ) { + throw new Error("A valid HTTPS TypeAgent npm feed is required."); + } + + return { + schemaVersion: 1, + sdkPackage: "@github/copilot-sdk", + sdkVersion: sdk.version, + sdkCliRequirement: cliRequirement, + cliPackage: "@github/copilot", + cliVersion, + platformPackage, + platformVersion, + registry: resolvedRegistry, + azureDevOpsResource: feedConfig.azureDevOpsResource, + }; +} + +export function writeCopilotRuntimeManifest(outputPath, options) { + const manifest = createCopilotRuntimeManifest(options); + fs.mkdirSync(path.dirname(outputPath), { recursive: true }); + fs.writeFileSync(outputPath, `${JSON.stringify(manifest, null, 2)}\n`); + return manifest; +} diff --git a/ts/tools/scripts/deployAgentServer.mjs b/ts/tools/scripts/deployAgentServer.mjs index 20f2017bb9..f753d96ce5 100644 --- a/ts/tools/scripts/deployAgentServer.mjs +++ b/ts/tools/scripts/deployAgentServer.mjs @@ -28,6 +28,7 @@ import { spawnSync } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import { writeCopilotRuntimeManifest } from "./copilotRuntimeManifest.mjs"; const scriptsDir = path.dirname(fileURLToPath(import.meta.url)); const tsRoot = path.resolve(scriptsDir, "..", ".."); // ts/ @@ -145,10 +146,9 @@ function main() { ); } - // 2c. External-CLI variant: drop the bundled Claude/Copilot runtimes. Only - // valid where `claude`/`copilot` are on PATH (managed machines / the - // standalone installer); the runtime query() callers are wired to resolve - // the PATH binary (claudeExecutableOption), so they don't need the bundle. + // 2c. External-runtime variant: drop the bundled Claude/Copilot runtimes. + // Claude resolves from PATH. Copilot resolves from TypeAgent's managed + // runtime cache or an explicit compatible path. if (args.externalCli) { run( "node", @@ -157,7 +157,7 @@ function main() { ); fs.writeFileSync( path.join(args.out, ".typeagent-external-cli"), - "claude,copilot must be on PATH\n", + "claude must be on PATH; copilot uses the TypeAgent managed runtime\n", "utf8", ); console.log(" recorded external-cli mode (.typeagent-external-cli)"); @@ -177,6 +177,14 @@ function main() { // closure (chalk, @azure/keyvault-secrets, @azure/identity, js-yaml, // @typeagent/config), so it runs as `node tools/getKeys.mjs`. const toolsOut = path.join(args.out, "tools"); + writeCopilotRuntimeManifest(path.join(args.out, "copilot-runtime.json"), { + platform: args.platform, + arch: args.arch, + }); + copyInto( + path.join(scriptsDir, "copilotRuntime.mjs"), + path.join(toolsOut, "copilotRuntime.mjs"), + ); copyInto( path.join(scriptsDir, "getKeys.mjs"), path.join(toolsOut, "getKeys.mjs"), diff --git a/ts/tools/scripts/install-typeagent.ps1 b/ts/tools/scripts/install-typeagent.ps1 index 7474edf8ef..71241adf8e 100644 --- a/ts/tools/scripts/install-typeagent.ps1 +++ b/ts/tools/scripts/install-typeagent.ps1 @@ -11,10 +11,10 @@ and prerequisites exist). This script provisions what a bare machine lacks: 1. Verifies Node >= 22. - 2. For the 'external' artifact variant, provisions the Claude Code + GitHub - Copilot CLIs on PATH (the external-cli agent-server resolves them at - runtime; see @typeagent/agent-sdk/node claudeExecutableOption). The 'full' - variant bundles those runtimes, so this step is skipped. + 2. For the 'external' artifact variant, provisions Claude Code on PATH and, + when Copilot is selected, installs the exact SDK-compatible Copilot + runtime into TypeAgent's per-user cache. The 'full' variant bundles those + runtimes, so this step is skipped. 3. Downloads the agent-server Universal package for this RID from the feed. 4. Installs and registers the Copilot CLI plugin (from feed by default, or -PluginSource). 5. When VS Code 1.133 or newer is present, installs TypeAgent Chat and @@ -55,6 +55,7 @@ param( [string]$Org = "https://dev.azure.com/msctoproj", [string]$Project = "AI_Systems", [string]$Feed = "typeagent", + [string]$NpmFeedRegistry = "", [string]$PluginSource = "", [string]$PluginVersion = "latest", [string]$PluginPackageName = "typeagent-copilot-plugin", @@ -130,6 +131,53 @@ function Test-Command($name) { return [bool](Get-Command $name -ErrorAction SilentlyContinue) } +$AzureDevOpsResource = "499b84ac-1321-427f-aa17-267ca6975798" + +function Get-NpmFeedToken { + $output = & az account get-access-token ` + --resource $AzureDevOpsResource ` + --output json ` + --only-show-errors 2>$null | Out-String + if ($LASTEXITCODE -ne 0 -or -not $output) { + return $null + } + try { + return ($output | ConvertFrom-Json).accessToken + } catch { + return $null + } +} + +function New-TransientNpmrc { + param( + [Parameter(Mandatory = $true)][string]$Registry, + [Parameter(Mandatory = $true)][string]$Token + ) + + $normalized = if ($Registry.EndsWith("/")) { $Registry } else { "$Registry/" } + $authKey = $normalized -replace '^https:', '' + $baseAuthKey = $authKey -replace 'registry/?$', '' + $directory = Join-Path $env:TEMP ("ta-npmauth-" + [guid]::NewGuid().ToString("N")) + New-Item -ItemType Directory -Force -Path $directory | Out-Null + $userconfig = Join-Path $directory ".npmrc" + $content = "registry=$normalized`n$($baseAuthKey):_authToken=$Token`n$($authKey):_authToken=$Token`n$($authKey):always-auth=true`n" + Set-Content -Path $userconfig -Value $content -NoNewline -Encoding ascii + return $userconfig +} + +function Install-GlobalPackageFromTypeAgentFeed { + param( + [Parameter(Mandatory = $true)][string]$Package, + [Parameter(Mandatory = $true)][string]$Registry, + [Parameter(Mandatory = $true)][string]$UserConfig + ) + + & npm install -g $Package --registry $Registry --userconfig $UserConfig + if ($LASTEXITCODE -ne 0) { + Fail "Failed to install '$Package' from the TypeAgent package feed." + } +} + function Test-AzureDevOpsAuthError { param( [Parameter(Mandatory = $true)][string]$Text @@ -426,23 +474,11 @@ Write-Host " Node $(& node --version)" # --- 2. External-CLI prerequisites (Claude Code + Copilot CLI) --------------- if ($Variant -eq "external") { - Write-Step "Provisioning external CLIs (claude, copilot)" + Write-Step "Checking external runtime prerequisites" if (-not (Test-Command npm)) { - Fail "npm is required to install the CLIs (ships with Node)." - } - if (-not (Test-Command claude)) { - Write-Host " Installing Claude Code CLI (npm i -g @anthropic-ai/claude-code)" - & npm install -g "@anthropic-ai/claude-code" - } else { - Write-Host " claude already on PATH: $((Get-Command claude).Source)" + Fail "npm is required to install external runtimes from the TypeAgent package feed." } - if (-not (Test-Command copilot)) { - Write-Host " Installing GitHub Copilot CLI (npm i -g @github/copilot)" - & npm install -g "@github/copilot" - } else { - Write-Host " copilot already on PATH: $((Get-Command copilot).Source)" - } - Write-Host " NOTE: both CLIs require a one-time auth (e.g. 'claude' / 'copilot' login) before agent actions work." + Write-Host " Runtime packages will be provisioned after the agent-server artifact is available." } # --- 3. Download the agent-server artifact from the feed --------------------- @@ -491,6 +527,60 @@ if ($assetExists) { if (-not (Test-Path $serve)) { Fail "Agent-server assets missing typeagent-serve.mjs (unexpected layout)." } +$managedCopilotPath = $null +if ($Variant -eq "external") { + $runtimeManifestPath = Join-Path $InstallDir "copilot-runtime.json" + if (-not (Test-Path $runtimeManifestPath)) { + Fail "Agent-server assets are missing copilot-runtime.json." + } + $runtimeManifest = Get-Content -Raw -Path $runtimeManifestPath | ConvertFrom-Json + $resolvedNpmRegistry = if ($NpmFeedRegistry) { + $NpmFeedRegistry + } else { + [string]$runtimeManifest.registry + } + if (-not $resolvedNpmRegistry) { + Fail "The TypeAgent npm feed registry is not configured." + } + + if (-not (Test-Command claude)) { + $feedToken = Get-NpmFeedToken + if (-not $feedToken) { + Invoke-AzLoginForAccess -Reason "TypeAgent package feed sign-in is required to install Claude Code." + $feedToken = Get-NpmFeedToken + } + if (-not $feedToken) { + Fail "Could not authenticate to the TypeAgent package feed. npm was not invoked." + } + $userconfig = New-TransientNpmrc -Registry $resolvedNpmRegistry -Token $feedToken + try { + Write-Host " Installing Claude Code CLI from the TypeAgent package feed" + Install-GlobalPackageFromTypeAgentFeed ` + -Package "@anthropic-ai/claude-code" ` + -Registry $resolvedNpmRegistry ` + -UserConfig $userconfig + } finally { + Remove-Item (Split-Path -Parent $userconfig) -Recurse -Force -ErrorAction SilentlyContinue + } + } else { + Write-Host " claude already on PATH: $((Get-Command claude).Source)" + } + + if ($Provider -eq "copilot") { + Write-Step "Installing the SDK-compatible Copilot runtime" + & node $serve setup --provider copilot --runtime-only + if ($LASTEXITCODE -ne 0) { + Fail "Copilot runtime installation from the TypeAgent package feed failed." + } + $runtimeTool = Join-Path $InstallDir "tools\copilotRuntime.mjs" + $managedCopilotPath = (& node $runtimeTool path | Select-Object -First 1) + if ($LASTEXITCODE -ne 0 -or -not $managedCopilotPath -or -not (Test-Path $managedCopilotPath)) { + Fail "The managed Copilot runtime was installed but its executable could not be resolved." + } + Write-Host " Copilot runtime: $managedCopilotPath" + } +} + # --- 4. Install and register the Copilot CLI plugin --------------------------- Write-Step "Installing Copilot CLI plugin" $pluginSourceDir = $PluginInstallDir @@ -500,16 +590,10 @@ $pluginName = "typeagent" $pluginDescription = "TypeAgent Copilot CLI plugin" $pluginResolvedVersion = $PluginVersion -if (-not (Test-Command copilot)) { - if (-not (Test-Command npm)) { - Fail "GitHub Copilot CLI is required to register the plugin, and npm is not available to install it." - } - - Write-Host " Installing GitHub Copilot CLI (npm i -g @github/copilot)" - & npm install -g "@github/copilot" - if (-not (Test-Command copilot)) { - Fail "GitHub Copilot CLI was not found after installation." - } +$pluginCopilotPath = if (Test-Command copilot) { + (Get-Command copilot).Source +} else { + $managedCopilotPath } if ($Upgrade -and (Test-Path $pluginSourceDir)) { @@ -576,23 +660,28 @@ if (-not (Test-Path $registerPluginScript)) { } $pluginRegisterLogPath = Join-Path (Join-Path $env:USERPROFILE ".typeagent") "logs\register-plugin.log" -Write-Host " Registering plugin with shared script" -$registerArgs = @( - $registerPluginScript, - "--install-dir", $InstallDir, - "--plugin-source-dir", $pluginSourceDir, - "--marketplace-name", $PluginMarketplaceName, - "--marketplace-root", $PluginMarketplaceDir, - "--plugin-name", $pluginName, - "--plugin-description", $pluginDescription, - "--plugin-version", $pluginResolvedVersion, - "--log-path", $pluginRegisterLogPath -) -& node @registerArgs -if ($LASTEXITCODE -ne 0) { - Fail "Copilot plugin registration failed. See log: $pluginRegisterLogPath" +if ($pluginCopilotPath) { + Write-Host " Registering plugin with shared script via $pluginCopilotPath" + $registerArgs = @( + $registerPluginScript, + "--install-dir", $InstallDir, + "--plugin-source-dir", $pluginSourceDir, + "--marketplace-name", $PluginMarketplaceName, + "--marketplace-root", $PluginMarketplaceDir, + "--plugin-name", $pluginName, + "--plugin-description", $pluginDescription, + "--plugin-version", $pluginResolvedVersion, + "--copilot-path", $pluginCopilotPath, + "--log-path", $pluginRegisterLogPath + ) + & node @registerArgs + if ($LASTEXITCODE -ne 0) { + Fail "Copilot plugin registration failed. See log: $pluginRegisterLogPath" + } + Write-Host " Copilot plugin '$pluginName' registered successfully" +} else { + Write-Host " Copilot CLI is not available. Plugin registration is deferred; rerun setup after installing a CLI." -ForegroundColor Yellow } -Write-Host " Copilot plugin '$pluginName' registered successfully" # --- 5. Install the native VS Code Chat extension ---------------------------- $vscodeChatInstalled = $false @@ -754,7 +843,11 @@ if ($Provider -eq "aisystems") { } } if ($Provider -eq "copilot") { - Write-Host " Reminder: the 'copilot' CLI must be installed and authenticated (github login)." -ForegroundColor Yellow + Write-Step "Signing in to and verifying GitHub Copilot" + & node $serve setup --provider copilot + if ($LASTEXITCODE -ne 0) { + Fail "GitHub Copilot setup did not complete successfully." + } } } diff --git a/ts/tools/scripts/pruneSdkBinaries.mjs b/ts/tools/scripts/pruneSdkBinaries.mjs index 24964ef1bc..c6ccda7782 100644 --- a/ts/tools/scripts/pruneSdkBinaries.mjs +++ b/ts/tools/scripts/pruneSdkBinaries.mjs @@ -10,8 +10,8 @@ * Safe ONLY when: * (1) every runtime `query()` caller passes pathToClaudeCodeExecutable (we wire * them via claudeExecutableOption()), AND - * (2) `claude` and `copilot` are guaranteed on PATH in the target environment - * (managed machines; the standalone installer provisions them). + * (2) `claude` is available on PATH and Copilot is resolved from TypeAgent's + * managed runtime cache or an explicit compatible path. * Otherwise the SDKs lose their bundled binary with no fallback. This is why it * is opt-in (deployAgentServer --external-cli), never the default artifact. * @@ -111,7 +111,7 @@ function main() { } console.log( `${args.dryRun ? "Would free" : "Freed"} ${fmt(freed)} across ${removed.length} bundled-runtime package(s). ` + - `(claude/copilot must be on PATH in the target environment.)`, + `(external Claude and managed Copilot runtimes are required.)`, ); } diff --git a/ts/tools/scripts/setup-typeagent-prereqs.ps1 b/ts/tools/scripts/setup-typeagent-prereqs.ps1 index c9552c1bc0..8e44805a51 100644 --- a/ts/tools/scripts/setup-typeagent-prereqs.ps1 +++ b/ts/tools/scripts/setup-typeagent-prereqs.ps1 @@ -13,10 +13,11 @@ 3. Azure CLI (Microsoft.AzureCLI via winget when missing) 4. Azure DevOps az extension 5. Optional: az login - 6. For Variant=external: claude + copilot CLIs via npm -g - 7. Optional: devtunnel CLI when -DevTunnel is specified + 6. Optional: devtunnel CLI when -DevTunnel is specified This script is standalone and does not require a local TypeAgent repository. + External runtime packages are installed later by install-typeagent.ps1 after + the artifact supplies its exact compatibility manifest and package feed. .EXAMPLE pwsh ./setup-typeagent-prereqs.ps1 @@ -298,38 +299,6 @@ function Ensure-AzureCli { } } -function Ensure-NpmGlobalCli { - param( - [Parameter(Mandatory = $true)][string]$Command, - [Parameter(Mandatory = $true)][string]$PackageName, - [Parameter(Mandatory = $true)][string]$FriendlyName - ) - - $shouldInstall = $ForceReinstallCli -or -not (Test-Command $Command) - - if ($shouldInstall) { - Write-Info "Installing $FriendlyName (npm i -g $PackageName)" - & npm install -g $PackageName - if ($LASTEXITCODE -ne 0) { - Fail "npm global install failed for $PackageName" - } - Refresh-Path - } - - if (-not (Test-Command $Command)) { - Fail "$FriendlyName command '$Command' was not found on PATH after install." - } - - Write-Ok "${FriendlyName}: $((Get-Command $Command).Source)" -} - -function Ensure-ExternalClis { - Write-Step "Ensuring external CLIs (claude, copilot)" - Ensure-NpmGlobalCli -Command "claude" -PackageName "@anthropic-ai/claude-code" -FriendlyName "Claude Code CLI" - Ensure-NpmGlobalCli -Command "copilot" -PackageName "@github/copilot" -FriendlyName "GitHub Copilot CLI" - Write-WarnMsg "Remember to sign in once: run 'claude' and 'copilot' interactively." -} - function Ensure-DevTunnel { Write-Step "Ensuring devtunnel CLI" @@ -372,7 +341,10 @@ Ensure-Node Ensure-AzureCli if ($Variant -eq "external") { - Ensure-ExternalClis + Write-Info "External runtimes will be installed from the TypeAgent package feed after the agent-server artifact is downloaded." +} +if ($ForceReinstallCli) { + Write-WarnMsg "-ForceReinstallCli is deprecated; install-typeagent.ps1 now owns exact runtime installation." } if ($DevTunnel) { diff --git a/ts/tools/scripts/test/copilotManagedRuntime.spec.mjs b/ts/tools/scripts/test/copilotManagedRuntime.spec.mjs new file mode 100644 index 0000000000..29b3a00c7f --- /dev/null +++ b/ts/tools/scripts/test/copilotManagedRuntime.spec.mjs @@ -0,0 +1,289 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; +import { + copilotPlatformPackage, + createCopilotRuntimeManifest, +} from "../copilotRuntimeManifest.mjs"; +import { + globalCopilotPackageVersion, + managedRuntimeDirectory, + npmInstallArgs, + npmInvocation, + npmViewArgs, + readCopilotRuntimeManifest, + resolveInstalledCopilotPath, + transientNpmrcContent, +} from "../copilotRuntime.mjs"; +import { listedEntryState } from "../../installers/common/register-plugin.mjs"; + +const testDir = path.dirname(fileURLToPath(import.meta.url)); +const tsRoot = path.resolve(testDir, "..", "..", ".."); + +test("manifest records the resolved SDK-compatible Windows runtime", () => { + const manifest = createCopilotRuntimeManifest({ + platform: "win32", + arch: "x64", + }); + assert.equal(manifest.sdkPackage, "@github/copilot-sdk"); + assert.equal(manifest.sdkVersion, "1.0.9"); + assert.equal(manifest.sdkCliRequirement, "^1.0.78"); + assert.equal(manifest.cliPackage, "@github/copilot"); + assert.equal(manifest.cliVersion, "1.0.79"); + assert.equal(manifest.platformPackage, "@github/copilot-win32-x64"); + assert.equal(manifest.platformVersion, manifest.cliVersion); + assert.match(manifest.registry, /^https:\/\/pkgs\.dev\.azure\.com\//); +}); + +test("platform package naming follows Copilot package conventions", () => { + assert.equal( + copilotPlatformPackage("win32", "x64"), + "@github/copilot-win32-x64", + ); + assert.equal( + copilotPlatformPackage("darwin", "arm64"), + "@github/copilot-darwin-arm64", + ); +}); + +test("npm install is exact and locked to the authenticated feed", () => { + const manifest = createCopilotRuntimeManifest({ + platform: "win32", + arch: "x64", + }); + const args = npmInstallArgs(manifest, "C:\\runtime", "C:\\auth\\.npmrc"); + assert.deepEqual(args.slice(0, 3), ["install", "--prefix", "C:\\runtime"]); + assert.ok(args.includes("--registry")); + assert.ok(args.includes(manifest.registry)); + assert.ok(args.includes("--userconfig")); + assert.ok(args.includes("C:\\auth\\.npmrc")); + assert.ok(args.includes(`@github/copilot@${manifest.cliVersion}`)); + assert.ok(!args.includes("-g")); +}); + +test("Windows npm shims run through node without shell parsing", () => { + const root = fs.mkdtempSync( + path.join(os.tmpdir(), "TypeAgent Program Files "), + ); + try { + const npmCommand = path.join(root, "nodejs", "npm.cmd"); + const nodeExecutable = path.join(root, "nodejs", "node.exe"); + const npmCli = path.join( + root, + "nodejs", + "node_modules", + "npm", + "bin", + "npm-cli.js", + ); + fs.mkdirSync(path.dirname(npmCli), { recursive: true }); + fs.writeFileSync(npmCommand, ""); + fs.writeFileSync(nodeExecutable, ""); + fs.writeFileSync(npmCli, ""); + + assert.deepEqual(npmInvocation(npmCommand, "win32", "fallback.exe"), { + command: nodeExecutable, + argsPrefix: [npmCli], + }); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test("transient npm config pins the registry and scopes the token", () => { + const registry = + "https://pkgs.dev.azure.com/org/project/_packaging/feed/npm/registry/"; + const contents = transientNpmrcContent(registry, "token-value"); + assert.match(contents, /^registry=https:\/\/pkgs\.dev\.azure\.com\//); + assert.match( + contents, + /\n\/\/pkgs\.dev\.azure\.com\/org\/project\/_packaging\/feed\/npm\/:_authToken=token-value/, + ); + assert.match( + contents, + /\n\/\/pkgs\.dev\.azure\.com\/org\/project\/_packaging\/feed\/npm\/registry\/:_authToken=token-value/, + ); + assert.doesNotMatch(contents, /\nhttps:.*_authToken/); +}); + +test("feed verification resolves both exact packages through the configured registry", () => { + const manifest = createCopilotRuntimeManifest({ + platform: "win32", + arch: "x64", + }); + const args = npmViewArgs( + manifest, + manifest.platformPackage, + manifest.platformVersion, + "C:\\auth\\.npmrc", + ); + assert.deepEqual(args.slice(0, 3), [ + "view", + `${manifest.platformPackage}@${manifest.platformVersion}`, + "version", + ]); + assert.ok(args.includes(manifest.registry)); + assert.ok(args.includes("C:\\auth\\.npmrc")); +}); + +test("managed runtime resolves the platform executable only at the exact version", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ta-copilot-test-")); + try { + const manifest = createCopilotRuntimeManifest({ + platform: "win32", + arch: "x64", + }); + + test("global Copilot compatibility uses npm package metadata", () => { + const root = fs.mkdtempSync( + path.join(os.tmpdir(), "ta-copilot-global-"), + ); + try { + const packageDir = path.join(root, "@github", "copilot"); + fs.mkdirSync(packageDir, { recursive: true }); + fs.writeFileSync( + path.join(packageDir, "package.json"), + JSON.stringify({ + name: "@github/copilot", + version: "1.0.79", + }), + ); + assert.equal(globalCopilotPackageVersion(root), "1.0.79"); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + const runtimeDir = managedRuntimeDirectory(manifest, root); + const packageDir = path.join( + runtimeDir, + "node_modules", + "@github", + "copilot-win32-x64", + ); + fs.mkdirSync(packageDir, { recursive: true }); + fs.writeFileSync( + path.join(packageDir, "package.json"), + JSON.stringify({ + name: manifest.platformPackage, + version: manifest.platformVersion, + bin: { "copilot-win32-x64": "copilot.exe" }, + }), + ); + const executable = path.join(packageDir, "copilot.exe"); + fs.writeFileSync(executable, ""); + assert.equal( + resolveInstalledCopilotPath(runtimeDir, manifest), + executable, + ); + + const manifestPath = path.join(root, "copilot-runtime.json"); + fs.writeFileSync(manifestPath, JSON.stringify(manifest)); + assert.deepEqual(readCopilotRuntimeManifest(manifestPath), manifest); + + const wrong = { ...manifest, platformVersion: "9.9.9" }; + assert.equal(resolveInstalledCopilotPath(runtimeDir, wrong), undefined); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test("installer integration uses managed setup without adding UI properties", () => { + const prereqs = fs.readFileSync( + path.join(tsRoot, "tools", "installers", "wix", "install-prereqs.ps1"), + "utf8", + ); + const wix = fs.readFileSync( + path.join( + tsRoot, + "tools", + "installers", + "wix", + "TypeAgent-AgentServer.wxs", + ), + "utf8", + ); + const standalone = fs.readFileSync( + path.join(tsRoot, "tools", "scripts", "install-typeagent.ps1"), + "utf8", + ); + const bootstrap = fs.readFileSync( + path.join(tsRoot, "tools", "scripts", "setup-typeagent-prereqs.ps1"), + "utf8", + ); + const artifactBuilders = ["bundleAgentServer.mjs", "deployAgentServer.mjs"] + .map((file) => + fs.readFileSync( + path.join(tsRoot, "tools", "scripts", file), + "utf8", + ), + ) + .join("\n"); + assert.match( + prereqs, + /setup --provider copilot --runtime-only --non-interactive/, + ); + assert.doesNotMatch( + prereqs, + /Install-Cli\s+"copilot"\s+"@github\/copilot"/, + ); + assert.match(wix, /-Provider "\[PROVIDER\]"/); + assert.doesNotMatch(wix, /Property Id="COPILOTRUNTIME"/); + assert.match( + wix, + /Custom Action="LaunchCopilotSetup"\s+After="InstallFinalize"/, + ); + assert.match( + wix, + /\(NOT Installed\) AND \(PROVIDER="COPILOT"\) AND \(UILevel >= 4\)/, + ); + assert.doesNotMatch(standalone, /npm install -g ["']?@github\/copilot/); + assert.doesNotMatch(bootstrap, /npm install -g/); + assert.doesNotMatch(artifactBuilders, /copilot must be on PATH/); + assert.match(artifactBuilders, /copilot-runtime\.json/); +}); + +test("deployed launcher dispatches the Copilot setup command", () => { + const launcher = path.join( + tsRoot, + "tools", + "scripts", + "typeagent-serve.mjs", + ); + const result = spawnSync( + process.execPath, + [launcher, "setup", "--provider", "invalid"], + { encoding: "utf8" }, + ); + assert.equal(result.status, 1); + assert.match( + result.stderr, + /Setup currently supports only '--provider copilot'/, + ); + assert.doesNotMatch(result.stderr, /cmdSetup is not defined/); +}); + +test("plugin registration distinguishes disabled marketplace entries", () => { + const identifier = "typeagent@typeagent-local"; + assert.equal(listedEntryState("", identifier), "absent"); + assert.equal( + listedEntryState( + ` • ${identifier} (v0.0.1) (disabled)\n from C:\\marketplace`, + identifier, + ), + "disabled", + ); + assert.equal( + listedEntryState( + ` • ${identifier} (v0.0.1) (enabled)\n from C:\\marketplace`, + identifier, + ), + "enabled", + ); +}); diff --git a/ts/tools/scripts/typeagent-serve.mjs b/ts/tools/scripts/typeagent-serve.mjs index 8b281b8fba..8f88df5252 100644 --- a/ts/tools/scripts/typeagent-serve.mjs +++ b/ts/tools/scripts/typeagent-serve.mjs @@ -19,6 +19,8 @@ * Usage (from the artifact root): * node typeagent-serve.mjs [start] # ensure the daemon is up (default) * node typeagent-serve.mjs provision # run getKeys to write config.local.yaml + * node typeagent-serve.mjs setup --provider copilot + * # install and authenticate Copilot * node typeagent-serve.mjs status # report whether the daemon is listening * node typeagent-serve.mjs stop # stop the daemon (best effort) * node typeagent-serve.mjs autostart [enable|disable|status] # register a @@ -49,6 +51,11 @@ const generateConfigEntry = path.join( "tools", "generate-selfhost-config.mjs", ); +const copilotRuntimeEntry = path.join( + artifactDir, + "tools", + "copilotRuntime.mjs", +); // Profile recorded by deployAgentServer when the artifact was profile-pruned. function readProfileMarker() { @@ -125,6 +132,30 @@ function runInline(entry, extraArgs) { }); } +function configureManagedCopilotRuntime() { + if ( + process.env.TYPEAGENT_COPILOT_CLI_PATH || + process.env.COPILOT_CLI_PATH || + !fs.existsSync(copilotRuntimeEntry) + ) { + return; + } + const result = spawnSync(process.execPath, [copilotRuntimeEntry, "path"], { + encoding: "utf8", + env: process.env, + windowsHide: true, + }); + if (result.status === 0) { + const executable = result.stdout + .split(/\r?\n/) + .map((line) => line.trim()) + .find(Boolean); + if (executable) { + process.env.TYPEAGENT_COPILOT_CLI_PATH = executable; + } + } +} + function daemonLogPath() { return path.join(userDataDir(), "agent-server.log"); } @@ -252,6 +283,30 @@ async function cmdStart() { return 1; } +async function cmdSetup() { + const provider = (arg("--provider") ?? "").toLowerCase(); + if (provider !== "copilot") { + console.error("Setup currently supports only '--provider copilot'."); + return 1; + } + if (!fs.existsSync(copilotRuntimeEntry)) { + console.error( + `Copilot runtime setup tool not found at ${copilotRuntimeEntry}.`, + ); + return 1; + } + const passthrough = []; + const rest = process.argv.slice(3); + for (let i = 0; i < rest.length; i++) { + if (rest[i] === "--provider") { + i++; + continue; + } + passthrough.push(rest[i]); + } + return runInline(copilotRuntimeEntry, ["setup", ...passthrough]); +} + async function cmdProvision() { // Chat endpoint provider. Default 'aisystems' preserves today's behavior // (Key Vault download via getKeys). 'ollama'/'copilot' synthesize a @@ -875,7 +930,10 @@ async function main() { : "start"; switch (cmd) { case "start": + configureManagedCopilotRuntime(); return cmdStart(); + case "setup": + return cmdSetup(); case "provision": case "getkeys": return cmdProvision();