Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 42 additions & 36 deletions ts/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,24 +1,15 @@
FROM node:22 AS base
FROM node:22 AS runtime-base

ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN npm install -g corepack@latest
RUN corepack enable pnpm
RUN corepack install -g pnpm@latest

# dependencies
RUN apt-get update
RUN apt-get install -y libsecret-1-0
RUN apt-get install -y libsecret-1-dev
RUN apt install -y gnome-keyring
RUN curl -sL https://aka.ms/InstallAzureCLIDeb | bash

# Install dependencies required for Chrome/Puppeteer
RUN apt-get update && apt-get install -y \
# Install runtime dependencies required by TypeAgent and Chrome/Puppeteer.
# Azure CLI is deliberately excluded from this stage so it cannot persist in
# the production image or leave a reusable account cache.
RUN apt-get update && apt-get install -y --no-install-recommends \
wget \
gnupg \
ca-certificates \
procps \
libsecret-1-0 \
gnome-keyring \
libxss1 \
libgconf-2-4 \
libatk1.0-0 \
Expand All @@ -37,14 +28,20 @@ RUN apt-get update && apt-get install -y \
libxdamage1 \
libxrandr2 \
xdg-utils

# Install Chrome for Puppeteer
RUN wget -q https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb \
&& apt-get install -y ./google-chrome-stable_current_amd64.deb \
&& rm google-chrome-stable_current_amd64.deb
&& rm google-chrome-stable_current_amd64.deb \
&& rm -rf /var/lib/apt/lists/*

# base only image
FROM base AS build
# Build-only image. Azure CLI remains available here for authenticated package
# feeds, but this stage is not shipped.
FROM runtime-base AS build
ARG PNPM_VERSION=11.9.0
RUN npm install --global "pnpm@${PNPM_VERSION}"
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl libsecret-1-dev \
&& curl -sL https://aka.ms/InstallAzureCLIDeb | bash \
&& rm -rf /var/lib/apt/lists/*
COPY ./ /usr/src/app
WORKDIR /usr/src/app
# onnxruntime-node's postinstall otherwise downloads the large CUDA/GPU
Expand All @@ -55,15 +52,14 @@ ENV ONNXRUNTIME_NODE_INSTALL_CUDA=skip
RUN pnpm install --frozen-lockfile
RUN pnpm run -r build
RUN pnpm deploy --legacy --filter=agent-api --prod /prod/api
RUN pnpm deploy --legacy --filter=tools-scripts --prod /prod/tools

# image with api and tool bits only
FROM base AS api
# Image with API bits only.
FROM runtime-base AS api
# api bits
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 #########################################
Expand All @@ -88,22 +84,32 @@ EXPOSE 80: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 TYPEAGENT_AZURE_AUTH_MODE=managed-identity
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
# TYPEAGENT_API_ALLOWED_ORIGINS to that public origin when running this image,
# 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 API server
RUN echo pnpm start >> 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"]

# run start script
CMD ["bash", "start.sh"]
# Keep production as the final stage because the ACR deployment builds this
# Dockerfile without an explicit --target.
FROM api AS production
30 changes: 25 additions & 5 deletions ts/packages/aiclient/src/auth.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

import { AccessToken, DefaultAzureCredential } from "@azure/identity";
import {
AccessToken,
DefaultAzureCredential,
ManagedIdentityCredential,
type TokenCredential,
} from "@azure/identity";
import { Result, success, error } from "typechat";

export interface AuthTokenProvider {
Expand All @@ -19,7 +24,7 @@ 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<Result<string>> | undefined;
return {
Expand All @@ -37,10 +42,14 @@ export function createAzureTokenProvider(

async function refreshToken(): Promise<Result<string>> {
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());
}
Expand Down Expand Up @@ -73,6 +82,17 @@ export function createAzureTokenProvider(
}
}

export function createDefaultCredential(): DefaultAzureCredential {
return new DefaultAzureCredential();
export function createDefaultCredential(): TokenCredential {
const authMode = process.env.TYPEAGENT_AZURE_AUTH_MODE?.trim();
if (authMode === undefined || authMode === "") {
return new DefaultAzureCredential();
}
if (authMode !== "managed-identity") {
throw new Error(`Unsupported TYPEAGENT_AZURE_AUTH_MODE '${authMode}'.`);
}
const clientId =
process.env.TYPEAGENT_API_MANAGED_IDENTITY_CLIENT_ID?.trim();
return clientId
? new ManagedIdentityCredential(clientId)
: new ManagedIdentityCredential();
}
53 changes: 53 additions & 0 deletions ts/packages/aiclient/test/auth.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

import {
DefaultAzureCredential,
ManagedIdentityCredential,
} from "@azure/identity";
import { createDefaultCredential } from "../src/auth.js";

describe("createDefaultCredential", () => {
const originalMode = process.env.TYPEAGENT_AZURE_AUTH_MODE;
const originalClientId =
process.env.TYPEAGENT_API_MANAGED_IDENTITY_CLIENT_ID;

afterEach(() => {
restoreEnvironment("TYPEAGENT_AZURE_AUTH_MODE", originalMode);
restoreEnvironment(
"TYPEAGENT_API_MANAGED_IDENTITY_CLIENT_ID",
originalClientId,
);
});

test("uses the normal Azure credential chain by default", () => {
delete process.env.TYPEAGENT_AZURE_AUTH_MODE;
expect(createDefaultCredential()).toBeInstanceOf(
DefaultAzureCredential,
);
});

test("selects managed identity explicitly for the hosted API", () => {
process.env.TYPEAGENT_AZURE_AUTH_MODE = "managed-identity";
process.env.TYPEAGENT_API_MANAGED_IDENTITY_CLIENT_ID =
"00000000-0000-0000-0000-000000000001";
expect(createDefaultCredential()).toBeInstanceOf(
ManagedIdentityCredential,
);
});

test("rejects unknown authentication modes", () => {
process.env.TYPEAGENT_AZURE_AUTH_MODE = "azure-cli";
expect(() => createDefaultCredential()).toThrow(
"Unsupported TYPEAGENT_AZURE_AUTH_MODE 'azure-cli'.",
);
});
});

function restoreEnvironment(name: string, value: string | undefined): void {
if (value === undefined) {
delete process.env[name];
} else {
process.env[name] = value;
}
}
67 changes: 66 additions & 1 deletion ts/packages/api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,72 @@ 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.

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

Expand Down
Loading
Loading