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
2 changes: 2 additions & 0 deletions ts/packages/agentServer/protocol/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ export {
AgentServerChannelName,
ConversationInfo,
JoinConversationResult,
UserIdentity,
DefaultUserIdentity,
getDispatcherChannelName,
getClientIOChannelName,
registerClientType,
Expand Down
23 changes: 23 additions & 0 deletions ts/packages/agentServer/protocol/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,18 @@ export type JoinConversationResult = {
pendingInteractions?: PendingInteractionRequest[];
};

/**
* Identity of the OS user the agent-server process is running as.
* Used by clients that can't do Office SSO (e.g. the Excel add-in) so
* they can show the user's initial instead of a generic "U" avatar.
* This is a convenience signal, not a security claim.
*/
export type UserIdentity = {
username: string; // OS username, e.g. "robgruen"
displayName: string; // Git user.name if set, else username
initial: string; // Single uppercase character for avatars
};

export type AgentServerInvokeFunctions = {
joinConversation: (
options?: DispatcherConnectOptions,
Expand All @@ -40,6 +52,17 @@ export type AgentServerInvokeFunctions = {
) => Promise<void>;
deleteConversation: (conversationId: string) => Promise<void>;
shutdown: () => Promise<void>;
getUserIdentity: () => Promise<UserIdentity>;
};

/**
* Fallback UserIdentity for clients that fail to reach the server. Keeps
* the UI from having to guard for undefined everywhere.
*/
export const DefaultUserIdentity: UserIdentity = {
username: "user",
displayName: "user",
initial: "U",
};

export const AgentServerChannelName = "agent-server";
Expand Down
1 change: 1 addition & 0 deletions ts/packages/agentServer/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
"tsc": "tsc -b"
},
"dependencies": {
"@azure/identity": "^4.10.0",
"@typeagent/agent-rpc": "workspace:*",
"@typeagent/agent-server-client": "workspace:*",
"@typeagent/agent-server-protocol": "workspace:*",
Expand Down
103 changes: 103 additions & 0 deletions ts/packages/agentServer/server/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
AgentServerInvokeFunctions,
AgentServerChannelName,
DispatcherConnectOptions,
UserIdentity,
getDispatcherChannelName,
getClientIOChannelName,
} from "@typeagent/agent-server-protocol";
Expand All @@ -34,11 +35,112 @@ import {
removeServerPid,
} from "@typeagent/agent-server-client";
import registerDebug from "debug";
import os from "node:os";
import { DefaultAzureCredential } from "@azure/identity";

const envPath = new URL("../../../../.env", import.meta.url);
dotenv.config({ path: envPath });

const debugStartup = registerDebug("agent-server:startup");

// User identity resolution. Precedence:
// 1. TYPEAGENT_USER_NAME env var (dev override / CI)
// 2. Claims from the Azure AD token DefaultAzureCredential acquires for
// the Cognitive Services scope — this is the same credential the
// agent-server uses to talk to Azure OpenAI, so if the server can talk
// to the model at all, the token's `name`/`upn` claims give us the
// real user. Works without any extra setup (no git config, no Office
// SSO).
// 3. OS username as a last resort.
//
// The Azure step is async and involves a network call, so we resolve it
// after startup and overwrite the cached identity when it arrives. The
// first few RPC calls may see the OS-username fallback; subsequent calls
// see the real display name.
function parseJwtClaims(token: string): Record<string, unknown> | undefined {
const [, payload] = token.split(".");
if (!payload) return undefined;
const b64 = payload.replace(/-/g, "+").replace(/_/g, "/");
const padded = b64 + "=".repeat((4 - (b64.length % 4)) % 4);
try {
return JSON.parse(Buffer.from(padded, "base64").toString("utf-8"));
} catch {
return undefined;
}
}

function identityFromClaims(
claims: Record<string, unknown>,
fallbackUsername: string,
): UserIdentity | undefined {
// Azure AD `name` is typically "First Last" or "Last, First". Prefer
// just the first name so the chat header stays compact. Split on
// whitespace or comma and take the first non-empty part.
const fullName =
typeof claims.name === "string" && claims.name.trim()
? claims.name.trim()
: undefined;
if (!fullName) return undefined;
// "Last, First" → prefer "First" after the comma; otherwise first token.
const firstName = fullName.includes(",")
? (fullName.split(",")[1]?.trim().split(/\s+/)[0] ?? fullName)
: (fullName.split(/\s+/)[0] ?? fullName);
const upn =
(typeof claims.upn === "string" && claims.upn) ||
(typeof claims.preferred_username === "string" &&
claims.preferred_username) ||
(typeof claims.unique_name === "string" && claims.unique_name) ||
undefined;
const initial = (firstName[0] ?? "U").toUpperCase();
return {
username: upn || fallbackUsername,
displayName: firstName,
initial,
};
}

async function resolveIdentityFromAzureToken(
fallbackUsername: string,
): Promise<UserIdentity | undefined> {
try {
const token = await new DefaultAzureCredential().getToken(
"https://cognitiveservices.azure.com/.default",
);
if (!token?.token) return undefined;
const claims = parseJwtClaims(token.token);
if (!claims) return undefined;
return identityFromClaims(claims, fallbackUsername);
} catch {
return undefined;
}
}

function initialIdentity(): UserIdentity {
const username = os.userInfo().username || "user";
const envName = process.env.TYPEAGENT_USER_NAME?.trim();
const displayName = envName || username;
const initial = (displayName[0] ?? "U").toUpperCase();
return { username, displayName, initial };
}

let userIdentity: UserIdentity = initialIdentity();

// Kick off the token-based resolution asynchronously. Env override wins
// if set, so skip the network call in that case.
if (!process.env.TYPEAGENT_USER_NAME?.trim()) {
const fallbackUsername = userIdentity.username;
resolveIdentityFromAzureToken(fallbackUsername)
.then((resolved) => {
if (resolved) {
userIdentity = resolved;
debugStartup(
`resolved user identity from Azure token: ${resolved.displayName}`,
);
}
})
.catch(() => {});
}

async function main() {
debugStartup(`pid=${process.pid} resolving instance dir + traceId`);
const [instanceDir, traceId] = await Promise.all([
Expand Down Expand Up @@ -289,6 +391,7 @@ async function main() {
);
},
shutdown: shutdownServer,
getUserIdentity: async () => userIdentity,
};

// Clean up all conversations on WebSocket disconnect
Expand Down
45 changes: 40 additions & 5 deletions ts/packages/agents/onboarding/src/packaging/packagingHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,14 @@ import { getPackagingModel } from "../lib/llm.js";
import { spawn } from "child_process";
import path from "path";
import fs from "fs/promises";
import { fileURLToPath } from "node:url";

// `<typeagent-root>` derived from this file's location.
// Compiled file path: <root>/packages/agents/onboarding/dist/src/packaging/packagingHandler.js
const TYPEAGENT_REPO_ROOT = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
"../../../../../..",
);

export async function executePackagingAction(
action: TypeAgentAction<PackagingActions>,
Expand Down Expand Up @@ -411,10 +419,16 @@ async function registerWithDispatcher(
integrationName: string,
agentDir: string,
): Promise<string> {
// Add agent to defaultAgentProvider config.json
const configPath = path.resolve(
agentDir,
"../../../../defaultAgentProvider/data/config.json",
// Add agent to defaultAgentProvider config.json. Anchor the config path
// at the TypeAgent monorepo root rather than walking up from agentDir —
// agents scaffolded outside the monorepo (e.g. in SecretAgents) sit at
// a different depth than `<root>/packages/agents/<name>`.
const configPath = path.join(
TYPEAGENT_REPO_ROOT,
"packages",
"defaultAgentProvider",
"data",
"config.json",
);

try {
Expand All @@ -426,10 +440,31 @@ async function registerWithDispatcher(
return `Agent "${integrationName}" is already registered in the dispatcher config.`;
}

config.agents[integrationName] = {
const agentEntry: Record<string, unknown> = {
name: `${integrationName}-agent`,
};

// For agents outside the monorepo, the dispatcher needs an explicit
// path (it can't resolve via npm). Emit a relative path from the
// defaultAgentProvider package, matching the existing convention
// (e.g. excel: "../../SecretAgents/ts/packages/agents/excel").
const insideMonorepo = path
.resolve(agentDir)
.startsWith(TYPEAGENT_REPO_ROOT + path.sep);
if (!insideMonorepo) {
const providerDir = path.join(
TYPEAGENT_REPO_ROOT,
"packages",
"defaultAgentProvider",
);
agentEntry.path = path
.relative(providerDir, agentDir)
.replace(/\\/g, "/");
agentEntry.execMode = "dispatcher";
}

config.agents[integrationName] = agentEntry;

await fs.writeFile(
configPath,
JSON.stringify(config, null, 2),
Expand Down
Loading
Loading