Skip to content
Merged
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
104 changes: 56 additions & 48 deletions ts/Dockerfile
Original file line number Diff line number Diff line change
@@ -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 \
Expand All @@ -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 \
Expand All @@ -55,42 +68,28 @@ 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
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 \
Expand All @@ -112,22 +111,31 @@ 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
# 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 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
35 changes: 30 additions & 5 deletions ts/packages/aiclient/src/auth.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,46 @@
// 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 {
getAccessToken(): Promise<Result<string>>;
refreshToken(): Promise<Result<string>>;
}

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<Result<string>> | undefined;
return {
Expand All @@ -37,10 +58,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 +98,6 @@ export function createAzureTokenProvider(
}
}

export function createDefaultCredential(): DefaultAzureCredential {
return new DefaultAzureCredential();
export function createDefaultCredential(): TokenCredential {
return azureCredentialFactory();
}
32 changes: 32 additions & 0 deletions ts/packages/aiclient/test/auth.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
69 changes: 68 additions & 1 deletion ts/packages/api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading
Loading