Skip to content
Draft
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
25 changes: 25 additions & 0 deletions nodejs/copilot-sdk/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# Offline console telemetry is the default. Smoke ignores this file and all live flags.
ENABLE_A365_OBSERVABILITY=true
ENABLE_A365_OBSERVABILITY_EXPORTER=false
OTEL_LOG_LEVEL=ERROR
A365_OBSERVABILITY_LOG_LEVEL=error

COPILOT_MODEL=gpt-5-mini
COPILOT_TIMEOUT_MS=120000
# Optional eligible GitHub token; existing GH_TOKEN/GITHUB_TOKEN or CLI login also work.
# Never commit a real value.
COPILOT_GITHUB_TOKEN=
# Token-based runs isolate runtime state here, without modifying global Copilot auth/config.
# If overriding this default, prefer an absolute path outside the repository.
COPILOT_SAMPLE_HOME=.copilot-local
# Optional redacted local trace file. Must not already exist.
# Prefer an absolute path outside the repository. This example filename is ignored.
# COPILOT_TRACE_FILE=telemetry-live.json

AGENT365_AGENT_NAME=copilot-sdk-standalone
# Required ONLY for explicit live A365 export. Use the agent identity CLIENT ID, not object ID.
AGENT365_TENANT_ID=
AGENT365_BLUEPRINT_CLIENT_ID=
AGENT365_AGENT_ID=
# Blueprint credential; development only. Provide via secure environment injection.
AGENT365_CLIENT_SECRET=
5 changes: 5 additions & 0 deletions nodejs/copilot-sdk/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
.env*
!.env.example
.copilot-local/
.copilot-traces/
telemetry*.json
184 changes: 184 additions & 0 deletions nodejs/copilot-sdk/README.md

Large diffs are not rendered by default.

32 changes: 32 additions & 0 deletions nodejs/copilot-sdk/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
{
"name": "agent365-copilot-sdk-sample",
"version": "0.1.0",
"private": true,
"description": "Standalone Copilot SDK agent with application-side Agent 365 telemetry",
"type": "module",
"license": "MIT",
"engines": {
"node": ">=22.12.0"
},
"scripts": {
"build": "tsc -p tsconfig.json",
"test": "npm run build && node --test dist/test/*.test.js",
"smoke": "npm run build && node dist/src/index.js --smoke",
"runtime:check": "npm run build && node --env-file-if-exists=.env dist/src/index.js --runtime-check",
"start": "node --env-file-if-exists=.env dist/src/index.js"
},
"dependencies": {
"@azure/msal-node": "7.0.0",
"@github/copilot-sdk": "1.0.14",
"@microsoft/opentelemetry": "1.4.0",
"@opentelemetry/api": "1.9.1",
"@opentelemetry/core": "2.10.0",
"@opentelemetry/resources": "2.10.0",
"@opentelemetry/sdk-trace-base": "2.10.0",
"@opentelemetry/sdk-trace-node": "2.10.0"
},
"devDependencies": {
"@types/node": "24.13.5",
"typescript": "5.9.3"
}
}
101 changes: 101 additions & 0 deletions nodejs/copilot-sdk/src/agent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

import { randomUUID } from 'node:crypto';
import { resolve } from 'node:path';
import {
CopilotClient, ToolSet, type CopilotSession, type SessionConfig,
} from '@github/copilot-sdk';
import { RUNTIME_VERSION, runtimeEnvironment, type SampleConfig } from './config.js';
import { createTools } from './tools.js';
import type { InvocationTelemetry, SampleTelemetry } from './telemetry.js';

export function createClient(env: NodeJS.ProcessEnv = process.env): CopilotClient {
if (env.COPILOT_CLI_PATH) throw new Error('Unset COPILOT_CLI_PATH to use the pinned bundled runtime');
const token = env.COPILOT_GITHUB_TOKEN || env.GH_TOKEN || env.GITHUB_TOKEN;
return new CopilotClient({
mode: 'empty',
env: runtimeEnvironment(env),
...(token ? { baseDirectory: resolve(env.COPILOT_SAMPLE_HOME || '.copilot-local') } : {}),
...(token ? { gitHubToken: token, useLoggedInUser: false } : { useLoggedInUser: true }),
logLevel: 'error',
enableRemoteSessions: false,
});
}

export function sessionConfig(
config: SampleConfig, events: InvocationTelemetry,
): SessionConfig {
return {
sessionId: events.sessionId,
model: config.model,
tools: createTools(events),
availableTools: new ToolSet().addCustom('add_numbers').addCustom('fail_deliberately'),
onPermissionRequest: () => ({ kind: 'denied-no-approval-rule-and-could-not-request-from-user' }),
enableConfigDiscovery: false,
enableOnDemandInstructionDiscovery: false,
enableFileHooks: false,
enableHostGitOperations: false,
enableSessionStore: false,
enableSkills: false,
remoteSession: 'off',
mcpServers: {},
customAgents: [],
infiniteSessions: { enabled: false },
systemMessage: {
mode: 'replace',
content: 'You are a standalone arithmetic demonstration agent. Use add_numbers for addition. ' +
'Call fail_deliberately only when explicitly requested. Report tool failures honestly. ' +
'Never claim Agent 365 delivery or cloud registration. You have no filesystem, shell, or network tools.',
},
onEvent: event => events.onEvent(event),
};
}

export interface RuntimeClient {
start(): Promise<void>;
getStatus(): Promise<{ version: string; protocolVersion: number }>;
getAuthStatus(): Promise<{ isAuthenticated: boolean }>;
createSession(config: SessionConfig): Promise<Pick<CopilotSession, 'sendAndWait' | 'disconnect' | 'abort'>>;
stop(): Promise<Error[]>;
}

export async function runtimeStatus(client: RuntimeClient) {
await client.start();
const status = await client.getStatus();
if (status.version !== RUNTIME_VERSION) throw new Error('Runtime version does not match the pinned SDK runtime');
const auth = await client.getAuthStatus();
return { ...status, isAuthenticated: auth.isAuthenticated };
}

export async function runCopilot(
config: SampleConfig,
telemetry: SampleTelemetry,
prompt: string,
client: RuntimeClient = createClient(),
): Promise<string> {
try {
const status = await runtimeStatus(client);
if (!status.isAuthenticated) {
throw new Error('Copilot is not authenticated; provide an eligible GitHub token or existing CLI login');
}
return await telemetry.invoke(randomUUID(), async events => {
let session: Awaited<ReturnType<RuntimeClient['createSession']>> | undefined;
try {
session = await client.createSession(sessionConfig(config, events));
const response = await session.sendAndWait({ prompt }, config.timeoutMs);
events.assertSessionHealthy();
if (!response) throw new Error('Copilot returned no assistant response');
return response.data.content;
} catch (error) {
if (session) await session.abort();
throw error;
} finally {
if (session) await session.disconnect();
}
});
} finally {
const errors = await client.stop();
if (errors.length) throw new Error('Copilot runtime cleanup failed');
}
}
83 changes: 83 additions & 0 deletions nodejs/copilot-sdk/src/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

import { ConfidentialClientApplication, LogLevel } from '@azure/msal-node';
import type { LiveIdentity } from './config.js';

export const OBSERVABILITY_SCOPE = 'api://9b975845-388f-4429-889e-eab1ef63949c/.default';
const EXCHANGE_SCOPE = 'api://AzureADTokenExchange/.default';
const REFRESH_SKEW_MS = 5 * 60 * 1000;

export interface AccessToken {
accessToken: string;
expiresOn: Date | null;
}

export type AcquireAgentToken = () => Promise<AccessToken | null>;

export function createTokenResolver(
identity: LiveIdentity,
acquire: AcquireAgentToken = createMsalAcquirer(identity),
now: () => number = Date.now,
): (agentId: string, tenantId: string, scopes?: string[]) => Promise<string> {
let cached: AccessToken | undefined;
let pending: Promise<string> | undefined;
return async (agentId, tenantId, scopes) => {
if (agentId !== identity.agentId || tenantId !== identity.tenantId) {
throw new Error('A365 token resolver identity mismatch');
}
if (scopes?.some(scope => scope !== OBSERVABILITY_SCOPE)) {
throw new Error('A365 token resolver received an unexpected scope');
}
if (cached?.expiresOn && cached.expiresOn.getTime() - REFRESH_SKEW_MS > now()) {
return cached.accessToken;
}
if (!pending) {
pending = (async () => {
const result = await acquire();
if (!result?.accessToken || !result.expiresOn ||
result.expiresOn.getTime() - REFRESH_SKEW_MS <= now()) {
throw new Error('A365 token acquisition returned no usable token or expiry');
}
cached = result;
return result.accessToken;
})().finally(() => { pending = undefined; });
}
return pending;
};
}

function createMsalAcquirer(identity: LiveIdentity): AcquireAgentToken {
const authority = `https://login.microsoftonline.com/${identity.tenantId}`;
const system = { loggerOptions: { piiLoggingEnabled: false, logLevel: LogLevel.Error } };
const blueprint = new ConfidentialClientApplication({
auth: { authority, clientId: identity.blueprintClientId, clientSecret: identity.clientSecret },
system,
});
const agent = new ConfidentialClientApplication({
auth: {
authority,
clientId: identity.agentId,
clientAssertion: async () => {
const result = await blueprint.acquireTokenByClientCredential({
scopes: [EXCHANGE_SCOPE],
fmiPath: identity.agentId,
});
if (!result?.accessToken) throw new Error('Blueprint token was not returned');
return result.accessToken;
},
},
system,
});
return async () => {
try {
return await agent.acquireTokenByClientCredential({ scopes: [OBSERVABILITY_SCOPE] });
} catch (error) {
// Do not copy MSAL response bodies, assertions, or credential-bearing causes into logs.
const code = error instanceof Error && 'errorCode' in error &&
typeof error.errorCode === 'string' && /^[a-z0-9_]{1,80}$/i.test(error.errorCode)
? error.errorCode : 'token_acquisition_failed';
throw new Error(`A365 S2S authentication failed (${code}); check blueprint credentials, FMI identity and OtelWrite consent`);
}
};
}
86 changes: 86 additions & 0 deletions nodejs/copilot-sdk/src/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

import type { AgentDetails } from '@microsoft/opentelemetry';

export const SDK_VERSION = '1.0.14';
export const RUNTIME_VERSION = '1.0.85';

export interface LiveIdentity {
tenantId: string;
agentId: string;
blueprintClientId: string;
clientSecret: string;
}

export interface SampleConfig {
observability: boolean;
exportToA365: boolean;
agent: AgentDetails;
identity?: LiveIdentity;
model: string;
timeoutMs: number;
}

function flag(env: NodeJS.ProcessEnv, name: string, fallback: boolean): boolean {
const value = env[name];
if (value === undefined || value === '') return fallback;
if (value !== 'true' && value !== 'false') throw new Error(`${name} must be true or false`);
return value === 'true';
}

function required(env: NodeJS.ProcessEnv, name: string): string {
const value = env[name]?.trim();
if (!value || value.includes('<<')) throw new Error(`${name} is required for live A365 export`);
return value;
}

function guid(env: NodeJS.ProcessEnv, name: string): string {
const value = required(env, name);
if (!/^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/i.test(value)) {
throw new Error(`${name} must be a GUID`);
}
return value.toLowerCase();
}

export function loadConfig(env: NodeJS.ProcessEnv = process.env): SampleConfig {
const observability = flag(env, 'ENABLE_A365_OBSERVABILITY', true);
const exportToA365 = flag(env, 'ENABLE_A365_OBSERVABILITY_EXPORTER', false);
if (exportToA365 && !observability) {
throw new Error('Live export requires ENABLE_A365_OBSERVABILITY=true');
}
const timeoutMs = Number(env.COPILOT_TIMEOUT_MS ?? '120000');
if (!Number.isInteger(timeoutMs) || timeoutMs < 1000 || timeoutMs > 600000) {
throw new Error('COPILOT_TIMEOUT_MS must be an integer between 1000 and 600000');
}
const identity: LiveIdentity | undefined = exportToA365 ? {
tenantId: guid(env, 'AGENT365_TENANT_ID'),
agentId: guid(env, 'AGENT365_AGENT_ID'),
blueprintClientId: guid(env, 'AGENT365_BLUEPRINT_CLIENT_ID'),
clientSecret: required(env, 'AGENT365_CLIENT_SECRET'),
} : undefined;
if (identity && identity.agentId === identity.blueprintClientId) {
throw new Error('AGENT365_AGENT_ID must be the agent identity client ID, not the blueprint');
}
return {
observability,
exportToA365,
...(identity ? { identity } : {}),
agent: {
agentId: identity?.agentId ?? 'local-copilot-sdk',
tenantId: identity?.tenantId ?? 'local-only',
...(identity ? { agentBlueprintId: identity.blueprintClientId } : {}),
agentName: env.AGENT365_AGENT_NAME?.trim() || 'copilot-sdk-standalone',
providerName: 'github.copilot',
agentVersion: '0.1.0',
},
model: env.COPILOT_MODEL?.trim() || 'gpt-5-mini',
timeoutMs,
};
}

// The Copilot subprocess must not inherit the blueprint credential or exporter settings.
export function runtimeEnvironment(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
return Object.fromEntries(Object.entries(env).filter(([key]) =>
!/^(AGENT365_|A365_|ENABLE_A365_|OTEL_|AZURE_|APPLICATIONINSIGHTS_)/i.test(key)));
}
Loading
Loading