diff --git a/ts/Dockerfile b/ts/Dockerfile index d629db51fe..9bbd75c1fb 100644 --- a/ts/Dockerfile +++ b/ts/Dockerfile @@ -1,24 +1,12 @@ # syntax=docker/dockerfile:1 -FROM node:24.22.1-bookworm AS base +FROM node:24.22.1-bookworm AS runtime-base -ENV PNPM_HOME="/pnpm" -ENV PATH="$PNPM_HOME:$PATH" - -WORKDIR /usr/src/app -COPY package.json ./package.json -# npm honors private-registry credentials; Corepack does not read .npmrc. -RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \ - npm install --global corepack@0.36.0 \ - && corepack disable pnpm \ - && npm install --global "$(node -p "require('./package.json').packageManager.split('+')[0]")" \ - && npm cache clean --force - -# Refresh security updates as well as installing the app's system dependencies. +# Refresh security updates and install only the dependencies needed at runtime. +# Azure CLI and development headers are deliberately excluded from this stage. RUN apt-get update \ && DEBIAN_FRONTEND=noninteractive apt-get upgrade -y \ && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ libsecret-1-0 \ - libsecret-1-dev \ gnome-keyring \ curl \ wget \ @@ -44,7 +32,32 @@ RUN apt-get update \ && apt-get clean \ && rm -rf /var/lib/apt/lists -RUN mkdir -p /etc/apt/keyrings \ +# Install Chrome for Puppeteer. +RUN curl -fsSL https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb -o /tmp/google-chrome.deb \ + && apt-get update \ + && apt-get install -y --no-install-recommends /tmp/google-chrome.deb \ + && rm /tmp/google-chrome.deb \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists + +# Build-only image. Azure CLI and pnpm are not copied into the runtime image. +FROM runtime-base AS build + +ENV PNPM_HOME="/pnpm" +ENV PATH="$PNPM_HOME:$PATH" + +WORKDIR /usr/src/app +COPY package.json ./package.json +# npm honors private-registry credentials; Corepack does not read .npmrc. +RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \ + npm install --global corepack@0.36.0 \ + && corepack disable pnpm \ + && npm install --global "$(node -p "require('./package.json').packageManager.split('+')[0]")" \ + && npm cache clean --force + +RUN apt-get update \ + && apt-get install -y --no-install-recommends libsecret-1-dev \ + && mkdir -p /etc/apt/keyrings \ && curl -fsSL https://packages.microsoft.com/keys/microsoft.asc -o /tmp/microsoft.asc \ && gpg --dearmor -o /etc/apt/keyrings/microsoft.gpg /tmp/microsoft.asc \ && chmod 644 /etc/apt/keyrings/microsoft.gpg \ @@ -55,22 +68,11 @@ RUN mkdir -p /etc/apt/keyrings \ && apt-get clean \ && rm -rf /var/lib/apt/lists -# Install Chrome for Puppeteer -RUN curl -fsSL https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb -o /tmp/google-chrome.deb \ - && apt-get update \ - && apt-get install -y --no-install-recommends /tmp/google-chrome.deb \ - && rm /tmp/google-chrome.deb \ - && apt-get clean \ - && rm -rf /var/lib/apt/lists - -# base only image -FROM base AS build COPY ./ /usr/src/app -WORKDIR /usr/src/app # onnxruntime-node's postinstall otherwise downloads the large CUDA/GPU # onnxruntime build from GitHub releases. This image is CPU-only (no CUDA # runtime) and that download is flaky in CI (fails with ECONNRESET), so skip -# it — the bundled CPU execution provider is all this container uses. +# it - the bundled CPU execution provider is all this container uses. ENV ONNXRUNTIME_NODE_INSTALL_CUDA=skip RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \ pnpm install --frozen-lockfile @@ -78,19 +80,16 @@ RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \ pnpm run -r build RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \ pnpm deploy --legacy --filter=agent-api --prod /prod/api -RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \ - pnpm deploy --legacy --filter=tools-scripts --prod /prod/tools -# image with api and tool bits only -FROM base AS api -# api bits +# Image with API bits only. +FROM runtime-base AS api + COPY --from=build /prod/api/ /prod/api/ -COPY --from=build /prod/tools/ /prod/tools/ -# rendering bits COPY --from=build /usr/src/app/packages/shell/out/ /prod/shell/out/ +COPY packages/api/container/start.sh /usr/local/bin/typeagent-api-start WORKDIR /prod/api -######################################### BLOB STORAGE MOUNT ######################################### +######################################### BLOB STORAGE MOUNT ######################################### ## blobfuse - https://github.com/Azure/azure-storage-fuse/tree/main ## Uncomment the section below to use blobfuse to mount blob storage #RUN apt-get update \ @@ -112,6 +111,10 @@ EXPOSE 3000 # the network. In the container the published port is the whole point and the # container boundary is the isolation, so bind all interfaces here. ENV TYPEAGENT_API_HOST=0.0.0.0 +ENV TYPEAGENT_API_BOOTSTRAP=hosted-azure-managed-identity-v1 +ENV TYPEAGENT_API_CONFIG_SECRET=typeagent-api-hosted-config +ENV HOME=/home/typeagent +ENV TYPEAGENT_USER_DATA_DIR=/home/typeagent/.typeagent # Browser clients are still gated on Origin, and a hosted deployment serves the # chat view from its own hostname rather than localhost. Set @@ -119,15 +122,20 @@ ENV TYPEAGENT_API_HOST=0.0.0.0 # e.g. TYPEAGENT_API_ALLOWED_ORIGINS=https://typeagent.example.com, or the # browser's dispatcher WebSocket will be refused with 403. -# start actions -# az login -RUN echo az login --identity >> start.sh -## mount blob storage locally -#RUN echo blobfuse2 mount /mnt/blob --config-file=mount-blobfuse.yaml >> start.sh -# run getKeys -RUN echo node ../tools/scripts/getKeys.mjs >> start.sh -# Start Node directly so startup does not need npm registry credentials. -RUN echo node ./dist/index.js >> start.sh - -# run start script -CMD ["bash", "start.sh"] +RUN groupadd --gid 10001 typeagent \ + && useradd --uid 10001 --gid typeagent --no-create-home \ + --shell /usr/sbin/nologin typeagent \ + && install -d -o root -g root -m 0555 /home/typeagent \ + && install -d -o typeagent -g typeagent -m 0700 /home/typeagent/.typeagent \ + && chmod 0555 /usr/local/bin/typeagent-api-start + +USER typeagent +CMD ["/usr/local/bin/typeagent-api-start"] + +FROM api AS api-smoke +COPY packages/api/container/smoke.mjs /prod/api/container/smoke.mjs +CMD ["node", "/prod/api/container/smoke.mjs"] + +# Keep production as the final stage because the ACR deployment builds this +# Dockerfile without an explicit --target. +FROM api AS production diff --git a/ts/packages/aiclient/src/auth.ts b/ts/packages/aiclient/src/auth.ts index 78182ac55c..50dff83d6c 100644 --- a/ts/packages/aiclient/src/auth.ts +++ b/ts/packages/aiclient/src/auth.ts @@ -1,7 +1,11 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { AccessToken, DefaultAzureCredential } from "@azure/identity"; +import { + AccessToken, + DefaultAzureCredential, + type TokenCredential, +} from "@azure/identity"; import { Result, success, error } from "typechat"; export interface AuthTokenProvider { @@ -9,17 +13,34 @@ export interface AuthTokenProvider { refreshToken(): Promise>; } +export type AzureCredentialFactory = () => TokenCredential; + export enum AzureTokenScopes { CogServices = "https://cognitiveservices.azure.com/.default", AzureMaps = "https://atlas.microsoft.com/.default", AzureSearch = "https://search.azure.com/", } +const defaultAzureCredentialFactory: AzureCredentialFactory = () => + new DefaultAzureCredential(); +let azureCredentialFactory = defaultAzureCredentialFactory; + +/** + * Select the credential factory used by identity-authenticated Azure clients. + * Hosts with a validated runtime profile may inject a narrower credential; + * all other callers retain the normal DefaultAzureCredential chain. + */ +export function setAzureCredentialFactory( + factory?: AzureCredentialFactory, +): void { + azureCredentialFactory = factory ?? defaultAzureCredentialFactory; +} + export function createAzureTokenProvider( scope: AzureTokenScopes, expirationBufferMs: number = 5 * 60 * 1000, // 5 minute buffer on renewal ): AuthTokenProvider { - const credential = new DefaultAzureCredential(); + let credential: TokenCredential | undefined; let accessToken: AccessToken | undefined; let refreshPromise: Promise> | undefined; return { @@ -37,10 +58,14 @@ export function createAzureTokenProvider( async function refreshToken(): Promise> { try { + credential ??= createDefaultCredential(); const tokenResponse = await credential.getToken(scope); + if (tokenResponse === null) { + return error("Azure credential returned no access token."); + } tokenResponse.expiresOnTimestamp -= expirationBufferMs; accessToken = tokenResponse; - return success(accessToken.token); + return success(tokenResponse.token); } catch (e: any) { return error(e.toString()); } @@ -73,6 +98,6 @@ export function createAzureTokenProvider( } } -export function createDefaultCredential(): DefaultAzureCredential { - return new DefaultAzureCredential(); +export function createDefaultCredential(): TokenCredential { + return azureCredentialFactory(); } diff --git a/ts/packages/aiclient/test/auth.spec.ts b/ts/packages/aiclient/test/auth.spec.ts new file mode 100644 index 0000000000..d37bb5b24b --- /dev/null +++ b/ts/packages/aiclient/test/auth.spec.ts @@ -0,0 +1,32 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { + DefaultAzureCredential, + ManagedIdentityCredential, +} from "@azure/identity"; +import { + createDefaultCredential, + setAzureCredentialFactory, +} from "../src/auth.js"; + +describe("createDefaultCredential", () => { + afterEach(() => { + setAzureCredentialFactory(); + }); + + test("uses the normal Azure credential chain by default", () => { + expect(createDefaultCredential()).toBeInstanceOf( + DefaultAzureCredential, + ); + }); + + test("uses an explicitly injected hosted credential factory", () => { + const credential = new ManagedIdentityCredential( + "00000000-0000-0000-0000-000000000001", + ); + setAzureCredentialFactory(() => credential); + + expect(createDefaultCredential()).toBe(credential); + }); +}); diff --git a/ts/packages/api/README.md b/ts/packages/api/README.md index b6c640a076..7fae2af501 100644 --- a/ts/packages/api/README.md +++ b/ts/packages/api/README.md @@ -46,7 +46,74 @@ browser, or drop GET support locally if you need to close this off entirely. ### Docker Image -It is possible to use the [docker image](../../Dockerfile) to host TypeAgent either locally or in a cloud hosted environment such as [Azure App Service](https://learn.microsoft.com/en-us/azure/app-service/quickstart-custom-container?tabs=dotnet&pivots=container-linux-vscode). The image sets `TYPEAGENT_API_HOST=0.0.0.0` because the published port is the point there and the container is the isolation boundary. Set `TYPEAGENT_API_ALLOWED_ORIGINS` to the public origin the chat view is served from so browser clients pass the Origin gate. +It is possible to use the [docker image](../../Dockerfile) to host TypeAgent +in a cloud environment such as +[Azure App Service](https://learn.microsoft.com/en-us/azure/app-service/quickstart-custom-container?tabs=dotnet&pivots=container-linux-vscode). +The production image runs as the non-root `typeagent` account, contains no +Azure CLI, and fetches one dedicated routing secret directly with Azure SDK +managed identity. It does not run `getKeys`, load the shared +`typeagent-config` secret, or create `config.local.yaml`. + +Set `TYPEAGENT_API_CONFIG_VAULT` to the vault containing the dedicated +`typeagent-api-hosted-config` secret. The secret must contain only the hosted +profile below; static API keys and unrelated agent credentials are rejected. +Vault names must be Azure Key Vault resource names, and model endpoints must +use the default HTTPS port on an Azure OpenAI or Azure AI Services hostname +(`*.openai.azure.com`, `*.cognitiveservices.azure.com`, or +`*.services.ai.azure.com`). `embedding` is optional, but `gpt_6_luna` is +required: + +```yaml +profile: hosted-azure-managed-identity-v1 +modelProvider: azure +azureOpenAI: + defaultAuth: identity + maxConcurrency: 4 + maxTimeoutMs: 120000 + maxRetryAttempts: 3 + responseFormat: true + deployments: + gpt_6_luna: + endpoints: + - endpoint: https://example-eastus.openai.azure.com/openai/deployments/gpt-6-luna/chat/completions?api-version=2025-01-01-preview + region: eastus + mode: PAYG + embedding: + endpoints: + - endpoint: https://example-eastus.openai.azure.com/openai/deployments/text-embedding-3-small/embeddings?api-version=2024-02-01 + region: eastus + mode: PAYG +``` + +The container identity needs read access to that one secret and data-plane +access to the configured Azure OpenAI deployments. Use a dedicated vault when +possible; otherwise scope the role assignment to the secret resource rather +than granting access to the broad shared configuration vault. For a +user-assigned identity, set `TYPEAGENT_API_MANAGED_IDENTITY_CLIENT_ID`; do not +inject an Azure client secret or an Azure CLI cache. + +The hosted API registers only `chat`, `greeting`, `list`, `settings`, and +`timer`. It does not advertise the broader desktop, browser, media, external +service, installed-agent, or MCP capabilities that require credentials or +models outside this least-privilege profile. This list is enforced separately +as a security policy ratchet, so editing the bundled hosted config alone cannot +broaden the production agent surface. + +The image sets `TYPEAGENT_API_HOST=0.0.0.0` because the published port is the +point there and the container is the isolation boundary. Set +`TYPEAGENT_API_ALLOWED_ORIGINS` to the public origin the chat view is served +from so browser clients pass the Origin gate. + +Run the offline container smoke harness with: + +```bash +node tools/scripts/testApiContainer.mjs +``` + +It builds the production and smoke targets, uses only local mock +configuration and model responses, verifies the runtime account and +permissions, and checks that missing required configuration exits before the +server listens. ### Trademarks diff --git a/ts/packages/api/container/smoke.mjs b/ts/packages/api/container/smoke.mjs new file mode 100644 index 0000000000..1357d3ea15 --- /dev/null +++ b/ts/packages/api/container/smoke.mjs @@ -0,0 +1,253 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import assert from "node:assert/strict"; +import fs from "node:fs"; +import http from "node:http"; +import os from "node:os"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; +import { once } from "node:events"; +import WebSocket from "ws"; +import { + createDispatcher, + DefaultIndexingServiceRegistry, +} from "agent-dispatcher"; +import { + HOSTED_API_PROFILE, + HOSTED_API_SECRET_NAME, + loadHostedApiRuntimeConfig, +} from "../dist/containerBootstrap.js"; +import { startAfterBootstrap } from "../dist/startup.js"; +import { TypeAgentAPIWebServer } from "../dist/webServer.js"; +import { TypeAgentAPIWebSocketServer } from "../dist/webSocketServer.js"; +import { + HOSTED_API_AGENT_SECURITY_ALLOWLIST, + loadHostedApiAgentProviders, + validateHostedApiAgentCapabilities, +} from "../dist/hostedAgentProfile.js"; +import { + azureApiSettingsFromConfig, + openai, + setRuntimeConfig, +} from "@typeagent/aiclient"; + +const sentinels = [ + "graph-secret-sentinel", + "cosmosdb-connection-string-sentinel", + "speech-key-sentinel", + "unrelated-api-key-sentinel", +]; + +assert.notEqual(process.getuid?.(), 0, "container must not run as root"); +assert.equal( + fs.existsSync("/root/.azure"), + false, + "root Azure CLI state must not exist", +); +assert.equal( + fs.existsSync(path.join(process.env.HOME, ".azure")), + false, + "runtime Azure CLI state must not exist", +); +assert.notEqual( + spawnSync("sh", ["-c", "command -v az"], { stdio: "ignore" }).status, + 0, + "Azure CLI must not be installed", +); + +const mockServer = http.createServer((request, response) => { + assert.equal(request.headers.authorization, "Bearer smoke-token"); + response.writeHead(200, { "content-type": "application/json" }); + response.end( + JSON.stringify({ + choices: [ + { + message: { content: "hosted-agent-ok" }, + finish_reason: "stop", + }, + ], + }), + ); +}); +mockServer.listen(0, "127.0.0.1"); +await once(mockServer, "listening"); +const mockAddress = mockServer.address(); +assert(mockAddress && typeof mockAddress === "object"); +const endpoint = + `http://127.0.0.1:${mockAddress.port}/openai/deployments/` + + "gpt-6-luna/chat/completions?api-version=2025-01-01-preview"; + +const dedicatedTree = { + profile: HOSTED_API_PROFILE, + modelProvider: "azure", + azureOpenAI: { + defaultAuth: "identity", + deployments: { + gpt_6_luna: { + endpoints: [ + { + endpoint, + region: "eastus", + mode: "PAYG", + }, + ], + }, + }, + }, +}; +const vault = { + [HOSTED_API_SECRET_NAME]: JSON.stringify(dedicatedTree), + "typeagent-config": JSON.stringify({ + msGraph: { clientSecret: sentinels[0] }, + storage: { + database: { + cosmosDbConnectionString: sentinels[1], + }, + }, + speech: { apiKey: sentinels[2] }, + openAI: { apiKey: sentinels[3] }, + }), +}; +const requests = []; +const runtimeConfig = await loadHostedApiRuntimeConfig({ + fetcher: async (vaultName, secretName) => { + requests.push(`${vaultName}/${secretName}`); + return vault[secretName] ?? null; + }, + endpointValidator: (url, fieldPath) => { + assert.equal(fieldPath.includes("gpt_6_luna"), true); + assert.equal(url.protocol, "http:"); + assert.equal(url.hostname, "127.0.0.1"); + }, +}); +assert.deepEqual(requests, [ + `${process.env.TYPEAGENT_API_CONFIG_VAULT}/${HOSTED_API_SECRET_NAME}`, +]); +setRuntimeConfig(runtimeConfig); +validateHostedApiAgentCapabilities(runtimeConfig); +const hostedProviders = await loadHostedApiAgentProviders(); +const hostedAgentNames = hostedProviders + .flatMap((provider) => provider.getAppAgentNames()) + .sort(); +assert.deepEqual( + hostedAgentNames, + [...HOSTED_API_AGENT_SECURITY_ALLOWLIST].sort(), +); +for (const unsupported of [ + "browser", + "discord", + "image", + "mcpfilesystem", + "photo", + "video", +]) { + assert.equal(hostedAgentNames.includes(unsupported), false); +} +for (const supported of ["chat", "list", "timer"]) { + assert.equal(hostedAgentNames.includes(supported), true); +} +const hostedDispatcher = await createDispatcher("api-container-smoke", { + appAgentProviders: hostedProviders, + agents: hostedAgentNames, + indexingServiceRegistry: new DefaultIndexingServiceRegistry(), +}); +const registeredAgentNames = (await hostedDispatcher.getStatus()).agents + .map((agent) => agent.name) + .sort(); +const registeredAppAgentNames = registeredAgentNames.filter( + (name) => + name !== "dispatcher" && + !name.startsWith("dispatcher.") && + name !== "system" && + !name.startsWith("system."), +); +assert.deepEqual(registeredAppAgentNames, hostedAgentNames); +await hostedDispatcher.close(); + +const settings = azureApiSettingsFromConfig( + runtimeConfig, + openai.ModelType.Chat, + "gpt_6_luna", +); +settings.tokenProvider = { + getAccessToken: async () => ({ success: true, data: "smoke-token" }), + refreshToken: async () => ({ success: true, data: "smoke-token" }), +}; +const completion = await openai.createChatModel(settings).complete("smoke"); +assert.deepEqual(completion, { success: true, data: "hosted-agent-ok" }); + +const webRoot = fs.mkdtempSync(path.join(os.tmpdir(), "api-container-smoke-")); +fs.writeFileSync(path.join(webRoot, "index.html"), "

container-ok

"); +const webServer = new TypeAgentAPIWebServer( + { + wwwroot: webRoot, + port: 0, + broadcast: false, + blobBackupEnabled: false, + }, + () => ({ ok: true }), +); +const socketServer = new TypeAgentAPIWebSocketServer( + webServer.server, + (socket) => socket.send("websocket-ok"), + (exitCode) => { + throw new Error(`WebSocket server requested exit ${exitCode}`); + }, +); +webServer.server.listen(0, "127.0.0.1"); +await once(webServer.server, "listening"); +const webAddress = webServer.server.address(); +assert(webAddress && typeof webAddress === "object"); + +const staticResponse = await fetch(`http://127.0.0.1:${webAddress.port}/`); +assert.equal(staticResponse.status, 200); +assert.match(await staticResponse.text(), /container-ok/); + +const socket = new WebSocket(`ws://127.0.0.1:${webAddress.port}/`); +const [socketMessage] = await once(socket, "message"); +assert.equal(socketMessage.toString(), "websocket-ok"); +socket.close(); +socketServer.stop(); +await new Promise((resolve) => webServer.server.close(resolve)); +fs.rmSync(webRoot, { recursive: true, force: true }); +mockServer.close(); +await once(mockServer, "close"); + +let listenerStarted = false; +const missingEnv = { + HOME: process.env.HOME, + TYPEAGENT_USER_DATA_DIR: process.env.TYPEAGENT_USER_DATA_DIR, + TYPEAGENT_API_BOOTSTRAP: HOSTED_API_PROFILE, + TYPEAGENT_API_CONFIG_VAULT: "missing-vault", + TYPEAGENT_API_CONFIG_SECRET: HOSTED_API_SECRET_NAME, +}; +await assert.rejects( + startAfterBootstrap( + () => + loadHostedApiRuntimeConfig({ + env: missingEnv, + fetcher: async () => null, + }), + async () => { + listenerStarted = true; + }, + ), + /Dedicated hosted API configuration secret is missing or empty/, +); +assert.equal(listenerStarted, false); + +const runtimeStats = fs.statSync(process.env.TYPEAGENT_USER_DATA_DIR); +assert.equal(runtimeStats.mode & 0o777, 0o700); +assert.equal(runtimeStats.uid, process.getuid()); +const processEnvironment = fs.readFileSync("/proc/1/environ", "utf8"); +for (const sentinel of sentinels) { + assert.equal(processEnvironment.includes(sentinel), false); +} +assert.deepEqual( + fs.readdirSync(process.env.TYPEAGENT_USER_DATA_DIR), + [], + "bootstrap must not persist configuration", +); + +process.stdout.write("API container smoke passed.\n"); diff --git a/ts/packages/api/container/start.sh b/ts/packages/api/container/start.sh new file mode 100644 index 0000000000..849023bd08 --- /dev/null +++ b/ts/packages/api/container/start.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +set -euo pipefail + +if [[ "$(id -u)" -eq 0 ]]; then + echo "[agent-api] Refusing to run as root." >&2 + exit 70 +fi + +if [[ -d "${HOME:?HOME must be set}/.azure" ]]; then + echo "[agent-api] Refusing to start with persistent Azure CLI state." >&2 + exit 78 +fi + +exec node ./dist/index.js diff --git a/ts/packages/api/package.json b/ts/packages/api/package.json index 23fdbc6094..68a7827c6d 100644 --- a/ts/packages/api/package.json +++ b/ts/packages/api/package.json @@ -13,6 +13,11 @@ "author": "Microsoft", "type": "module", "main": "./dist/index.js", + "files": [ + "dist", + "!dist/test", + "data" + ], "scripts": { "build": "npm run tsc", "clean": "rimraf --glob dist *.tsbuildinfo *.done.build.log", diff --git a/ts/packages/api/src/containerBootstrap.ts b/ts/packages/api/src/containerBootstrap.ts new file mode 100644 index 0000000000..ec7ff45b95 --- /dev/null +++ b/ts/packages/api/src/containerBootstrap.ts @@ -0,0 +1,873 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import fs from "node:fs"; +import path from "node:path"; +import { + ManagedIdentityCredential, + type TokenCredential, +} from "@azure/identity"; +import { + applyToProcessEnv, + buildConfig, + fetchKeyVaultConfig, + flatten, + loadConfig, + SIMPLE_CONFIG_MAPPING_LIST, + type Config, + type ConfigTree, + type FlatEnv, + type KeyVaultFetcher, +} from "@typeagent/config"; + +export const HOSTED_API_PROFILE = "hosted-azure-managed-identity-v1"; +export const HOSTED_API_SECRET_NAME = "typeagent-api-hosted-config"; + +const HOSTED_DEPLOYMENTS = new Set(["gpt_6_luna", "embedding"]); +const REQUIRED_DEPLOYMENT = "gpt_6_luna"; +const LEGACY_CONFIG_FILES = [".env", "config.local.yaml"]; +const AZURE_KEY_VAULT_NAME_PATTERN = /^[a-z][a-z0-9-]{1,22}[a-z0-9]$/; +const AZURE_KEY_VAULT_SECRET_NAME_PATTERN = /^[a-z0-9-]{1,127}$/i; +const AZURE_AI_HOSTNAME_PATTERN = + /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.(?:openai\.azure\.com|cognitiveservices\.azure\.com|services\.ai\.azure\.com)$/; + +const SCRUBBED_ENV_PREFIXES = [ + "AWS_", + "AZURE_AI_SEARCH_", + "AZURE_COSMOS_", + "AZURE_MAPS_", + "AZURE_OPENAI_", + "AZURE_SPEECH_", + "AZURE_STORAGE_", + "BING_", + "COSMOS_", + "COSMOSDB_", + "GOOGLE_", + "MONGODB_", + "MSGRAPH_", + "MS_GRAPH_", + "OPENAI_", + "SPEECH_", + "SPOTIFY_", + "TYPEAGENT_CONFIG_", + "TYPEAGENT_EMBEDDING_", + "WIKIPEDIA_", +]; + +const APP_SERVICE_CONNECTION_PREFIXES = [ + "CUSTOMCONNSTR_", + "MYSQLCONNSTR_", + "POSTGRESQLCONNSTR_", + "SQLAZURECONNSTR_", + "SQLCONNSTR_", +]; + +const STATIC_CREDENTIAL_NAMES = new Set([ + "AZURE_CLIENT_CERTIFICATE_PASSWORD", + "AZURE_CLIENT_CERTIFICATE_PATH", + "AZURE_CLIENT_SECRET", + "AZURE_FEDERATED_TOKEN_FILE", + "AZURE_PASSWORD", + "AZURE_USERNAME", +]); + +const SAFE_HOSTED_ENVIRONMENT_NAMES = new Set([ + "IDENTITY_ENDPOINT", + "IDENTITY_HEADER", + "IDENTITY_SERVER_THUMBPRINT", + "IMDS_ENDPOINT", + "MSI_ENDPOINT", + "MSI_SECRET", + "TYPEAGENT_API_BOOTSTRAP", + "TYPEAGENT_API_CONFIG_SECRET", + "TYPEAGENT_API_CONFIG_VAULT", + "TYPEAGENT_API_MANAGED_IDENTITY_CLIENT_ID", +]); + +const REPOSITORY_CREDENTIAL_ENVIRONMENT_NAMES = new Set( + SIMPLE_CONFIG_MAPPING_LIST.filter( + (mapping) => + mapping.valueKind === "auth" || + hasCredentialName(mapping.envVar) || + hasCredentialName(mapping.configPath), + ).map((mapping) => mapping.envVar), +); + +type HostedConfigFieldPolicy = + | "absent" + | "azure-model-provider" + | "empty-map" + | "empty-object" + | "identity-azure-openai"; + +/** + * Every root Config field must have an explicit hosted-profile decision. + * Adding a Config field fails compilation until it is classified here. + */ +export const HOSTED_API_CONFIG_FIELD_POLICY = { + embeddingModels: "absent", + defaultEmbeddingModel: "absent", + modelRequestConstraints: "absent", + azureOpenAI: "identity-azure-openai", + openAI: "absent", + speech: "absent", + maps: "absent", + msGraph: "absent", + googleCalendar: "absent", + spotify: "absent", + wikipedia: "absent", + storage: "empty-object", + vault: "absent", + azureFoundry: "absent", + azureAISearch: "absent", + reasoning: "absent", + copilot: "absent", + embedding: "absent", + modelProvider: "azure-model-provider", + extra: "empty-map", +} as const satisfies Record; + +export type HostedEndpointValidator = (url: URL, fieldPath: string) => void; +export type ApiAzureCredentialFactory = () => TokenCredential; + +export interface ApiRuntime { + readonly config: Config; + readonly azureCredentialFactory?: ApiAzureCredentialFactory; +} + +export interface HostedApiBootstrapOptions { + readonly env?: NodeJS.ProcessEnv; + readonly fetcher?: KeyVaultFetcher; + readonly credential?: TokenCredential; + readonly log?: (message: string) => void; + readonly getUid?: () => number | undefined; + readonly platform?: NodeJS.Platform; + /** @internal Test-only endpoint policy for local mocked services. */ + readonly endpointValidator?: HostedEndpointValidator; +} + +/** + * Load the API runtime configuration. Local development preserves the existing + * layered loader. The production image selects the hosted profile explicitly, + * which takes the dedicated fail-closed path below. + */ +export async function loadApiRuntimeConfig( + options: HostedApiBootstrapOptions = {}, +): Promise { + return (await loadApiRuntime(options)).config; +} + +export async function loadApiRuntime( + options: HostedApiBootstrapOptions = {}, +): Promise { + const env = options.env ?? process.env; + const bootstrap = env.TYPEAGENT_API_BOOTSTRAP?.trim(); + if (bootstrap === undefined || bootstrap === "") { + if (env !== process.env) { + throw new Error( + "A custom environment requires an explicit API bootstrap profile.", + ); + } + await loadConfig({ keyVault: {}, strict: false }); + return { + config: buildConfig(processEnvironmentToFlat(process.env)), + }; + } + if (bootstrap !== HOSTED_API_PROFILE) { + throw new Error( + `Unsupported TYPEAGENT_API_BOOTSTRAP profile '${bootstrap}'.`, + ); + } + return loadHostedApiRuntime(options); +} + +/** + * Fetch and install the dedicated hosted API routing profile. This path never + * reads the shared TypeAgent secret, config.local.yaml, or the legacy .env. + */ +export async function loadHostedApiRuntimeConfig( + options: HostedApiBootstrapOptions = {}, +): Promise { + return (await loadHostedApiRuntime(options)).config; +} + +async function loadHostedApiRuntime( + options: HostedApiBootstrapOptions, +): Promise { + const env = options.env ?? process.env; + prepareRuntime(env, options); + rejectStaticCredentialEnvironment(env); + + const vaultName = validateKeyVaultName( + requiredEnvironmentValue(env, "TYPEAGENT_API_CONFIG_VAULT"), + ); + const secretName = validateKeyVaultSecretName( + env.TYPEAGENT_API_CONFIG_SECRET?.trim() || HOSTED_API_SECRET_NAME, + ); + const clientId = env.TYPEAGENT_API_MANAGED_IDENTITY_CLIENT_ID?.trim(); + const credential = + options.credential ?? + (clientId + ? new ManagedIdentityCredential(clientId) + : new ManagedIdentityCredential()); + + let tree: ConfigTree | null; + try { + tree = await fetchKeyVaultConfig({ + vaultName, + secretName, + credential, + ...(options.fetcher !== undefined + ? { fetcher: options.fetcher } + : {}), + failOnError: true, + }); + } catch { + throw new Error( + "Failed to fetch or validate the dedicated hosted API configuration secret.", + ); + } + if (tree === null) { + throw new Error( + "Dedicated hosted API configuration secret is missing or empty.", + ); + } + + rejectCredentialFields(tree); + const selected = selectHostedApiConfig( + tree, + options.endpointValidator ?? validateProductionEndpoint, + ); + const flat = flatten(selected); + const config = buildConfig(flat); + validateHostedApiRuntimeConfig(config); + + scrubLegacyConfiguration(env); + applyToProcessEnv(config, { target: env, overwrite: true }); + + options.log?.( + `Loaded hosted API profile '${HOSTED_API_PROFILE}' with managed identity.`, + ); + return { + config, + azureCredentialFactory: () => credential, + }; +} + +function prepareRuntime( + env: NodeJS.ProcessEnv, + options: HostedApiBootstrapOptions, +): void { + const getUid = options.getUid ?? (() => process.getuid?.()); + const uid = getUid(); + if (uid === undefined) { + throw new Error( + "Hosted API startup requires operating-system account inspection.", + ); + } + if (uid === 0) { + throw new Error("Hosted API startup refuses to run as root."); + } + + const home = requiredEnvironmentValue(env, "HOME"); + if (!path.isAbsolute(home)) { + throw new Error("HOME must be an absolute path for hosted startup."); + } + if (fs.existsSync(path.join(home, ".azure"))) { + throw new Error( + "Hosted API startup refuses persistent Azure CLI credential state.", + ); + } + + const userDataDir = requiredEnvironmentValue( + env, + "TYPEAGENT_USER_DATA_DIR", + ); + if ( + path.resolve(userDataDir) !== + path.resolve(path.join(home, ".typeagent")) + ) { + throw new Error( + "TYPEAGENT_USER_DATA_DIR must be the dedicated runtime directory under HOME.", + ); + } + + fs.mkdirSync(userDataDir, { recursive: true, mode: 0o700 }); + const stats = fs.lstatSync(userDataDir); + if (!stats.isDirectory() || stats.isSymbolicLink()) { + throw new Error("TYPEAGENT_USER_DATA_DIR must be a real directory."); + } + fs.chmodSync(userDataDir, 0o700); + const securedStats = fs.statSync(userDataDir); + const platform = options.platform ?? process.platform; + if (platform !== "win32") { + if ((securedStats.mode & 0o777) !== 0o700) { + throw new Error("TYPEAGENT_USER_DATA_DIR must have mode 0700."); + } + if (securedStats.uid !== uid) { + throw new Error( + "TYPEAGENT_USER_DATA_DIR must be owned by the runtime account.", + ); + } + } + for (const fileName of LEGACY_CONFIG_FILES) { + if (fs.existsSync(path.join(userDataDir, fileName))) { + throw new Error( + `Hosted API startup refuses stale ${fileName} configuration.`, + ); + } + } +} + +function selectHostedApiConfig( + tree: ConfigTree, + endpointValidator: HostedEndpointValidator, +): ConfigTree { + const root = expectRecord(tree, ""); + rejectUnknownFields( + root, + new Set(["profile", "modelProvider", "azureOpenAI"]), + "", + ); + if (expectString(root.profile, "profile") !== HOSTED_API_PROFILE) { + throw new Error(`profile must be '${HOSTED_API_PROFILE}'.`); + } + if (expectString(root.modelProvider, "modelProvider") !== "azure") { + throw new Error("modelProvider must be 'azure'."); + } + + const azure = expectRecord(root.azureOpenAI, "azureOpenAI"); + rejectUnknownFields( + azure, + new Set([ + "defaultAuth", + "maxConcurrency", + "maxTimeoutMs", + "maxRetryAttempts", + "responseFormat", + "maxPromptChars", + "deployments", + ]), + "azureOpenAI", + ); + if ( + expectString(azure.defaultAuth, "azureOpenAI.defaultAuth") !== + "identity" + ) { + throw new Error("azureOpenAI.defaultAuth must be 'identity'."); + } + + const deployments = expectRecord( + azure.deployments, + "azureOpenAI.deployments", + ); + if ( + !Object.prototype.hasOwnProperty.call(deployments, REQUIRED_DEPLOYMENT) + ) { + throw new Error( + `azureOpenAI.deployments.${REQUIRED_DEPLOYMENT} is required.`, + ); + } + + const selectedDeployments: ConfigTree = {}; + for (const [name, value] of Object.entries(deployments)) { + if (!HOSTED_DEPLOYMENTS.has(name)) { + throw new Error(`Unsupported hosted API deployment '${name}'.`); + } + selectedDeployments[name] = selectDeployment( + value, + `azureOpenAI.deployments.${name}`, + endpointValidator, + ); + } + + const selectedAzure: ConfigTree = { + defaultAuth: "identity", + deployments: selectedDeployments, + }; + copyOptionalPositiveInteger( + azure, + selectedAzure, + "maxConcurrency", + "azureOpenAI", + ); + copyOptionalPositiveInteger( + azure, + selectedAzure, + "maxTimeoutMs", + "azureOpenAI", + ); + copyOptionalNonNegativeInteger( + azure, + selectedAzure, + "maxRetryAttempts", + "azureOpenAI", + ); + copyOptionalPositiveInteger( + azure, + selectedAzure, + "maxPromptChars", + "azureOpenAI", + ); + if (azure.responseFormat !== undefined) { + selectedAzure.responseFormat = expectBoolean( + azure.responseFormat, + "azureOpenAI.responseFormat", + ); + } + + return { + modelProvider: "azure", + azureOpenAI: selectedAzure, + }; +} + +function selectDeployment( + value: unknown, + fieldPath: string, + endpointValidator: HostedEndpointValidator, +): ConfigTree { + const deployment = expectRecord(value, fieldPath); + rejectUnknownFields( + deployment, + new Set(["defaultCapacity", "endpoints"]), + fieldPath, + ); + if ( + !Array.isArray(deployment.endpoints) || + deployment.endpoints.length < 1 + ) { + throw new Error(`${fieldPath}.endpoints must be a non-empty array.`); + } + + const selected: ConfigTree = { + endpoints: deployment.endpoints.map((endpoint, index) => + selectEndpoint( + endpoint, + `${fieldPath}.endpoints.${index}`, + endpointValidator, + ), + ), + }; + if (deployment.defaultCapacity !== undefined) { + selected.defaultCapacity = expectPositiveInteger( + deployment.defaultCapacity, + `${fieldPath}.defaultCapacity`, + ); + } + return selected; +} + +function selectEndpoint( + value: unknown, + fieldPath: string, + endpointValidator: HostedEndpointValidator, +): ConfigTree { + const endpoint = expectRecord(value, fieldPath); + rejectUnknownFields( + endpoint, + new Set([ + "endpoint", + "region", + "mode", + "priority", + "capacity", + "wireApi", + "auth", + ]), + fieldPath, + ); + + const endpointUrl = expectString( + endpoint.endpoint, + `${fieldPath}.endpoint`, + ); + validateEndpointUrl(endpointUrl, fieldPath, endpointValidator); + const selected: ConfigTree = { + endpoint: endpointUrl, + region: expectString(endpoint.region, `${fieldPath}.region`), + }; + + if (endpoint.auth !== undefined) { + if (expectString(endpoint.auth, `${fieldPath}.auth`) !== "identity") { + throw new Error(`${fieldPath}.auth must be 'identity'.`); + } + selected.auth = "identity"; + } + if (endpoint.mode !== undefined) { + const mode = expectString(endpoint.mode, `${fieldPath}.mode`); + if (mode !== "PAYG" && mode !== "PTU") { + throw new Error(`${fieldPath}.mode must be PAYG or PTU.`); + } + selected.mode = mode; + } + if (endpoint.priority !== undefined) { + selected.priority = expectPositiveInteger( + endpoint.priority, + `${fieldPath}.priority`, + ); + } + if (endpoint.capacity !== undefined) { + selected.capacity = + endpoint.capacity === null + ? null + : expectPositiveInteger( + endpoint.capacity, + `${fieldPath}.capacity`, + ); + } + if (endpoint.wireApi !== undefined) { + const wireApi = expectString(endpoint.wireApi, `${fieldPath}.wireApi`); + if ( + wireApi !== "chat_completions" && + wireApi !== "responses" && + wireApi !== "messages" + ) { + throw new Error( + `${fieldPath}.wireApi must be chat_completions, responses, or messages.`, + ); + } + selected.wireApi = wireApi; + } + return selected; +} + +function validateEndpointUrl( + value: string, + fieldPath: string, + endpointValidator: HostedEndpointValidator, +): void { + let url: URL; + try { + url = new URL(value); + } catch { + throw new Error(`${fieldPath}.endpoint must be an absolute URL.`); + } + if (url.username !== "" || url.password !== "") { + throw new Error(`${fieldPath}.endpoint must not contain credentials.`); + } + for (const key of url.searchParams.keys()) { + if (/^(api[-_]?key|key|sig|secret|token)$/i.test(key)) { + throw new Error( + `${fieldPath}.endpoint must not contain credential query parameters.`, + ); + } + } + endpointValidator(url, fieldPath); +} + +function validateProductionEndpoint(url: URL, fieldPath: string): void { + if (url.protocol !== "https:") { + throw new Error(`${fieldPath}.endpoint must use HTTPS.`); + } + if (url.port !== "") { + throw new Error( + `${fieldPath}.endpoint must use the default HTTPS port.`, + ); + } + if (!AZURE_AI_HOSTNAME_PATTERN.test(url.hostname)) { + throw new Error( + `${fieldPath}.endpoint must use a supported Azure OpenAI or Azure AI Services hostname.`, + ); + } +} + +export function validateHostedApiRuntimeConfig(config: Config): void { + for (const field of Object.keys(HOSTED_API_CONFIG_FIELD_POLICY) as Array< + keyof Config + >) { + const policy = HOSTED_API_CONFIG_FIELD_POLICY[field]; + const value = config[field]; + switch (policy) { + case "absent": + if (value !== undefined) { + throw new Error( + `Hosted API runtime field '${field}' must be absent.`, + ); + } + break; + case "azure-model-provider": + if (value !== "azure") { + throw new Error( + "Hosted API runtime provider validation failed.", + ); + } + break; + case "empty-map": + if (!(value instanceof Map) || value.size !== 0) { + throw new Error( + `Hosted API runtime field '${field}' must be an empty map.`, + ); + } + break; + case "empty-object": + if ( + value === null || + typeof value !== "object" || + Array.isArray(value) || + Object.keys(value).length !== 0 + ) { + throw new Error( + `Hosted API runtime field '${field}' must be an empty object.`, + ); + } + break; + case "identity-azure-openai": + validateHostedAzureOpenAI(config); + break; + } + } +} + +function validateHostedAzureOpenAI(config: Config): void { + if (config.azureOpenAI.defaultAuth.kind !== "identity") { + throw new Error("Hosted API runtime auth validation failed."); + } + const deployment = config.azureOpenAI.deployments.get(REQUIRED_DEPLOYMENT); + if (deployment === undefined || deployment.endpoints.length === 0) { + throw new Error("Hosted API runtime routing validation failed."); + } + for (const endpoint of config.azureOpenAI.deployments.values()) { + if ( + endpoint.endpoints.some((entry) => entry.auth.kind !== "identity") + ) { + throw new Error("Hosted API endpoint auth validation failed."); + } + } + for (const deploymentName of config.azureOpenAI.deployments.keys()) { + if (!HOSTED_DEPLOYMENTS.has(deploymentName)) { + throw new Error( + `Hosted API runtime contains unsupported deployment '${deploymentName}'.`, + ); + } + } +} + +function rejectStaticCredentialEnvironment(env: NodeJS.ProcessEnv): void { + const rejected: string[] = []; + for (const [key, value] of Object.entries(env)) { + if (value === undefined || value.trim() === "") { + continue; + } + if (isSafeHostedEnvironmentVariable(key, value)) { + continue; + } + if (isCredentialVariableName(key)) { + delete env[key]; + rejected.push(key); + } + } + if (rejected.length !== 0) { + throw new Error( + `Static credential environment variable '${rejected[0]}' is not allowed.`, + ); + } +} + +function isSafeHostedEnvironmentVariable(key: string, value: string): boolean { + return ( + SAFE_HOSTED_ENVIRONMENT_NAMES.has(key) || + (key === "AZURE_OPENAI_API_KEY" && value.toLowerCase() === "identity") + ); +} + +function scrubLegacyConfiguration(env: NodeJS.ProcessEnv): void { + for (const key of Object.keys(env)) { + const value = env[key] ?? ""; + if (isSafeHostedEnvironmentVariable(key, value)) { + continue; + } + if ( + SCRUBBED_ENV_PREFIXES.some((prefix) => key.startsWith(prefix)) || + isCredentialVariableName(key) || + key === "TYPEAGENT_DEFAULT_EMBEDDING_MODEL" || + key === "TYPEAGENT_DOTENV" || + key === "TYPEAGENT_MODEL_PROVIDER" || + key === "TYPEAGENT_SHAREDVAULT" || + key === "AZURE_CLIENT_ID" || + key === "AZURE_TENANT_ID" + ) { + delete env[key]; + } + } +} + +function isCredentialVariableName(key: string): boolean { + return ( + STATIC_CREDENTIAL_NAMES.has(key) || + REPOSITORY_CREDENTIAL_ENVIRONMENT_NAMES.has(key) || + APP_SERVICE_CONNECTION_PREFIXES.some((prefix) => + key.startsWith(prefix), + ) || + hasCredentialName(key) + ); +} + +function hasCredentialName(name: string): boolean { + const normalized = name + .replace(/([a-z0-9])([A-Z])/g, "$1_$2") + .replace(/[^a-z0-9]+/gi, "_") + .toUpperCase(); + return /(?:^|_)(?:API_KEY|ACCESS_KEY|ACCOUNT_KEY|PRIVATE_KEY|KEY|SECRET|PASSWORD|PASSWD|TOKEN|CREDENTIAL|CONNECTION_STRING|SAS)(?:_|$)/.test( + normalized, + ); +} + +function rejectCredentialFields(value: unknown, fieldPath = ""): void { + if (Array.isArray(value)) { + value.forEach((entry, index) => + rejectCredentialFields(entry, `${fieldPath}.${index}`), + ); + return; + } + if (value === null || typeof value !== "object") { + return; + } + for (const [key, child] of Object.entries( + value as Record, + )) { + const pathName = fieldPath === "" ? key : `${fieldPath}.${key}`; + if ( + APP_SERVICE_CONNECTION_PREFIXES.some((prefix) => + key.toUpperCase().startsWith(prefix), + ) || + hasCredentialName(key) + ) { + throw new Error( + `Static credential field '${pathName}' is not allowed.`, + ); + } + rejectCredentialFields(child, pathName); + } +} + +function rejectUnknownFields( + value: Record, + allowed: ReadonlySet, + fieldPath: string, +): void { + for (const key of Object.keys(value)) { + if (allowed.has(key)) { + continue; + } + const pathName = fieldPath === "" ? key : `${fieldPath}.${key}`; + if (hasCredentialName(key)) { + throw new Error( + `Static credential field '${pathName}' is not allowed.`, + ); + } + throw new Error(`Unsupported hosted API field '${pathName}'.`); + } +} + +function validateKeyVaultName(value: string): string { + const normalized = value.toLowerCase(); + if ( + !AZURE_KEY_VAULT_NAME_PATTERN.test(normalized) || + normalized.includes("--") + ) { + throw new Error( + "TYPEAGENT_API_CONFIG_VAULT must be a valid Azure Key Vault resource name.", + ); + } + return normalized; +} + +function validateKeyVaultSecretName(value: string): string { + if (!AZURE_KEY_VAULT_SECRET_NAME_PATTERN.test(value)) { + throw new Error( + "TYPEAGENT_API_CONFIG_SECRET must be a valid Azure Key Vault secret name.", + ); + } + return value; +} + +function requiredEnvironmentValue( + env: NodeJS.ProcessEnv, + name: string, +): string { + const value = env[name]?.trim(); + if (!value) { + throw new Error(`${name} is required for hosted API startup.`); + } + return value; +} + +function expectRecord( + value: unknown, + fieldPath: string, +): Record { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + throw new Error(`${fieldPath} must be an object.`); + } + return value as Record; +} + +function expectString(value: unknown, fieldPath: string): string { + if (typeof value !== "string" || value.trim() === "") { + throw new Error(`${fieldPath} must be a non-empty string.`); + } + return value; +} + +function expectBoolean(value: unknown, fieldPath: string): boolean { + if (typeof value !== "boolean") { + throw new Error(`${fieldPath} must be a boolean.`); + } + return value; +} + +function expectPositiveInteger(value: unknown, fieldPath: string): number { + if ( + typeof value !== "number" || + !Number.isSafeInteger(value) || + value <= 0 + ) { + throw new Error(`${fieldPath} must be a positive integer.`); + } + return value; +} + +function copyOptionalPositiveInteger( + source: Record, + target: ConfigTree, + key: string, + parentPath: string, +): void { + if (source[key] !== undefined) { + target[key] = expectPositiveInteger( + source[key], + `${parentPath}.${key}`, + ); + } +} + +function copyOptionalNonNegativeInteger( + source: Record, + target: ConfigTree, + key: string, + parentPath: string, +): void { + if (source[key] === undefined) { + return; + } + const value = source[key]; + if ( + typeof value !== "number" || + !Number.isSafeInteger(value) || + value < 0 + ) { + throw new Error(`${parentPath}.${key} must be a non-negative integer.`); + } + target[key] = value; +} + +function processEnvironmentToFlat(env: NodeJS.ProcessEnv): FlatEnv { + const flat: FlatEnv = {}; + for (const [key, value] of Object.entries(env)) { + if (value !== undefined) { + flat[key] = value; + } + } + return flat; +} diff --git a/ts/packages/api/src/hostedAgentProfile.ts b/ts/packages/api/src/hostedAgentProfile.ts new file mode 100644 index 0000000000..893e280bbe --- /dev/null +++ b/ts/packages/api/src/hostedAgentProfile.ts @@ -0,0 +1,77 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import type { AppAgentProvider } from "agent-dispatcher"; +import { getBundledAppAgentProvider } from "default-agent-provider"; +import type { Config } from "@typeagent/config"; +import { HOSTED_API_PROFILE } from "./containerBootstrap.js"; + +export type ApiAgentProfile = "default" | "hosted"; + +export const HOSTED_API_AGENT_CONFIG = "hosted"; +/** + * Independent security policy ratchet for the hosted API surface. The bundled + * config must match this list exactly; editing config.hosted.json alone cannot + * enable another agent. + */ +export const HOSTED_API_AGENT_SECURITY_ALLOWLIST = [ + "chat", + "greeting", + "list", + "settings", + "timer", +] as const; + +export function resolveApiAgentProfile( + env: NodeJS.ProcessEnv, +): ApiAgentProfile { + return env.TYPEAGENT_API_BOOTSTRAP?.trim() === HOSTED_API_PROFILE + ? "hosted" + : "default"; +} + +export function validateHostedApiAgentCapabilities(config: Config): void { + const deployment = config.azureOpenAI.deployments.get("gpt_6_luna"); + if ( + config.modelProvider !== "azure" || + config.azureOpenAI.defaultAuth.kind !== "identity" || + deployment === undefined || + deployment.endpoints.length === 0 || + deployment.endpoints.some( + (endpoint) => endpoint.auth.kind !== "identity", + ) + ) { + throw new Error( + "Hosted API agents require an identity-authenticated gpt_6_luna deployment.", + ); + } +} + +export function validateHostedApiAgentSecurityPolicy( + actualNames: readonly string[], +): void { + const actual = [...actualNames].sort(); + const expected = [...HOSTED_API_AGENT_SECURITY_ALLOWLIST].sort(); + if (JSON.stringify(actual) !== JSON.stringify(expected)) { + throw new Error( + `Hosted API agent security policy mismatch: expected ${expected.join(", ")}, got ${actual.join(", ")}.`, + ); + } +} + +export async function loadHostedApiAgentProviders(): Promise< + AppAgentProvider[] +> { + const providers = [getBundledAppAgentProvider(HOSTED_API_AGENT_CONFIG)]; + const actualNames = providers + .flatMap((provider) => provider.getAppAgentNames()) + .sort(); + validateHostedApiAgentSecurityPolicy(actualNames); + + for (const provider of providers) { + for (const agentName of provider.getAppAgentNames()) { + await provider.getAppAgentManifest(agentName); + } + } + return providers; +} diff --git a/ts/packages/api/src/index.ts b/ts/packages/api/src/index.ts index a1c3fa3d71..cbec3e102e 100644 --- a/ts/packages/api/src/index.ts +++ b/ts/packages/api/src/index.ts @@ -2,7 +2,9 @@ // Licensed under the MIT License. import { TypeAgentServer } from "./typeAgentServer.js"; -import { loadConfig } from "@typeagent/config"; +import { loadApiRuntime } from "./containerBootstrap.js"; +import { startAfterBootstrap } from "./startup.js"; +import { resolveApiAgentProfile } from "./hostedAgentProfile.js"; import { otel } from "@typeagent/telemetry"; import registerDebug from "debug"; @@ -39,26 +41,32 @@ process.once("SIGTERM", () => { }); async function main(): Promise { - // Load config from YAML layers + Key Vault (replacing legacy dotenv). - await loadConfig({ keyVault: {}, strict: false }); - const telemetryConfig = otel.resolveTelemetryConfig(); - await otel.initTelemetry({ - config: telemetryConfig, - processName: "api-server", - debugModules: [registerDebug], - debugBridge: { - includedNamespacePrefixes: ["typeagent:", "agent-server:"], - }, - }); - if (shutdownRequested) { - return; - } + await startAfterBootstrap(loadApiRuntime, async (runtime) => { + const telemetryConfig = otel.resolveTelemetryConfig(); + await otel.initTelemetry({ + config: telemetryConfig, + processName: "api-server", + debugModules: [registerDebug], + debugBridge: { + includedNamespacePrefixes: ["typeagent:", "agent-server:"], + }, + }); + if (shutdownRequested) { + return; + } - typeAgentServer = new TypeAgentServer((exitCode) => { - void shutdownHost(exitCode); - }, telemetryConfig.structuredLogs === true); + typeAgentServer = new TypeAgentServer( + (exitCode) => { + void shutdownHost(exitCode); + }, + telemetryConfig.structuredLogs === true, + runtime.config, + resolveApiAgentProfile(process.env), + runtime.azureCredentialFactory, + ); - await typeAgentServer.start(); + await typeAgentServer.start(); + }); } await main().catch(async (error) => { diff --git a/ts/packages/api/src/startup.ts b/ts/packages/api/src/startup.ts new file mode 100644 index 0000000000..5b3765e9df --- /dev/null +++ b/ts/packages/api/src/startup.ts @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * Keep configuration validation ahead of every listener or dispatcher start. + * Tests inject a start callback to verify invalid configuration cannot listen. + */ +export async function startAfterBootstrap( + bootstrap: () => Promise, + start: (runtime: TRuntime) => Promise, +): Promise { + const runtime = await bootstrap(); + return start(runtime); +} diff --git a/ts/packages/api/src/storageProviders/azureStorageProvider.ts b/ts/packages/api/src/storageProviders/azureStorageProvider.ts index 816e33a8ee..40694ada83 100644 --- a/ts/packages/api/src/storageProviders/azureStorageProvider.ts +++ b/ts/packages/api/src/storageProviders/azureStorageProvider.ts @@ -1,9 +1,12 @@ // Copyright (c) Microsoft Corporation and Henry Lucco. // Licensed under the MIT License. -import { DefaultAzureCredential } from "@azure/identity"; import { TypeAgentStorageProvider } from "../storageProvider.js"; -import { getEnvSetting, openai } from "@typeagent/aiclient"; +import { + createDefaultCredential, + getEnvSetting, + openai, +} from "@typeagent/aiclient"; import { env } from "node:process"; import { BlobServiceClient, @@ -40,7 +43,7 @@ export class AzureStorageProvider implements TypeAgentStorageProvider { this.accountURL = `https://${this.storageAccount}.blob.core.windows.net`; this.blobServiceClient = new BlobServiceClient( this.accountURL, - new DefaultAzureCredential(), + createDefaultCredential(), ); } diff --git a/ts/packages/api/src/typeAgentServer.ts b/ts/packages/api/src/typeAgentServer.ts index 9eab7ea9d5..5439b1494f 100644 --- a/ts/packages/api/src/typeAgentServer.ts +++ b/ts/packages/api/src/typeAgentServer.ts @@ -4,9 +4,12 @@ import { getUserDataDir } from "agent-dispatcher/helpers/data"; import { readFileSync } from "node:fs"; import { - initRuntimeConfigFromProcessEnv, + setAzureCredentialFactory, + setRuntimeConfig, warmupCopilotFromConfig, + type AzureCredentialFactory, } from "@typeagent/aiclient"; +import type { Config } from "@typeagent/config"; import { TypeAgentAPIServerConfig, TypeAgentAPIWebServer, @@ -28,6 +31,10 @@ import { AzureStorageProvider } from "./storageProviders/azureStorageProvider.js import { AWSStorageProvider } from "./storageProviders/awsStorageProvider.js"; import { WebDispatcher, createWebDispatcher } from "./webDispatcher.js"; import registerDebug from "debug"; +import { + validateHostedApiAgentCapabilities, + type ApiAgentProfile, +} from "./hostedAgentProfile.js"; const debug = registerDebug("typeagent:webserver:api"); registerDebug.enable("typeagent:webserver:*"); @@ -43,12 +50,15 @@ export class TypeAgentServer { constructor( private readonly requestExit: (exitCode: number) => void, private readonly structuredLogs: boolean, + runtimeConfig: Config, + private readonly agentProfile: ApiAgentProfile = "default", + azureCredentialFactory?: AzureCredentialFactory, ) { - // Build typed runtime Config from process.env (already populated - // by loadConfig in the entry point) so aiclient consumers can - // use the typed accessor; legacy callers still see the same - // values through process.env directly. - initRuntimeConfigFromProcessEnv(); + if (agentProfile === "hosted") { + validateHostedApiAgentCapabilities(runtimeConfig); + } + setAzureCredentialFactory(azureCredentialFactory); + setRuntimeConfig(runtimeConfig); // Pre-warm the Copilot CLI/session/endpoint when it's the active // provider (no-op otherwise). Kept in the host to avoid an aiclient // import cycle; fire-and-forget so startup is never blocked. @@ -86,7 +96,10 @@ export class TypeAgentServer { sw.stop("Downloaded Session Backup"); } - this.webDispatcher = await createWebDispatcher(this.structuredLogs); + this.webDispatcher = await createWebDispatcher( + this.structuredLogs, + this.agentProfile, + ); debug("Web Dispatcher created."); // web server diff --git a/ts/packages/api/src/webDispatcher.ts b/ts/packages/api/src/webDispatcher.ts index 040d05be2c..4bb191dab0 100644 --- a/ts/packages/api/src/webDispatcher.ts +++ b/ts/packages/api/src/webDispatcher.ts @@ -5,6 +5,7 @@ import { awaitCommand, CommandResult, createDispatcher, + DefaultIndexingServiceRegistry, ProcessCommandOptions, SubmitResult, } from "agent-dispatcher"; @@ -18,12 +19,18 @@ import { getDefaultAppAgentProviders, getDefaultAppAgentSources, getDefaultConstructionProvider, + getDefaultDispatcherOptions, getIndexingServiceRegistry, } from "default-agent-provider"; import WebSocket from "ws"; import { getFsStorageProvider } from "dispatcher-node-providers"; import registerDebug from "debug"; import { FullAction } from "@typeagent/agent-cache"; +import { + HOSTED_API_AGENT_CONFIG, + loadHostedApiAgentProviders, + type ApiAgentProfile, +} from "./hostedAgentProfile.js"; const debug = registerDebug("typeagent:webserver:api"); registerDebug.enable("typeagent:webserver:*"); @@ -36,6 +43,7 @@ export interface WebDispatcher { export async function createWebDispatcher( structuredLogs: boolean, + agentProfile: ApiAgentProfile = "default", ): Promise { let ws: WebSocket | null = null; const clientIOChannel = createChannelAdapter((message: any) => @@ -50,12 +58,22 @@ export async function createWebDispatcher( debug("Creating Web Dispatcher..."); const instanceDir = getInstanceDir(); + const hosted = agentProfile === "hosted"; + const appAgentProviders = hosted + ? await loadHostedApiAgentProviders() + : getDefaultAppAgentProviders(instanceDir); + const appAgentSources = hosted + ? [] + : getDefaultAppAgentSources(instanceDir, { + excludePathSources: true, + }); + const indexingServiceRegistry = hosted + ? new DefaultIndexingServiceRegistry() + : await getIndexingServiceRegistry(instanceDir); const clientIO = createClientIORpcClient(clientIOChannel.channel); const dispatcher = await createDispatcher("api", { - appAgentProviders: getDefaultAppAgentProviders(instanceDir), - appAgentSources: getDefaultAppAgentSources(instanceDir, { - excludePathSources: true, - }), + appAgentProviders, + appAgentSources, persistSession: true, persistDir: instanceDir, storageProvider: getFsStorageProvider(), @@ -66,8 +84,12 @@ export async function createWebDispatcher( structuredLogs, }, clientIO: clientIO, - constructionProvider: getDefaultConstructionProvider(), - indexingServiceRegistry: await getIndexingServiceRegistry(instanceDir), + ...(hosted + ? getDefaultDispatcherOptions(HOSTED_API_AGENT_CONFIG) + : { + constructionProvider: getDefaultConstructionProvider(), + }), + indexingServiceRegistry, }); let settingSummary: string = ""; diff --git a/ts/packages/api/test/containerBootstrap.spec.ts b/ts/packages/api/test/containerBootstrap.spec.ts new file mode 100644 index 0000000000..06803b1845 --- /dev/null +++ b/ts/packages/api/test/containerBootstrap.spec.ts @@ -0,0 +1,617 @@ +// 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 { + HOSTED_API_CONFIG_FIELD_POLICY, + HOSTED_API_PROFILE, + HOSTED_API_SECRET_NAME, + loadApiRuntime, + loadHostedApiRuntimeConfig, + validateHostedApiRuntimeConfig, +} from "../src/containerBootstrap.js"; +import { startAfterBootstrap } from "../src/startup.js"; +import type { Config, ConfigTree, KeyVaultFetcher } from "@typeagent/config"; +import type { TokenCredential } from "@azure/identity"; + +const ALLOWED_ENDPOINT = + "https://api-eastus.openai.azure.com/openai/deployments/gpt-6-luna/chat/completions?api-version=2025-01-01-preview"; + +function createTempRoot(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), "typeagent-api-bootstrap-")); +} + +function hostedTree(extra: ConfigTree = {}): ConfigTree { + return { + profile: HOSTED_API_PROFILE, + modelProvider: "azure", + azureOpenAI: { + defaultAuth: "identity", + deployments: { + gpt_6_luna: { + endpoints: [ + { + endpoint: ALLOWED_ENDPOINT, + region: "eastus", + mode: "PAYG", + }, + ], + }, + }, + }, + ...extra, + }; +} + +function createEnvironment(root: string): NodeJS.ProcessEnv { + const home = path.join(root, "home"); + fs.mkdirSync(home, { recursive: true }); + return { + HOME: home, + TYPEAGENT_USER_DATA_DIR: path.join(home, ".typeagent"), + TYPEAGENT_API_BOOTSTRAP: HOSTED_API_PROFILE, + TYPEAGENT_API_CONFIG_VAULT: "api-vault", + TYPEAGENT_API_CONFIG_SECRET: HOSTED_API_SECRET_NAME, + }; +} + +function fakeVault( + secrets: Readonly>, + requests: string[], +): KeyVaultFetcher { + return async (vaultName, secretName) => { + requests.push(`${vaultName}/${secretName}`); + return secrets[secretName] ?? null; + }; +} + +function serializedRuntime( + env: NodeJS.ProcessEnv, + config: Awaited>, + logs: readonly string[], +): string { + return JSON.stringify({ + env, + config: { + modelProvider: config.modelProvider, + deployments: [...config.azureOpenAI.deployments.values()], + extra: [...config.extra], + }, + logs, + }); +} + +describe("hosted API container bootstrap", () => { + const roots: string[] = []; + + afterEach(() => { + for (const root of roots.splice(0)) { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + test("fetches only the dedicated allowlisted routing secret", async () => { + const root = createTempRoot(); + roots.push(root); + const env = createEnvironment(root); + env.TYPEAGENT_SHAREDVAULT = "shared-vault"; + env.TYPEAGENT_CONFIG_LOCAL = path.join(root, "legacy.yaml"); + env.OPENAI_ENDPOINT = "https://legacy-sentinel.invalid"; + env.AZURE_OPENAI_ENDPOINT = "https://stale-sentinel.invalid"; + env.AWS_S3_BUCKET_NAME = "aws-bucket-sentinel"; + env.AZURE_STORAGE_ACCOUNT = "storage-account-sentinel"; + env.COSMOSDB_ENDPOINT = "https://cosmosdb-routing-sentinel.invalid"; + env.WIKIPEDIA_ENDPOINT = "https://wikipedia-sentinel.invalid"; + fs.writeFileSync( + env.TYPEAGENT_CONFIG_LOCAL, + "openAI:\n apiKey: legacy-file-sentinel\n", + ); + + const unrelatedSentinels = [ + "graph-client-secret-sentinel", + "cosmosdb-connection-string-sentinel", + "speech-key-sentinel", + "unrelated-api-key-sentinel", + ]; + const requests: string[] = []; + const logs: string[] = []; + const config = await loadHostedApiRuntimeConfig({ + env, + fetcher: fakeVault( + { + [HOSTED_API_SECRET_NAME]: JSON.stringify(hostedTree()), + "typeagent-config": JSON.stringify({ + msGraph: { clientSecret: unrelatedSentinels[0] }, + storage: { + database: { + cosmosDbConnectionString: unrelatedSentinels[1], + }, + }, + speech: { apiKey: unrelatedSentinels[2] }, + openAI: { apiKey: unrelatedSentinels[3] }, + }), + }, + requests, + ), + log: (message) => logs.push(message), + getUid: () => 10001, + platform: "win32", + }); + + expect(requests).toEqual([`api-vault/${HOSTED_API_SECRET_NAME}`]); + expect(config.modelProvider).toBe("azure"); + expect( + config.azureOpenAI.deployments.get("gpt_6_luna")?.endpoints[0] + .endpoint, + ).toBe(ALLOWED_ENDPOINT); + expect(env.TYPEAGENT_SHAREDVAULT).toBeUndefined(); + expect(env.TYPEAGENT_CONFIG_LOCAL).toBeUndefined(); + expect(env.OPENAI_ENDPOINT).toBeUndefined(); + expect(env.AZURE_OPENAI_ENDPOINT).toBe(ALLOWED_ENDPOINT); + expect(env.AWS_S3_BUCKET_NAME).toBeUndefined(); + expect(env.AZURE_STORAGE_ACCOUNT).toBeUndefined(); + expect(env.COSMOSDB_ENDPOINT).toBeUndefined(); + expect(env.WIKIPEDIA_ENDPOINT).toBeUndefined(); + const runtime = serializedRuntime(env, config, logs); + for (const sentinel of unrelatedSentinels) { + expect(runtime).not.toContain(sentinel); + } + expect(fs.readdirSync(env.TYPEAGENT_USER_DATA_DIR as string)).toEqual( + [], + ); + }); + + test("missing required configuration fails before a listener starts", async () => { + const root = createTempRoot(); + roots.push(root); + const env = createEnvironment(root); + let listenerStarted = false; + + await expect( + startAfterBootstrap( + () => + loadHostedApiRuntimeConfig({ + env, + fetcher: async () => null, + getUid: () => 10001, + platform: "win32", + }), + async () => { + listenerStarted = true; + }, + ), + ).rejects.toThrow( + "Dedicated hosted API configuration secret is missing or empty.", + ); + + expect(listenerStarted).toBe(false); + expect(fs.readdirSync(env.TYPEAGENT_USER_DATA_DIR as string)).toEqual( + [], + ); + }); + + test.each([ + "https://attacker.example/vault", + "api-vault/secret", + "api.vault", + "api-vault:443", + "api vault", + "-api-vault", + "api--vault", + ])("rejects invalid Key Vault resource name %s", async (vaultName) => { + const root = createTempRoot(); + roots.push(root); + const env = createEnvironment(root); + env.TYPEAGENT_API_CONFIG_VAULT = vaultName; + let requested = false; + + await expect( + loadHostedApiRuntimeConfig({ + env, + fetcher: async () => { + requested = true; + return JSON.stringify(hostedTree()); + }, + getUid: () => 10001, + platform: "win32", + }), + ).rejects.toThrow( + "TYPEAGENT_API_CONFIG_VAULT must be a valid Azure Key Vault resource name.", + ); + expect(requested).toBe(false); + }); + + test("rejects an attacker-controlled model endpoint", async () => { + const root = createTempRoot(); + roots.push(root); + const env = createEnvironment(root); + const tree = hostedTree(); + const azure = tree.azureOpenAI as ConfigTree; + const deployments = azure.deployments as ConfigTree; + const chat = deployments.gpt_6_luna as ConfigTree; + const endpoints = chat.endpoints as ConfigTree[]; + endpoints[0].endpoint = + "https://attacker.example/openai/deployments/gpt-6-luna/chat/completions"; + + await expect( + loadHostedApiRuntimeConfig({ + env, + fetcher: async () => JSON.stringify(tree), + getUid: () => 10001, + platform: "win32", + }), + ).rejects.toThrow( + "azureOpenAI.deployments.gpt_6_luna.endpoints.0.endpoint must use a supported Azure OpenAI or Azure AI Services hostname.", + ); + }); + + test("shared vault and legacy files never override the dedicated source", async () => { + const root = createTempRoot(); + roots.push(root); + const env = createEnvironment(root); + const legacyDir = path.join(root, "legacy"); + fs.mkdirSync(legacyDir); + fs.writeFileSync( + path.join(legacyDir, ".env"), + "AZURE_OPENAI_ENDPOINT=https://dotenv-sentinel.invalid\n", + ); + fs.writeFileSync( + path.join(legacyDir, "config.local.yaml"), + "azureOpenAI:\n defaultChat:\n endpoint: https://local-sentinel.invalid\n", + ); + env.TYPEAGENT_SHAREDVAULT = "shared-vault-sentinel"; + env.TYPEAGENT_CONFIG_DIR = legacyDir; + env.TYPEAGENT_DOTENV = path.join(legacyDir, ".env"); + env.AZURE_OPENAI_ENDPOINT = "https://environment-sentinel.invalid"; + env.AZURE_CLIENT_ID = "ambient-client-id"; + env.TYPEAGENT_API_MANAGED_IDENTITY_CLIENT_ID = "dedicated-client-id"; + + const requests: string[] = []; + const config = await loadHostedApiRuntimeConfig({ + env, + fetcher: fakeVault( + { + [HOSTED_API_SECRET_NAME]: JSON.stringify(hostedTree()), + "typeagent-config": JSON.stringify({ + azureOpenAI: { + defaultChat: { + endpoint: + "https://shared-vault-sentinel.invalid", + }, + }, + }), + }, + requests, + ), + getUid: () => 10001, + platform: "win32", + }); + + expect(requests).toEqual([`api-vault/${HOSTED_API_SECRET_NAME}`]); + expect( + config.azureOpenAI.deployments.get("gpt_6_luna")?.endpoints[0] + .endpoint, + ).toBe(ALLOWED_ENDPOINT); + expect(env.AZURE_OPENAI_ENDPOINT).toBe(ALLOWED_ENDPOINT); + expect(env.TYPEAGENT_SHAREDVAULT).toBeUndefined(); + expect(env.TYPEAGENT_CONFIG_DIR).toBeUndefined(); + expect(env.TYPEAGENT_DOTENV).toBeUndefined(); + expect(env.AZURE_CLIENT_ID).toBeUndefined(); + expect(env.TYPEAGENT_API_MANAGED_IDENTITY_CLIENT_ID).toBe( + "dedicated-client-id", + ); + }); + + test("scrubs an ambient Azure client ID when no dedicated identity is selected", async () => { + const root = createTempRoot(); + roots.push(root); + const env = createEnvironment(root); + env.AZURE_CLIENT_ID = "ambient-client-id"; + + await loadHostedApiRuntimeConfig({ + env, + fetcher: async () => JSON.stringify(hostedTree()), + getUid: () => 10001, + platform: "win32", + }); + + expect(env.AZURE_CLIENT_ID).toBeUndefined(); + }); + + test("returns the validated hosted managed-identity credential factory", async () => { + const root = createTempRoot(); + roots.push(root); + const env = createEnvironment(root); + env.TYPEAGENT_API_MANAGED_IDENTITY_CLIENT_ID = + "00000000-0000-0000-0000-000000000001"; + const credential: TokenCredential = { + getToken: async () => null, + }; + + const runtime = await loadApiRuntime({ + env, + credential, + fetcher: async () => JSON.stringify(hostedTree()), + getUid: () => 10001, + platform: "win32", + }); + + expect(runtime.config.modelProvider).toBe("azure"); + expect(runtime.azureCredentialFactory?.()).toBe(credential); + expect(env.AZURE_CLIENT_ID).toBeUndefined(); + }); + + test("classifies every root Config field in the hosted security policy", async () => { + expect(HOSTED_API_CONFIG_FIELD_POLICY).toEqual({ + embeddingModels: "absent", + defaultEmbeddingModel: "absent", + modelRequestConstraints: "absent", + azureOpenAI: "identity-azure-openai", + openAI: "absent", + speech: "absent", + maps: "absent", + msGraph: "absent", + googleCalendar: "absent", + spotify: "absent", + wikipedia: "absent", + storage: "empty-object", + vault: "absent", + azureFoundry: "absent", + azureAISearch: "absent", + reasoning: "absent", + copilot: "absent", + embedding: "absent", + modelProvider: "azure-model-provider", + extra: "empty-map", + }); + + const root = createTempRoot(); + roots.push(root); + const config = await loadHostedApiRuntimeConfig({ + env: createEnvironment(root), + fetcher: async () => JSON.stringify(hostedTree()), + getUid: () => 10001, + platform: "win32", + }); + expect(() => validateHostedApiRuntimeConfig(config)).not.toThrow(); + + const unsupportedMaps: Config = { + ...config, + maps: { + clientId: "unsupported-client", + endpoint: "https://atlas.microsoft.com", + }, + }; + expect(() => validateHostedApiRuntimeConfig(unsupportedMaps)).toThrow( + "Hosted API runtime field 'maps' must be absent.", + ); + + const unsupportedStorage: Config = { + ...config, + storage: { + database: { + cosmosDbConnectionString: "unsupported-connection", + }, + }, + }; + expect(() => + validateHostedApiRuntimeConfig(unsupportedStorage), + ).toThrow( + "Hosted API runtime field 'storage' must be an empty object.", + ); + }); + + test("rejects secret-shaped fields without logging their values", async () => { + const root = createTempRoot(); + roots.push(root); + const env = createEnvironment(root); + const sentinel = "dedicated-secret-value-sentinel"; + const logs: string[] = []; + const unsafe = hostedTree(); + const azure = unsafe.azureOpenAI as ConfigTree; + azure.apiKey = sentinel; + + await expect( + loadHostedApiRuntimeConfig({ + env, + fetcher: async () => JSON.stringify(unsafe), + log: (message) => logs.push(message), + getUid: () => 10001, + platform: "win32", + }), + ).rejects.toThrow( + "Static credential field 'azureOpenAI.apiKey' is not allowed.", + ); + expect(JSON.stringify({ env, logs })).not.toContain(sentinel); + }); + + test("rejects static credential environment variables", async () => { + const root = createTempRoot(); + roots.push(root); + const env = createEnvironment(root); + env.OPENAI_API_KEY = "static-environment-secret-sentinel"; + + await expect( + loadHostedApiRuntimeConfig({ + env, + fetcher: async () => JSON.stringify(hostedTree()), + getUid: () => 10001, + platform: "win32", + }), + ).rejects.toThrow( + "Static credential environment variable 'OPENAI_API_KEY' is not allowed.", + ); + expect(env.OPENAI_API_KEY).toBeUndefined(); + }); + + test("rejects the Cosmos DB connection string without leaking it", async () => { + const root = createTempRoot(); + roots.push(root); + const env = createEnvironment(root); + const sentinel = "cosmosdb-environment-credential-sentinel"; + const previousProcessValue = process.env.COSMOSDB_CONNECTION_STRING; + const logs: string[] = []; + env.COSMOSDB_CONNECTION_STRING = sentinel; + + let failure = ""; + try { + await loadHostedApiRuntimeConfig({ + env, + fetcher: async () => JSON.stringify(hostedTree()), + log: (message) => logs.push(message), + getUid: () => 10001, + platform: "win32", + }); + } catch (error) { + failure = String(error); + } + + expect(failure).toContain( + "Static credential environment variable 'COSMOSDB_CONNECTION_STRING' is not allowed.", + ); + expect(failure).not.toContain(sentinel); + expect(logs.join("\n")).not.toContain(sentinel); + expect(env.COSMOSDB_CONNECTION_STRING).toBeUndefined(); + expect(process.env.COSMOSDB_CONNECTION_STRING).toBe( + previousProcessValue, + ); + expect(fs.readdirSync(env.TYPEAGENT_USER_DATA_DIR as string)).toEqual( + [], + ); + }); + + test.each([ + "AWS_SECRET_ACCESS_KEY", + "AZURE_STORAGE_CONNECTION_STRING", + "CUSTOMCONNSTR_DATABASE", + "DISCORD_BOT_TOKEN", + "MYSQLCONNSTR_DATABASE", + "MONGODB_CONNECTION_STRING", + "POSTGRESQLCONNSTR_DATABASE", + "SPOTIFY_APP_CLISEC", + "SQLAZURECONNSTR_DATABASE", + "SQLCONNSTR_DATABASE", + "WIKIPEDIA_CLIENT_SECRET", + ])("rejects unrelated static credential %s", async (key) => { + const root = createTempRoot(); + roots.push(root); + const env = createEnvironment(root); + const sentinel = `unrelated-${key.toLowerCase()}-sentinel`; + const logs: string[] = []; + env[key] = sentinel; + + let failure = ""; + try { + await loadHostedApiRuntimeConfig({ + env, + fetcher: async () => JSON.stringify(hostedTree()), + log: (message) => logs.push(message), + getUid: () => 10001, + platform: "win32", + }); + } catch (error) { + failure = String(error); + } + expect(failure).toContain( + `Static credential environment variable '${key}' is not allowed.`, + ); + expect(failure).not.toContain(sentinel); + expect(logs.join("\n")).not.toContain(sentinel); + expect(env[key]).toBeUndefined(); + expect(fs.readdirSync(env.TYPEAGENT_USER_DATA_DIR as string)).toEqual( + [], + ); + }); + + test.each(["discordBotToken", "CUSTOMCONNSTR_DATABASE"])( + "rejects credential-shaped configuration field %s", + async (field) => { + const root = createTempRoot(); + roots.push(root); + const env = createEnvironment(root); + const sentinel = "configuration-credential-sentinel"; + const unsafe = hostedTree({ [field]: sentinel }); + const logs: string[] = []; + + let failure = ""; + try { + await loadHostedApiRuntimeConfig({ + env, + fetcher: async () => JSON.stringify(unsafe), + log: (message) => logs.push(message), + getUid: () => 10001, + platform: "win32", + }); + } catch (error) { + failure = String(error); + } + + expect(failure).toContain( + `Static credential field '${field}' is not allowed.`, + ); + expect(failure).not.toContain(sentinel); + expect(logs.join("\n")).not.toContain(sentinel); + expect( + fs.readdirSync(env.TYPEAGENT_USER_DATA_DIR as string), + ).toEqual([]); + }, + ); + + test("sanitizes malformed dedicated secret errors", async () => { + const root = createTempRoot(); + roots.push(root); + const env = createEnvironment(root); + const sentinel = "malformed-secret-sentinel"; + + await expect( + loadHostedApiRuntimeConfig({ + env, + fetcher: async () => `profile: [${sentinel}`, + getUid: () => 10001, + platform: "win32", + }), + ).rejects.toThrow( + "Failed to fetch or validate the dedicated hosted API configuration secret.", + ); + + try { + await loadHostedApiRuntimeConfig({ + env, + fetcher: async () => `profile: [${sentinel}`, + getUid: () => 10001, + platform: "win32", + }); + } catch (error) { + expect(String(error)).not.toContain(sentinel); + } + }); + + test("rejects root and persistent Azure CLI account state", async () => { + const root = createTempRoot(); + roots.push(root); + const rootEnv = createEnvironment(path.join(root, "root-case")); + await expect( + loadHostedApiRuntimeConfig({ + env: rootEnv, + fetcher: async () => JSON.stringify(hostedTree()), + getUid: () => 0, + platform: "linux", + }), + ).rejects.toThrow("Hosted API startup refuses to run as root."); + + const azureEnv = createEnvironment(path.join(root, "azure-case")); + fs.mkdirSync(path.join(azureEnv.HOME as string, ".azure")); + await expect( + loadHostedApiRuntimeConfig({ + env: azureEnv, + fetcher: async () => JSON.stringify(hostedTree()), + getUid: () => 10001, + platform: "win32", + }), + ).rejects.toThrow( + "Hosted API startup refuses persistent Azure CLI credential state.", + ); + }); +}); diff --git a/ts/packages/api/test/containerImage.spec.ts b/ts/packages/api/test/containerImage.spec.ts new file mode 100644 index 0000000000..3f2d8018d5 --- /dev/null +++ b/ts/packages/api/test/containerImage.spec.ts @@ -0,0 +1,149 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +interface DockerStage { + readonly base: string; + readonly name: string; + readonly body: string; +} + +function workspaceRoot(): string { + let current = path.dirname(fileURLToPath(import.meta.url)); + while (path.dirname(current) !== current) { + if ( + fs.existsSync(path.join(current, "pnpm-workspace.yaml")) && + fs.existsSync(path.join(current, "Dockerfile")) + ) { + return current; + } + current = path.dirname(current); + } + throw new Error("Could not locate TypeAgent workspace root."); +} + +function parseDockerStages(dockerfile: string): DockerStage[] { + const matches = [ + ...dockerfile.matchAll(/^FROM\s+(\S+)(?:\s+AS\s+(\S+))?\s*$/gim), + ]; + return matches.map((match, index) => ({ + base: match[1], + name: match[2] ?? "", + body: dockerfile.slice( + match.index, + matches[index + 1]?.index ?? dockerfile.length, + ), + })); +} + +describe("production API image definition", () => { + const root = workspaceRoot(); + const dockerfile = fs.readFileSync(path.join(root, "Dockerfile"), "utf8"); + const stages = parseDockerStages(dockerfile); + const stage = (name: string): DockerStage => { + const match = stages.find((candidate) => candidate.name === name); + if (match === undefined) { + throw new Error(`Docker stage '${name}' is missing.`); + } + return match; + }; + const apiImage = stage("api"); + const smokeImage = stage("api-smoke"); + const defaultImage = stages.at(-1); + const startScript = fs.readFileSync( + path.join(root, "packages", "api", "container", "start.sh"), + "utf8", + ); + const smokeScript = fs.readFileSync( + path.join(root, "packages", "api", "container", "smoke.mjs"), + "utf8", + ); + const packageJson = JSON.parse( + fs.readFileSync( + path.join(root, "packages", "api", "package.json"), + "utf8", + ), + ) as { files?: string[] }; + const deploymentPipeline = fs.readFileSync( + path.join(root, "..", "pipelines", "azure-build-docker-container.yml"), + "utf8", + ); + const acrBuildTask = fs.readFileSync( + path.join(root, "acr-build.yaml"), + "utf8", + ); + + test("runs as the dedicated account without Azure CLI bootstrap", () => { + expect(apiImage.body).toContain("USER typeagent"); + expect(apiImage.body).not.toContain("az login"); + expect(apiImage.body).not.toContain("getKeys.mjs"); + expect(apiImage.body).not.toContain("/prod/tools"); + expect(apiImage.body).not.toContain("InstallAzureCLIDeb"); + expect(apiImage.body).not.toContain("pnpm start"); + }); + + test("uses the checked-in fail-fast startup script", () => { + expect(apiImage.body).toContain( + 'CMD ["/usr/local/bin/typeagent-api-start"]', + ); + expect(startScript).toContain("set -euo pipefail"); + expect(startScript).toContain('"$(id -u)" -eq 0'); + expect(startScript).toContain("${HOME:?HOME must be set}/.azure"); + expect(startScript).toContain("exec node ./dist/index.js"); + }); + + test("selects the dedicated managed-identity bootstrap", () => { + expect(apiImage.body).toContain( + "TYPEAGENT_API_BOOTSTRAP=hosted-azure-managed-identity-v1", + ); + expect(apiImage.body).toContain( + "TYPEAGENT_API_CONFIG_SECRET=typeagent-api-hosted-config", + ); + expect(apiImage.body).toContain( + "TYPEAGENT_USER_DATA_DIR=/home/typeagent/.typeagent", + ); + expect(apiImage.body).not.toContain("TYPEAGENT_AZURE_AUTH_MODE"); + }); + + test("keeps production as the targetless deployment image", () => { + expect(defaultImage).toMatchObject({ + base: "api", + name: "production", + }); + expect(defaultImage?.body).not.toContain("container/smoke.mjs"); + expect(defaultImage?.body).not.toContain("container-smoke.mjs"); + expect(apiImage.body).not.toContain("container/smoke.mjs"); + expect(deploymentPipeline).toContain("az acr run"); + expect(acrBuildTask).toContain("-f Dockerfile ."); + expect(acrBuildTask).not.toContain("--target"); + }); + + test("installs the smoke harness where its runtime imports resolve", () => { + const copy = smokeImage.body.match( + /COPY packages\/api\/container\/smoke\.mjs\s+(\S+)/, + ); + expect(copy).not.toBeNull(); + const installedPath = copy?.[1] as string; + expect(smokeImage.body).toContain(`CMD ["node", "${installedPath}"]`); + + const localImports = [ + ...smokeScript.matchAll(/from "(\.\.\/dist\/[^"]+)"/g), + ].map((match) => match[1]); + expect(localImports.length).toBeGreaterThan(0); + for (const specifier of localImports) { + expect( + path.posix.resolve( + path.posix.dirname(installedPath), + specifier, + ), + ).toMatch(/^\/prod\/api\/dist\//); + } + }); + + test("excludes source and test fixtures from the production deploy", () => { + expect(packageJson.files).toEqual(["dist", "!dist/test", "data"]); + }); +}); diff --git a/ts/packages/api/test/hostedAgentProfile.spec.ts b/ts/packages/api/test/hostedAgentProfile.spec.ts new file mode 100644 index 0000000000..dad5563eb5 --- /dev/null +++ b/ts/packages/api/test/hostedAgentProfile.spec.ts @@ -0,0 +1,164 @@ +// 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 { + createDispatcher, + DefaultIndexingServiceRegistry, +} from "agent-dispatcher"; +import { + HOSTED_API_PROFILE, + HOSTED_API_SECRET_NAME, + loadHostedApiRuntimeConfig, +} from "../src/containerBootstrap.js"; +import { + HOSTED_API_AGENT_SECURITY_ALLOWLIST, + loadHostedApiAgentProviders, + resolveApiAgentProfile, + validateHostedApiAgentCapabilities, + validateHostedApiAgentSecurityPolicy, +} from "../src/hostedAgentProfile.js"; + +const ENDPOINT = + "https://hosted-api.openai.azure.com/openai/deployments/gpt-6-luna/chat/completions?api-version=2025-01-01-preview"; + +async function loadConfig(root: string) { + const home = path.join(root, "home"); + fs.mkdirSync(home, { recursive: true }); + return loadHostedApiRuntimeConfig({ + env: { + HOME: home, + TYPEAGENT_USER_DATA_DIR: path.join(home, ".typeagent"), + TYPEAGENT_API_BOOTSTRAP: HOSTED_API_PROFILE, + TYPEAGENT_API_CONFIG_VAULT: "hosted-api-vault", + TYPEAGENT_API_CONFIG_SECRET: HOSTED_API_SECRET_NAME, + }, + fetcher: async () => + JSON.stringify({ + profile: HOSTED_API_PROFILE, + modelProvider: "azure", + azureOpenAI: { + defaultAuth: "identity", + deployments: { + gpt_6_luna: { + endpoints: [ + { + endpoint: ENDPOINT, + region: "eastus", + mode: "PAYG", + }, + ], + }, + }, + }, + }), + getUid: () => 10001, + platform: "win32", + }); +} + +describe("hosted API agent profile", () => { + let root: string; + + beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), "hosted-api-agents-")); + }); + + afterEach(() => { + fs.rmSync(root, { recursive: true, force: true }); + }); + + test("registers only supported hosted agents and loads their manifests", async () => { + const providers = await loadHostedApiAgentProviders(); + const names = providers + .flatMap((provider) => provider.getAppAgentNames()) + .sort(); + + expect(names).toEqual([...HOSTED_API_AGENT_SECURITY_ALLOWLIST].sort()); + expect(names).toEqual( + expect.arrayContaining(["chat", "list", "timer"]), + ); + expect(names).not.toEqual( + expect.arrayContaining([ + "browser", + "discord", + "image", + "mcpfilesystem", + "photo", + "video", + ]), + ); + + const dispatcher = await createDispatcher("hosted-api-test", { + appAgentProviders: providers, + agents: names, + indexingServiceRegistry: new DefaultIndexingServiceRegistry(), + }); + try { + const registeredAppAgentNames = ( + await dispatcher.getStatus() + ).agents + .map((agent) => agent.name) + .filter( + (name) => + name !== "dispatcher" && + !name.startsWith("dispatcher.") && + name !== "system" && + !name.startsWith("system."), + ) + .sort(); + expect(registeredAppAgentNames).toEqual(names); + } finally { + await dispatcher.close(); + } + }); + + test("fails closed when provider config differs from the security allowlist", () => { + expect(() => + validateHostedApiAgentSecurityPolicy([ + ...HOSTED_API_AGENT_SECURITY_ALLOWLIST, + "browser", + ]), + ).toThrow( + "Hosted API agent security policy mismatch: expected chat, greeting, list, settings, timer, got browser, chat, greeting, list, settings, timer.", + ); + expect(() => + validateHostedApiAgentSecurityPolicy( + HOSTED_API_AGENT_SECURITY_ALLOWLIST.filter( + (name) => name !== "timer", + ), + ), + ).toThrow( + "Hosted API agent security policy mismatch: expected chat, greeting, list, settings, timer, got chat, greeting, list, settings.", + ); + }); + + test("requires the identity-backed GPT deployment used by every hosted agent", async () => { + const config = await loadConfig(root); + expect(() => validateHostedApiAgentCapabilities(config)).not.toThrow(); + + const missingDeployment = { + ...config, + azureOpenAI: { + ...config.azureOpenAI, + deployments: new Map(), + }, + }; + expect(() => + validateHostedApiAgentCapabilities(missingDeployment), + ).toThrow( + "Hosted API agents require an identity-authenticated gpt_6_luna deployment.", + ); + }); + + test("selects the hosted catalog only for the explicit container profile", () => { + expect( + resolveApiAgentProfile({ + TYPEAGENT_API_BOOTSTRAP: ` ${HOSTED_API_PROFILE} `, + }), + ).toBe("hosted"); + expect(resolveApiAgentProfile({})).toBe("default"); + }); +}); diff --git a/ts/packages/defaultAgentProvider/data/config.hosted.json b/ts/packages/defaultAgentProvider/data/config.hosted.json new file mode 100644 index 0000000000..4ba45a90cb --- /dev/null +++ b/ts/packages/defaultAgentProvider/data/config.hosted.json @@ -0,0 +1,25 @@ +{ + "description": "Hosted API profile with only managed-identity GPT-backed and local agents.", + "agents": { + "chat": { + "name": "@typeagent/chat-agent", + "execMode": "dispatcher" + }, + "greeting": { + "name": "greeting-agent", + "execMode": "dispatcher" + }, + "list": { + "name": "@typeagent/list-agent" + }, + "settings": { + "name": "settings-agent", + "execMode": "dispatcher" + }, + "timer": { + "name": "@typeagent/timer-agent" + } + }, + "explainers": {}, + "tests": [] +} diff --git a/ts/packages/defaultAgentProvider/src/defaultAgentProviders.ts b/ts/packages/defaultAgentProvider/src/defaultAgentProviders.ts index 1c6a7eeba6..be179cf56c 100644 --- a/ts/packages/defaultAgentProvider/src/defaultAgentProviders.ts +++ b/ts/packages/defaultAgentProvider/src/defaultAgentProviders.ts @@ -75,6 +75,15 @@ import registerDebug from "debug"; const debug = registerDebug("typeagent:defaultAgentProvider:source"); +export function getBundledAppAgentProvider( + configName?: string, + executionModeOverrides: Parameters< + typeof createBundledAppAgentProvider + >[1] = {}, +): AppAgentProvider { + return createBundledAppAgentProvider(configName, executionModeOverrides); +} + // The directory under `installDir` that holds every per-agent, version-scoped // install root. GC (prune-on-swap + startup orphan sweep) operates // only within this directory, never the legacy shared `installDir/node_modules`, @@ -191,7 +200,7 @@ export function getDefaultAppAgentProviders( ): AppAgentProvider[] { // The bundled agents are always present as their own static provider. const providers: AppAgentProvider[] = [ - createBundledAppAgentProvider(configName, executionModeOverrides), + getBundledAppAgentProvider(configName, executionModeOverrides), ]; const instanceConfigs = typeof instanceDirOrConfigProvider === "string" diff --git a/ts/packages/defaultAgentProvider/src/index.ts b/ts/packages/defaultAgentProvider/src/index.ts index eb8c514d37..6fff5be693 100644 --- a/ts/packages/defaultAgentProvider/src/index.ts +++ b/ts/packages/defaultAgentProvider/src/index.ts @@ -2,6 +2,7 @@ // Licensed under the MIT License. export { + getBundledAppAgentProvider, getDefaultAppAgentProviders, getDefaultAppAgentSource, getDefaultDispatcherOptions, diff --git a/ts/tools/scripts/testApiContainer.mjs b/ts/tools/scripts/testApiContainer.mjs new file mode 100644 index 0000000000..afbbcd8bac --- /dev/null +++ b/ts/tools/scripts/testApiContainer.mjs @@ -0,0 +1,171 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const workspaceRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "..", + "..", +); +const tag = `typeagent-api-smoke-${process.pid}`; +const smokeTag = `${tag}-runner`; +const sentinels = [ + "graph-secret-sentinel", + "cosmosdb-connection-string-sentinel", + "speech-key-sentinel", + "unrelated-api-key-sentinel", +]; + +function run(command, args, options = {}) { + const result = spawnSync(command, args, { + cwd: workspaceRoot, + encoding: "utf8", + stdio: options.capture ? "pipe" : "inherit", + timeout: options.timeout ?? 20 * 60 * 1000, + }); + if (result.error) { + throw result.error; + } + return result; +} + +const dockerInfo = run("docker", ["info"], { + capture: true, + timeout: 30_000, +}); +if (dockerInfo.status !== 0) { + console.error( + "Docker is installed but its engine is unavailable; container smoke was not run.", + ); + process.exit(2); +} + +try { + assert.equal( + run("docker", ["build", "--tag", tag, "--file", "Dockerfile", "."]) + .status, + 0, + "production image build failed", + ); + assert.equal( + run("docker", [ + "build", + "--target", + "api-smoke", + "--tag", + smokeTag, + "--file", + "Dockerfile", + ".", + ]).status, + 0, + "smoke image build failed", + ); + + const imageUser = run( + "docker", + ["image", "inspect", "--format", "{{.Config.User}}", tag], + { capture: true }, + ); + assert.equal(imageUser.status, 0); + assert.equal(imageUser.stdout.trim(), "typeagent"); + + const imageCommand = run( + "docker", + ["image", "inspect", "--format", "{{json .Config.Cmd}}", tag], + { capture: true }, + ); + assert.equal(imageCommand.status, 0); + assert.equal( + imageCommand.stdout.trim(), + '["/usr/local/bin/typeagent-api-start"]', + ); + assert.notEqual( + run( + "docker", + [ + "run", + "--rm", + "--entrypoint", + "test", + tag, + "-e", + "/prod/api/container/smoke.mjs", + ], + { capture: true }, + ).status, + 0, + "default production image must exclude the smoke harness", + ); + + for (const sentinel of sentinels) { + const fileScan = run( + "docker", + [ + "run", + "--rm", + "--entrypoint", + "grep", + tag, + "-R", + "-F", + "-l", + sentinel, + "/prod/api", + ], + { capture: true }, + ); + assert.equal( + fileScan.status, + 1, + `unrelated sentinel found in production runtime files: ${sentinel}`, + ); + } + + const smoke = run( + "docker", + [ + "run", + "--rm", + "--env", + "TYPEAGENT_API_CONFIG_VAULT=offline-smoke-vault", + smokeTag, + ], + { capture: true, timeout: 120_000 }, + ); + assert.equal( + smoke.status, + 0, + `container smoke failed:\n${smoke.stdout}\n${smoke.stderr}`, + ); + assert.match(smoke.stdout, /API container smoke passed/); + const smokeOutput = `${smoke.stdout}\n${smoke.stderr}`; + for (const sentinel of sentinels) { + assert.equal( + smokeOutput.includes(sentinel), + false, + `unrelated sentinel leaked to container logs: ${sentinel}`, + ); + } + + const missing = run("docker", ["run", "--rm", tag], { + capture: true, + timeout: 30_000, + }); + assert.notEqual(missing.status, 0); + assert.doesNotMatch( + `${missing.stdout}\n${missing.stderr}`, + /Listening at|WebSocket server started/, + ); + + console.log("API production container checks passed."); +} finally { + run("docker", ["image", "rm", "--force", smokeTag, tag], { + capture: true, + timeout: 60_000, + }); +}