diff --git a/nodejs/copilot-sdk/.env.example b/nodejs/copilot-sdk/.env.example new file mode 100644 index 00000000..09c87b10 --- /dev/null +++ b/nodejs/copilot-sdk/.env.example @@ -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= diff --git a/nodejs/copilot-sdk/.gitignore b/nodejs/copilot-sdk/.gitignore new file mode 100644 index 00000000..53e431f5 --- /dev/null +++ b/nodejs/copilot-sdk/.gitignore @@ -0,0 +1,5 @@ +.env* +!.env.example +.copilot-local/ +.copilot-traces/ +telemetry*.json diff --git a/nodejs/copilot-sdk/README.md b/nodejs/copilot-sdk/README.md new file mode 100644 index 00000000..6b94983b --- /dev/null +++ b/nodejs/copilot-sdk/README.md @@ -0,0 +1,184 @@ +# Standalone Copilot SDK Agent - TypeScript Sample + +An **experimental**, local-first, single-invocation console agent using the released GitHub Copilot SDK. It demonstrates deterministic custom tools and application-side Agent 365 observability without changing the standalone runtime into a Teams host, AI Teammate, or Digital Worker. This is a development sample, not a production-ready service. + +For comprehensive documentation, visit the [Microsoft Agent 365 Developer Documentation](https://learn.microsoft.com/en-us/microsoft-agent-365/developer/). + +## What This Sample Demonstrates + +| Pattern | Where | +|---------|-------| +| Pinned SDK and bundled runtime; no global Copilot install | `package.json`, `src/config.ts` | +| Standalone session with only two custom tools; ambient discovery disabled | `src/agent.ts` | +| Deterministic `add_numbers` and intentional `fail_deliberately` | `src/tools.ts` | +| Correlated invocation/session/tool events, explicit identity, cleanup | `src/telemetry.ts` | +| Blueprint-to-agent S2S token resolver with expiry-aware caching | `src/auth.ts` | +| Network-free smoke and unit tests, including failure paths | `src/smoke.ts`, `test/sample.test.ts` | + +## Prerequisites + +- [Node.js](https://nodejs.org/) **22.12+** and [npm](https://docs.npmjs.com/). +- Released [`@github/copilot-sdk` **1.0.14**](https://github.com/github/copilot-sdk/releases/tag/v1.0.14). Its exact optional platform package **1.0.14** bundles Copilot runtime **1.0.85**. Keep optional dependencies enabled; do not set `COPILOT_CLI_PATH`. +- [`@microsoft/opentelemetry`](https://github.com/microsoft/opentelemetry-distro-javascript) **1.4.0** and `@azure/msal-node` **7.0.0**; all direct dependencies are exact pins. +- Only for actual model calls: an eligible GitHub Copilot account/token or existing CLI login, plus model access. Azure OpenAI credentials are not used. +- Only for A365 export: an existing Entra agent identity blueprint, its agent identity, a development blueprint credential, admin-approved application `Agent365.Observability.OtelWrite`, and a tenant eligible for ingestion. +- [Azure CLI](https://learn.microsoft.com/cli/azure/install-azure-cli) and [Agent 365 CLI](https://learn.microsoft.com/microsoft-agent-365/developer/agent-365-cli) are optional provisioning tools, **not runtime dependencies**. Provisioning requires appropriate tenant roles; smoke needs none. + +## Authentication + Identity + +| Aspect | Model | +|--------|-------| +| **Authentication** | App-based | +| **Identity** | Agent identity | + +GitHub authentication and A365 authentication are independent. Copilot uses `COPILOT_GITHUB_TOKEN`, `GH_TOKEN`, `GITHUB_TOKEN` (in that order), or the existing CLI login; it never logs in or changes saved credentials. + +For explicit A365 export, `src/auth.ts` implements the [documented autonomous agent flow](https://learn.microsoft.com/entra/agent-id/autonomous-agent-authentication-authorization-flow): MSAL requests a blueprint token for `api://AzureADTokenExchange/.default` with typed `fmiPath = agent identity client ID`, then uses that token as the agent application's `clientAssertion` to request `api://9b975845-388f-4429-889e-eab1ef63949c/.default`. The resolver validates agent/tenant/scope, caches to actual expiry minus five minutes, coalesces concurrent refreshes, and fails rather than returning empty or stale tokens. + +No interactive administrator token, OBO, agent user, or generic app-only token is substituted. The blueprint secret is **development-only**, never passed to the Copilot subprocess, and never included in telemetry. + +## Environment Configuration + +### Agent 365 Setup + +Provisioning is outside this sample. Obtain the tenant ID, blueprint **client ID**, agent identity **client ID** (not object ID), and a securely supplied blueprint credential from the tenant owner. The agent identity must have the Observability API application permission and tenant eligibility. + +Use the [autonomous agent authentication guide](https://learn.microsoft.com/entra/agent-id/autonomous-agent-authentication-authorization-flow) for identity and consent prerequisites. The standalone route does **not** require `a365 setup all`, bot registration, agent users, Teams manifests, or endpoints. + +Creating an Entra agent identity and registering an agent in the Agent 365 registry are distinct operations. Neither a configured identity nor successful telemetry delivery proves registry registration. This sample does not perform either operation. + +### Configuration + +Copy `.env.example` to `.env` or inject environment variables securely. Both `npm start` and `npm run runtime:check` load `.env` when present; process environment values take precedence. + +`.env` variants, the default `.copilot-local/` runtime directory, `.copilot-traces/`, and `telemetry*.json` are ignored by Git. For custom `COPILOT_SAMPLE_HOME` or `COPILOT_TRACE_FILE` values, prefer **absolute paths outside the repository**: arbitrary custom paths are not automatically ignored. Traces omit prompt content but can contain tenant/agent attribution, and runtime state can contain conversation data. Do not commit either, credentials, or captured console output. + +| Variable | Default / meaning | Set by | +|----------|-------------------|--------| +| `ENABLE_A365_OBSERVABILITY` | `true`; application tracing | Manual | +| `ENABLE_A365_OBSERVABILITY_EXPORTER` | **`false`**; explicitly set `true` to contact A365 | Manual | +| `OTEL_LOG_LEVEL` | `ERROR` in example; no verbose payload diagnostics | Manual | +| `A365_OBSERVABILITY_LOG_LEVEL` | `error` in example | Manual | +| `COPILOT_MODEL` | `gpt-5-mini`; requires account access | Manual | +| `COPILOT_TIMEOUT_MS` | `120000`; integer 1000-600000 | Manual | +| `COPILOT_GITHUB_TOKEN` | Optional; otherwise `GH_TOKEN`, `GITHUB_TOKEN`, existing login | Secure injection | +| `COPILOT_SAMPLE_HOME` | `.copilot-local`; isolated runtime state for token-based runs | Manual | +| `COPILOT_TRACE_FILE` | Optional new trace file; prefer an absolute path outside the repo; parent directory must exist; refuses overwrite | Manual | +| `AGENT365_AGENT_NAME` | `copilot-sdk-standalone` | Manual | +| `AGENT365_TENANT_ID` | Required GUID for live export | Tenant owner | +| `AGENT365_BLUEPRINT_CLIENT_ID` | Required blueprint application client ID | Tenant owner | +| `AGENT365_AGENT_ID` | Required **agent identity** application client ID, distinct from blueprint | Tenant owner | +| `AGENT365_CLIENT_SECRET` | Blueprint development credential; never commit | Secure injection | + +Missing or invalid live-export configuration is a hard error, not a silent console fallback. Offline telemetry uses clearly labelled `local-copilot-sdk` / `local-only` identifiers, not invented cloud IDs. + +## Running the Agent Locally + +### Quick start (Copilot; no A365) + +PowerShell, from this directory: + +```powershell +npm install +npm run build +npm test +npm run smoke +``` + +Use your organization's approved npm configuration. Following repository convention, generated `package-lock.json` stays local and ignored; it is not committed with this sample. Direct dependency versions are exact, but transitive resolutions can vary between fresh installations. Once `npm install` has generated a local lockfile, subsequent `npm ci` runs can reproduce that local resolution. A fresh checkout must use `npm install`, not `npm ci`. + +Smoke ignores **all** ambient export flags/credentials, does not launch Copilot, and makes no network calls. It exercises actual custom handlers and OTel scopes with **synthetic SDK events**, asserting `19 + 23 = 42`, an expected tool failure, three correlated spans, and an error status. It is not a live Copilot or ingestion test. + +For an actual model call using your existing GitHub credentials: + +```powershell +npm run runtime:check +npm start -- --prompt "Call add_numbers with a=19 and b=23, then report the result." +npm start -- --prompt "Call fail_deliberately once and report the failure honestly." +``` + +`runtime:check` starts the bundled runtime and reports version, protocol and authentication boolean, never the token or account name; it does not call a model or export A365 telemetry. The standalone session uses `mode: 'empty'`, allows only the two custom tools, and denies other permission requests. Config discovery, file hooks, host Git operations, shared session store, skills, MCP and remote-session export are disabled. With an environment token, its runtime home is isolated; existing-login fallback uses the existing home without modifying authentication settings. + +Model tool selection is not deterministic. Inspect the JSON evidence for `execute_tool add_numbers` or `execute_tool fail_deliberately`; an assistant answer alone does not prove a tool ran. The intentionally failing tool may be handled by the model, so a successful invocation can legitimately contain an ERROR tool span. + +### Local development (with A365 observability) + +Only after the tenant owner has supplied configuration and approved export: + +```powershell +Copy-Item .env.example .env +# Securely supply the four AGENT365 identity/credential values in .env or the process environment. +# Set ENABLE_A365_OBSERVABILITY_EXPORTER=true explicitly. +$env:COPILOT_TRACE_FILE = Join-Path ([System.IO.Path]::GetTempPath()) ("copilot-trace-" + [guid]::NewGuid() + ".json") +npm start -- --prompt "Call add_numbers with a=19 and b=23, then report the result." +``` + +Token acquisition is awaited before inference. Export uses the **S2S endpoint** and explicit token resolver. Flush and runtime cleanup failures return a nonzero exit code. + +No Playground, WebChat, Teams, dev tunnel, HTTP server, or Microsoft 365 Agents SDK host is required or supported in this standalone sample. + +### Troubleshooting + +| Symptom | Action | +|---------|--------| +| Missing runtime / version mismatch | Run `npm install` with optional dependencies enabled (`npm ci` only if a local lockfile already exists); unset `COPILOT_CLI_PATH`. Do not substitute an arbitrary CLI build. | +| `isAuthenticated: false` | Supply an eligible token or authenticate separately using the supported CLI workflow. This sample never opens a browser or initiates login. | +| Model access/timeout failure | Check account/model eligibility and `COPILOT_MODEL`; inspect the redacted trace. No response is replaced with fake success. | +| Missing `AGENT365_*` / authentication error | Use real agent and blueprint client IDs and a valid blueprint credential; confirm FMI relationship and application consent with the tenant owner. | +| Missing tool completion / correlation error | Invocation fails and pending spans close as ERROR. Check the pinned SDK/runtime rather than inventing timing boundaries. | +| Trace file already exists | Choose a new `COPILOT_TRACE_FILE`; existing evidence is not overwritten. | +| HTTP 200 but no portal telemetry | Inspect actual service sink results/tenant eligibility; transport acceptance is not ingestion proof. | + +## Deploying the Agent + +Deployment is deliberately out of scope. This sample does not publish, create resources, configure an endpoint, or install a Teams manifest. Production adoption needs a separately reviewed certificate or managed-identity/FIC credential provider replacing the development secret acquirer, lifecycle/termination handling, and operational export monitoring. Those production credential paths are **not implemented or claimed** here. + +## Observability + +`createTelemetry(config, tokenResolver?)` in `src/telemetry.ts` returns `invoke(sessionId, callback)`, `snapshot()` and `shutdown()`. Wrap session creation and `sendAndWait` with `invoke`; register `events.onEvent` before session creation and wrap deterministic tool handlers with `events.executeTool`. Always await shutdown. This helper source is shipped with the sample, not as a new package. + +The recommended Microsoft package supplies `InvokeAgentScope`, `ExecuteToolScope` and `Agent365Exporter`. An explicit standard `NodeTracerProvider` avoids unrelated distro auto-instrumentation. Each scope receives agent identity, blueprint and tenant directly; tool scopes carry an explicit invocation parent. SDK session/turn/tool/usage events are correlated onto the invocation, with duplicate-event protection and cleanup for incomplete calls. No `TurnContext` or hosting baggage helper is necessary. + +Prompts, tool arguments/results, reasoning, raw SDK errors, opaque API call IDs, and exception stacks are omitted from telemetry. The assistant's answer is printed separately to the console. Trace JSON includes only application-observed spans/events and numeric usage fields. + +**Limits:** timings cover the application invocation and observed tool lifecycle, not complete model inference. `assistant.usage` is post-hoc reporting, stored as an event; its duration is never used to reconstruct an inference span. Dynamic subagent visibility is incomplete and flattened under the root; a runtime subagent label is not an Entra agent identity. This is a one-prompt session, not a resumable server or durable trace pipeline. + +**Platform scope:** the local instructions use PowerShell. The SDK selects its platform-specific runtime during installation, but package availability does not establish a tested OS/architecture matrix. Validate the sample on your target platform; Node.js 22.12+ is a dependency requirement, not a claim that every supported Node release has been tested. + +**Delivery evidence:** exporter errors are surfaced, but the pinned exporter does not expose full service sink results through its public callback. `a365IngestionVerified` therefore remains **false** even when export completes. HTTP 200 and `partialSuccess.rejectedSpans=0` are insufficient if service sinks reject delivery. A tenant owner must separately verify ingestion and portal visibility; the sample does not perform eligibility/provisioning calls or supply a service-response observer. + +See the [Agent observability guide](https://learn.microsoft.com/en-us/microsoft-agent-365/developer/observability) and [Microsoft OTel migration guide](https://github.com/microsoft/opentelemetry-distro-javascript/blob/main/MIGRATION_A365.md). + +## Support + +For issues, questions, or feedback: + +- **Issues**: [GitHub Issues](https://github.com/microsoft/Agent365-Samples/issues) +- **Documentation**: [Microsoft Agent 365 Developer Documentation](https://learn.microsoft.com/en-us/microsoft-agent-365/developer/) +- **Security**: [SECURITY.md](../../SECURITY.md) + +## Contributing + +This project welcomes contributions and suggestions. Most contributions require you to agree to a Contributor License Agreement (CLA) declaring that you have the right to, and actually do, grant us the rights to use your contribution. For details, visit . + +When you submit a pull request, a CLA bot will automatically determine whether you need to provide a CLA and decorate the PR appropriately (e.g., status check, comment). Simply follow the instructions provided by the bot. You will only need to do this once across all repos using our CLA. + +This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). For more information see the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) or contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additional questions or comments. + +## Additional Resources + +- [Microsoft Agent 365 Developer Documentation](https://learn.microsoft.com/en-us/microsoft-agent-365/developer/) +- [Agent observability guide](https://learn.microsoft.com/en-us/microsoft-agent-365/developer/observability) +- [Copilot SDK repository](https://github.com/github/copilot-sdk) +- [Microsoft OpenTelemetry JavaScript distribution](https://github.com/microsoft/opentelemetry-distro-javascript) +- [Autonomous agent authentication](https://learn.microsoft.com/entra/agent-id/autonomous-agent-authentication-authorization-flow) + +## Trademarks + +*Microsoft, Windows, Microsoft Azure and/or other Microsoft products and services referenced in the documentation may be either trademarks or registered trademarks of Microsoft in the United States and/or other countries. The licenses for this project do not grant you rights to use any Microsoft names, logos, or trademarks. Microsoft's general trademark guidelines can be found at http://go.microsoft.com/fwlink/?LinkID=254653.* + +## License + +Copyright (c) Microsoft Corporation. All rights reserved. + +Licensed under the MIT License - see [LICENSE.md](../../LICENSE.md). diff --git a/nodejs/copilot-sdk/package.json b/nodejs/copilot-sdk/package.json new file mode 100644 index 00000000..4c9c9c0c --- /dev/null +++ b/nodejs/copilot-sdk/package.json @@ -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" + } +} diff --git a/nodejs/copilot-sdk/src/agent.ts b/nodejs/copilot-sdk/src/agent.ts new file mode 100644 index 00000000..0ee94810 --- /dev/null +++ b/nodejs/copilot-sdk/src/agent.ts @@ -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; + getStatus(): Promise<{ version: string; protocolVersion: number }>; + getAuthStatus(): Promise<{ isAuthenticated: boolean }>; + createSession(config: SessionConfig): Promise>; + stop(): Promise; +} + +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 { + 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> | 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'); + } +} diff --git a/nodejs/copilot-sdk/src/auth.ts b/nodejs/copilot-sdk/src/auth.ts new file mode 100644 index 00000000..53d29ddf --- /dev/null +++ b/nodejs/copilot-sdk/src/auth.ts @@ -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; + +export function createTokenResolver( + identity: LiveIdentity, + acquire: AcquireAgentToken = createMsalAcquirer(identity), + now: () => number = Date.now, +): (agentId: string, tenantId: string, scopes?: string[]) => Promise { + let cached: AccessToken | undefined; + let pending: Promise | 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`); + } + }; +} diff --git a/nodejs/copilot-sdk/src/config.ts b/nodejs/copilot-sdk/src/config.ts new file mode 100644 index 00000000..60ec3215 --- /dev/null +++ b/nodejs/copilot-sdk/src/config.ts @@ -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))); +} diff --git a/nodejs/copilot-sdk/src/index.ts b/nodejs/copilot-sdk/src/index.ts new file mode 100644 index 00000000..671754d8 --- /dev/null +++ b/nodejs/copilot-sdk/src/index.ts @@ -0,0 +1,74 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { writeFile } from 'node:fs/promises'; +import { parseArgs } from 'node:util'; +import { createClient, runCopilot, runtimeStatus } from './agent.js'; +import { createTokenResolver } from './auth.js'; +import { loadConfig, SDK_VERSION, RUNTIME_VERSION } from './config.js'; +import { runOfflineSmoke } from './smoke.js'; +import { createTelemetry } from './telemetry.js'; + +async function main(): Promise { + const { values } = parseArgs({ + options: { + smoke: { type: 'boolean' }, + 'runtime-check': { type: 'boolean' }, + prompt: { type: 'string' }, + }, + }); + if ([values.smoke, values['runtime-check'], values.prompt !== undefined].filter(Boolean).length !== 1) { + throw new Error('Choose exactly one: --smoke, --runtime-check, or --prompt "text"'); + } + if (values['runtime-check']) { + const client = createClient(); + try { + console.log(JSON.stringify({ mode: 'runtime-check', sdkVersion: SDK_VERSION, ...await runtimeStatus(client) })); + } finally { + if ((await client.stop()).length) throw new Error('Runtime cleanup failed'); + } + return; + } + // Smoke ignores ambient credentials/export flags; it never starts the runtime or makes network calls. + const config = loadConfig(values.smoke ? {} : process.env); + const tokenResolver = config.identity ? createTokenResolver(config.identity) : undefined; + if (config.identity && tokenResolver) { + await tokenResolver(config.identity.agentId, config.identity.tenantId); + } + const telemetry = createTelemetry(config, tokenResolver); + try { + if (values.smoke) { + await runOfflineSmoke(telemetry); + console.log('Offline smoke passed: deterministic success and expected tool failure; no live calls.'); + } else { + if (!values.prompt?.trim()) throw new Error('Prompt must not be empty'); + console.log(await runCopilot(config, telemetry, values.prompt)); + } + } finally { + const evidence = { + mode: values.smoke ? 'offline-synthetic-events' : 'live-copilot', + sdkVersion: SDK_VERSION, + runtimeVersion: RUNTIME_VERSION, + a365ExportRequested: config.exportToA365, + a365IngestionVerified: false, + spans: telemetry.snapshot(), + }; + try { + // Flush failure propagates; a successful callback is still not proof of sink ingestion. + await telemetry.shutdown(); + } finally { + console.log(JSON.stringify(evidence, null, 2)); + if (!values.smoke && process.env.COPILOT_TRACE_FILE) { + await writeFile(process.env.COPILOT_TRACE_FILE, JSON.stringify(evidence, null, 2), { flag: 'wx' }); + } + } + } +} + +main().catch((error: unknown) => { + // Never dump upstream SDK/MSAL error objects, prompts, assertions, or credentials. + const message = error instanceof Error ? error.message : ''; + const safe = /^(Choose exactly|Prompt must|AGENT365_|ENABLE_A365_|Live export requires|COPILOT_TIMEOUT_MS|Unset COPILOT_CLI_PATH|A365 S2S authentication failed|Copilot is not authenticated|Runtime version does not match)/.test(message); + console.error(safe ? message : 'Sample failed (runtime, tool, configuration, or export). No live ingestion is claimed.'); + process.exitCode = 1; +}); diff --git a/nodejs/copilot-sdk/src/smoke.ts b/nodejs/copilot-sdk/src/smoke.ts new file mode 100644 index 00000000..80df405b --- /dev/null +++ b/nodejs/copilot-sdk/src/smoke.ts @@ -0,0 +1,45 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import assert from 'node:assert/strict'; +import { randomUUID } from 'node:crypto'; +import type { SessionEvent } from '@github/copilot-sdk'; +import type { SampleTelemetry } from './telemetry.js'; +import { createTools } from './tools.js'; + +export function eventMetadata() { + return { id: randomUUID(), timestamp: new Date().toISOString(), parentId: null }; +} + +export async function runOfflineSmoke(telemetry: SampleTelemetry): Promise { + await telemetry.invoke('offline-smoke', async events => { + const tools = createTools(events); + for (const [index, tool] of tools.entries()) { + assert.ok(tool.handler); + const toolCallId = `offline-tool-${index}`; + const args = tool.name === 'add_numbers' ? { a: 19, b: 23 } : {}; + const started: SessionEvent = { + ...eventMetadata(), type: 'tool.execution_start', + data: { toolCallId, toolName: tool.name }, + }; + events.onEvent(started); + const action = () => Promise.resolve(tool.handler!(args, { + sessionId: 'offline-smoke', toolCallId, toolName: tool.name, arguments: args, + })); + if (tool.name === 'add_numbers') { + assert.equal(await action(), 42); + } else { + await assert.rejects(action, /Intentional deterministic tool failure/); + } + events.onEvent({ + ...eventMetadata(), type: 'tool.execution_complete', + data: { toolCallId, success: tool.name === 'add_numbers' }, + }); + } + }); + const spans = telemetry.snapshot(); + assert.equal(spans.length, 3); + assert.equal(spans.filter(span => span.status.code === 2).length, 1); + assert.equal(new Set(spans.map(span => span.traceId)).size, 1); + assert.ok(spans.every(span => span.attributes['microsoft.tenant.id'] === 'local-only')); +} diff --git a/nodejs/copilot-sdk/src/telemetry.ts b/nodejs/copilot-sdk/src/telemetry.ts new file mode 100644 index 00000000..ec83f524 --- /dev/null +++ b/nodejs/copilot-sdk/src/telemetry.ts @@ -0,0 +1,260 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { trace, type Span } from '@opentelemetry/api'; +import { ExportResultCode } from '@opentelemetry/core'; +import { resourceFromAttributes } from '@opentelemetry/resources'; +import { + BatchSpanProcessor, InMemorySpanExporter, SimpleSpanProcessor, AlwaysOffSampler, + type SpanExporter, type SpanProcessor, +} from '@opentelemetry/sdk-trace-base'; +import { NodeTracerProvider } from '@opentelemetry/sdk-trace-node'; +import { + Agent365Exporter, ExecuteToolScope, InvokeAgentScope, + type AgentDetails, type TokenResolver, +} from '@microsoft/opentelemetry'; +import type { SessionEvent, ToolInvocation } from '@github/copilot-sdk'; +import { OBSERVABILITY_SCOPE } from './auth.js'; +import { SDK_VERSION, RUNTIME_VERSION, type SampleConfig } from './config.js'; + +interface ToolSpan { + scope: ExecuteToolScope; + handlerFailed: boolean; +} + +function telemetryError(message: string): Error { + const error = new Error(message); + delete error.stack; + return error; +} + +export class InvocationTelemetry { + private readonly seen = new Set(); + private readonly tools = new Map(); + private readonly completed = new Set(); + private errorSeen = false; + private closed = false; + private toolFailures = 0; + private correlationGaps = 0; + + constructor( + readonly sessionId: string, + private readonly root: InvokeAgentScope, + private readonly span: Span, + private readonly agent: AgentDetails, + ) {} + + onEvent(event: SessionEvent): void { + if (this.closed || this.seen.has(event.id)) return; + this.seen.add(event.id); + const attributes: Record = { 'copilot.event.id': event.id }; + if (event.agentId) attributes['copilot.runtime.agent_id'] = event.agentId; + switch (event.type) { + case 'session.start': + case 'session.idle': + case 'assistant.turn_start': + case 'assistant.turn_end': + break; + case 'session.error': + this.errorSeen = true; + this.root.recordError(telemetryError('Copilot reported a session error')); + break; + case 'tool.execution_start': + if (this.completed.has(event.data.toolCallId)) { + this.correlationGaps++; + } else { + this.startTool(event.data.toolCallId, event.data.toolName, event.agentId); + } + attributes['gen_ai.tool.call.id'] = event.data.toolCallId; + break; + case 'tool.execution_complete': { + const id = event.data.toolCallId; + if (this.completed.has(id)) return; + const tool = this.tools.get(id); + attributes['gen_ai.tool.call.id'] = id; + attributes['copilot.tool.success'] = event.data.success; + if (!tool) { + this.correlationGaps++; + } else { + if (!event.data.success && !tool.handlerFailed) { + tool.scope.recordError(telemetryError('Copilot tool execution failed')); + this.toolFailures++; + } + tool.scope.dispose(); + this.tools.delete(id); + } + this.completed.add(id); + break; + } + case 'assistant.usage': + // Usage is post-hoc reporting, not an observable inference start/end boundary. + attributes['gen_ai.request.model'] = event.data.model; + for (const [key, value] of Object.entries({ + 'gen_ai.usage.input_tokens': event.data.inputTokens, + 'gen_ai.usage.output_tokens': event.data.outputTokens, + 'copilot.reported.duration_ms': event.data.duration, + 'copilot.usage.cache_read_tokens': event.data.cacheReadTokens, + 'copilot.usage.cache_write_tokens': event.data.cacheWriteTokens, + })) { + if (value !== undefined && Number.isFinite(value)) attributes[key] = value; + } + break; + default: + return; + } + // Deliberately omit prompts, arguments, outputs, reasoning and SDK error messages. + this.span.addEvent(event.type, attributes); + } + + private startTool(id: string, name: string, runtimeAgentId?: string): ToolSpan { + if (this.completed.has(id)) throw new Error('Tool call ID was reused after completion'); + const existing = this.tools.get(id); + if (existing) return existing; + const scope = ExecuteToolScope.start( + { sessionId: this.sessionId, conversationId: this.sessionId }, + { toolName: name, toolCallId: id, toolType: 'function' }, + this.agent, + undefined, + { parentContext: this.root.getSpanContext() }, + ); + scope.recordAttributes({ + 'copilot.timing.source': 'application_observed_tool_lifecycle', + ...(runtimeAgentId ? { 'copilot.runtime.agent_id': runtimeAgentId } : {}), + }); + const tool = { scope, handlerFailed: false }; + this.tools.set(id, tool); + return tool; + } + + async executeTool(invocation: ToolInvocation, action: () => T | Promise): Promise { + if (this.closed || invocation.sessionId !== this.sessionId) { + throw new Error('Tool invocation does not belong to the active session'); + } + const tool = this.startTool(invocation.toolCallId, invocation.toolName); + return tool.scope.withActiveSpanAsync(async () => { + try { + return await action(); + } catch (error) { + tool.handlerFailed = true; + this.toolFailures++; + tool.scope.recordError(telemetryError('Custom tool handler failed')); + throw error; + } finally { + tool.scope.recordAttributes({ 'copilot.handler.completed': true }); + } + }); + } + + assertSessionHealthy(): void { + if (this.errorSeen) throw new Error('Copilot session failed; inspect GitHub authentication and model access'); + if (this.correlationGaps) throw new Error('Copilot tool event correlation was incomplete'); + } + + close(): void { + if (this.closed) return; + this.closed = true; + for (const tool of this.tools.values()) { + tool.scope.recordError(telemetryError('Tool completion was not observed before invocation cleanup')); + tool.scope.recordCancellation('Invocation ended with an unfinished tool'); + tool.scope.dispose(); + this.correlationGaps++; + } + this.tools.clear(); + this.root.recordAttributes({ + 'copilot.tool.failures': this.toolFailures, + 'copilot.correlation.gaps': this.correlationGaps, + }); + if (this.correlationGaps > 0) this.root.recordError(telemetryError('Incomplete tool event correlation')); + } +} + +export function createTelemetry(config: SampleConfig, tokenResolver?: TokenResolver) { + if (config.exportToA365 && !tokenResolver) throw new Error('Live A365 export requires an explicit token resolver'); + const memory = new InMemorySpanExporter(); + const processors: SpanProcessor[] = [new SimpleSpanProcessor(memory)]; + let exportFailed = false; + if (config.exportToA365) { + const remote = new Agent365Exporter({ + tokenResolver: tokenResolver!, + useS2SEndpoint: true, + authScopes: [OBSERVABILITY_SCOPE], + clusterCategory: 'prod', + httpRequestTimeoutMilliseconds: 15000, + exporterTimeoutMilliseconds: 60000, + }); + const checked: SpanExporter = { + export: (spans, callback) => { + void remote.export(spans, result => { + if (result.code !== ExportResultCode.SUCCESS) exportFailed = true; + callback(result); + }); + }, + shutdown: () => remote.shutdown(), + forceFlush: () => remote.forceFlush(), + }; + processors.push(new BatchSpanProcessor(checked, remote.getBufferConfig())); + } + const provider = new NodeTracerProvider({ + resource: resourceFromAttributes({ + 'service.name': 'agent365-copilot-sdk-sample', + 'service.version': '0.1.0', + 'copilot.sdk.version': SDK_VERSION, + 'copilot.runtime.expected_version': RUNTIME_VERSION, + 'sample.export.mode': config.exportToA365 ? 'a365' : 'local-only', + }), + spanProcessors: processors, + ...(!config.observability ? { sampler: new AlwaysOffSampler() } : {}), + }); + provider.register(); + return { + async invoke(sessionId: string, action: (events: InvocationTelemetry) => Promise): Promise { + const scope = InvokeAgentScope.start( + { sessionId, conversationId: sessionId, channel: { name: 'console' } }, + {}, + config.agent, + ); + try { + return await scope.withActiveSpanAsync(async () => { + const span = trace.getActiveSpan(); + if (!span) throw new Error('Invocation span context was not established'); + const events = new InvocationTelemetry(sessionId, scope, span, config.agent); + try { + const result = await action(events); + events.close(); + events.assertSessionHealthy(); + return result; + } finally { + events.close(); + } + }); + } catch (error) { + scope.recordError(telemetryError('Copilot invocation failed')); + throw error; + } finally { + scope.dispose(); + } + }, + snapshot() { + return memory.getFinishedSpans().map(span => ({ + name: span.name, + traceId: span.spanContext().traceId, + spanId: span.spanContext().spanId, + parentSpanId: span.parentSpanContext?.spanId, + attributes: span.attributes, + events: span.events, + status: span.status, + durationMs: span.duration[0] * 1000 + span.duration[1] / 1e6, + })); + }, + async shutdown() { + try { + await provider.forceFlush(); + } finally { + await provider.shutdown(); + } + if (exportFailed) throw new Error('A365 exporter reported a failure; ingestion is not verified'); + }, + }; +} + +export type SampleTelemetry = ReturnType; diff --git a/nodejs/copilot-sdk/src/tools.ts b/nodejs/copilot-sdk/src/tools.ts new file mode 100644 index 00000000..7c300d55 --- /dev/null +++ b/nodejs/copilot-sdk/src/tools.ts @@ -0,0 +1,42 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { defineTool, type Tool } from '@github/copilot-sdk'; +import type { InvocationTelemetry } from './telemetry.js'; + +export function addNumbers(args: unknown): number { + if (typeof args !== 'object' || args === null || + !('a' in args) || !('b' in args) || + typeof args.a !== 'number' || typeof args.b !== 'number' || + !Number.isFinite(args.a) || !Number.isFinite(args.b) || + !Number.isFinite(args.a + args.b)) { + throw new Error('add_numbers requires finite numbers a and b with a finite sum'); + } + return args.a + args.b; +} + +export function failDeliberately(): never { + throw new Error('Intentional deterministic tool failure'); +} + +export function createTools(events: InvocationTelemetry): Tool[] { + return [ + defineTool('add_numbers', { + description: 'Add two finite numbers deterministically. No network or filesystem access.', + parameters: { + type: 'object', + properties: { a: { type: 'number' }, b: { type: 'number' } }, + required: ['a', 'b'], + additionalProperties: false, + }, + skipPermission: true, + handler: (args, invocation) => events.executeTool(invocation, () => addNumbers(args)), + }), + defineTool('fail_deliberately', { + description: 'Always fails. Use only when explicitly asked to exercise the failure path.', + parameters: { type: 'object', properties: {}, additionalProperties: false }, + skipPermission: true, + handler: (_args, invocation) => events.executeTool(invocation, failDeliberately), + }), + ]; +} diff --git a/nodejs/copilot-sdk/test/sample.test.ts b/nodejs/copilot-sdk/test/sample.test.ts new file mode 100644 index 00000000..c343a21f --- /dev/null +++ b/nodejs/copilot-sdk/test/sample.test.ts @@ -0,0 +1,392 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { test } from 'node:test'; +import { context, propagation, trace } from '@opentelemetry/api'; +import { ToolSet, type SessionConfig, type SessionEvent } from '@github/copilot-sdk'; +import { createTokenResolver, OBSERVABILITY_SCOPE, type AccessToken } from '../src/auth.js'; +import { createClient, runCopilot, sessionConfig, type RuntimeClient } from '../src/agent.js'; +import { loadConfig, runtimeEnvironment, SDK_VERSION, RUNTIME_VERSION, type LiveIdentity } from '../src/config.js'; +import { eventMetadata, runOfflineSmoke } from '../src/smoke.js'; +import { createTelemetry, type InvocationTelemetry, type SampleTelemetry } from '../src/telemetry.js'; +import { addNumbers, createTools } from '../src/tools.js'; + +async function withTelemetry(action: (telemetry: SampleTelemetry) => Promise) { + const telemetry = createTelemetry(loadConfig({})); + try { + await action(telemetry); + } finally { + await telemetry.shutdown(); + trace.disable(); + context.disable(); + propagation.disable(); + } +} + +function start(toolCallId: string, toolName = 'add_numbers'): SessionEvent { + return { ...eventMetadata(), type: 'tool.execution_start', data: { toolCallId, toolName } }; +} + +function complete(toolCallId: string, success = true): SessionEvent { + return { ...eventMetadata(), type: 'tool.execution_complete', data: { toolCallId, success } }; +} + +test('released SDK and bundled runtime match the recorded exact pins', async () => { + const sdk = JSON.parse(await readFile(resolve('node_modules', '@github', 'copilot-sdk', 'package.json'), 'utf8')); + assert.equal(sdk.version, SDK_VERSION); + assert.equal(sdk.copilotCliVersion, RUNTIME_VERSION); + const platforms = [ + 'darwin-arm64', 'darwin-x64', 'linux-arm64', 'linux-x64', + 'linuxmusl-arm64', 'linuxmusl-x64', 'win32-arm64', 'win32-x64', + ]; + assert.deepEqual(sdk.optionalDependencies, Object.fromEntries( + platforms.map(platform => [`@github/copilot-sdk-${platform}`, SDK_VERSION]), + )); +}); + +test('start and runtime preflight share optional .env loading and environment precedence', async () => { + const manifest = JSON.parse(await readFile(resolve('package.json'), 'utf8')); + const directory = await mkdtemp(join(tmpdir(), 'copilot-env-test-')); + const env = { ...process.env }; + delete env.COPILOT_MODEL; + try { + for (const script of [manifest.scripts.start, manifest.scripts['runtime:check']]) { + const command: string[] = script.split(' && ').at(-1).split(' '); + assert.deepEqual(command.slice(0, 3), ['node', '--env-file-if-exists=.env', 'dist/src/index.js']); + const args = [ + ...command.slice(1, 2), '-p', + 'JSON.stringify({model: process.env.COPILOT_MODEL ?? null})', + ]; + const run = (childEnv = env) => JSON.parse(execFileSync(process.execPath, args, { + cwd: directory, env: childEnv, encoding: 'utf8', + })); + assert.deepEqual(run(), { model: null }); + await writeFile(join(directory, '.env'), 'COPILOT_MODEL=fixture-model\n'); + assert.deepEqual(run(), { model: 'fixture-model' }); + assert.deepEqual(run({ ...env, COPILOT_MODEL: 'process-model' }), { model: 'process-model' }); + await rm(join(directory, '.env')); + } + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); + +test('offline smoke creates real scopes without a runtime or credentials', async () => { + await withTelemetry(runOfflineSmoke); +}); + +test('configuration is local by default; live configuration is explicit and strict', () => { + const config = loadConfig({}); + assert.equal(config.exportToA365, false); + assert.equal(config.agent.tenantId, 'local-only'); + assert.equal(config.identity, undefined); + assert.throws(() => loadConfig({ ENABLE_A365_OBSERVABILITY_EXPORTER: 'yes' }), /true or false/); + assert.throws(() => loadConfig({ ENABLE_A365_OBSERVABILITY_EXPORTER: 'true' }), /AGENT365_TENANT_ID/); + assert.throws(() => loadConfig({ + ENABLE_A365_OBSERVABILITY_EXPORTER: 'true', ENABLE_A365_OBSERVABILITY: 'false', + }), /requires/); + assert.throws(() => loadConfig({ COPILOT_TIMEOUT_MS: 'NaN' }), /integer/); + assert.throws(() => loadConfig({ COPILOT_TIMEOUT_MS: '999999999' }), /integer/); +}); + +test('live identity uses agent appId, not the blueprint or a placeholder', () => { + const env = { + ENABLE_A365_OBSERVABILITY_EXPORTER: 'true', + AGENT365_TENANT_ID: '11111111-1111-4111-8111-111111111111', + AGENT365_BLUEPRINT_CLIENT_ID: '22222222-2222-4222-8222-222222222222', + AGENT365_AGENT_ID: '33333333-3333-4333-8333-333333333333', + AGENT365_CLIENT_SECRET: 'unit-test-only-not-a-credential', + }; + const config = loadConfig(env); + assert.equal(config.agent.agentId, env.AGENT365_AGENT_ID); + assert.equal(config.agent.agentBlueprintId, env.AGENT365_BLUEPRINT_CLIENT_ID); + assert.equal(config.agent.tenantId, env.AGENT365_TENANT_ID); + assert.throws(() => loadConfig({ ...env, AGENT365_AGENT_ID: env.AGENT365_BLUEPRINT_CLIENT_ID }), /not the blueprint/); + assert.throws(() => loadConfig({ ...env, AGENT365_CLIENT_SECRET: '<>' }), /required/); + assert.throws(() => createTelemetry(config), /explicit token resolver/); +}); + +test('Copilot child environment excludes blueprint credentials and ambient telemetry settings', () => { + const env = runtimeEnvironment({ + PATH: 'safe-path', GH_TOKEN: 'fake-github-token', AGENT365_CLIENT_SECRET: 'fake-blueprint-secret', + AGENT365_TENANT_ID: 'tenant', OTEL_EXPORTER_OTLP_HEADERS: 'secret-headers', + AZURE_CLIENT_SECRET: 'fake-azure-secret', ENABLE_A365_OBSERVABILITY_EXPORTER: 'true', + }); + assert.deepEqual(env, { PATH: 'safe-path', GH_TOKEN: 'fake-github-token' }); + assert.throws(() => createClient({ COPILOT_CLI_PATH: 'unknown-runtime' }), /pinned/); +}); + +test('finite deterministic addition rejects invalid and overflowing input', () => { + assert.equal(addNumbers({ a: 19, b: 23 }), 42); + for (const args of [null, {}, { a: '19', b: 23 }, { a: Infinity, b: 1 }, { a: 1e308, b: 1e308 }]) { + assert.throws(() => addNumbers(args), /finite/); + } +}); + +test('out-of-order concurrent completions and duplicate events correlate by toolCallId', async () => { + await withTelemetry(async telemetry => { + await telemetry.invoke('correlation', async events => { + const first = start('a'); + events.onEvent(first); + events.onEvent(first); + events.onEvent(start('b')); + events.onEvent(complete('b')); + events.onEvent(complete('a')); + events.onEvent(complete('a')); + }); + const spans = telemetry.snapshot(); + assert.equal(spans.length, 3); + const root = spans.find(span => span.attributes['gen_ai.operation.name'] === 'invoke_agent')!; + const children = spans.filter(span => span.attributes['gen_ai.operation.name'] === 'execute_tool'); + assert.deepEqual(new Set(children.map(span => span.attributes['gen_ai.tool.call.id'])), new Set(['a', 'b'])); + assert.ok(children.every(span => span.parentSpanId === root.spanId && span.traceId === root.traceId)); + assert.ok(spans.every(span => span.attributes['gen_ai.agent.id'] === 'local-copilot-sdk')); + assert.ok(spans.every(span => span.attributes['microsoft.tenant.id'] === 'local-only')); + assert.equal(root.attributes['copilot.correlation.gaps'], 0); + }); +}); + +test('concurrent sessions have separate traces even when tool IDs match', async () => { + await withTelemetry(async telemetry => { + await Promise.all(['one', 'two'].map(session => telemetry.invoke(session, async events => { + events.onEvent(start('same-id')); + await Promise.resolve(); + events.onEvent(complete('same-id')); + }))); + const spans = telemetry.snapshot(); + assert.equal(spans.length, 4); + assert.equal(new Set(spans.map(span => span.traceId)).size, 2); + for (const session of ['one', 'two']) { + const own = spans.filter(span => span.attributes['gen_ai.conversation.id'] === session); + assert.equal(own.length, 2); + assert.equal(new Set(own.map(span => span.traceId)).size, 1); + } + }); +}); + +test('post-hoc usage stays an event and does not synthesize inference spans', async () => { + await withTelemetry(async telemetry => { + await telemetry.invoke('usage', async events => { + events.onEvent({ + ...eventMetadata(), type: 'assistant.usage', ephemeral: true, + data: { model: 'test-model', inputTokens: 5, outputTokens: 3, duration: 2000 }, + }); + }); + const [root] = telemetry.snapshot(); + assert.equal(telemetry.snapshot().length, 1); + const usage = root!.events.find(event => event.name === 'assistant.usage')!; + assert.equal(usage.attributes?.['gen_ai.usage.input_tokens'], 5); + assert.equal(usage.attributes?.['copilot.reported.duration_ms'], 2000); + assert.ok(root!.durationMs < 2000); + }); +}); + +test('tool errors are marked and raw arguments/results/error messages never enter telemetry', async () => { + await withTelemetry(async telemetry => { + await telemetry.invoke('failure', async events => { + events.onEvent({ ...eventMetadata(), type: 'tool.execution_start', data: { + toolCallId: 'bad', toolName: 'fail_deliberately', arguments: { secret: 'sensitive-input-marker' }, + } }); + const tool = createTools(events).find(tool => tool.name === 'fail_deliberately')!; + await assert.rejects(async () => tool.handler!({}, { + sessionId: 'failure', toolCallId: 'bad', toolName: tool.name, arguments: {}, + }), /Intentional/); + events.onEvent({ ...eventMetadata(), type: 'tool.execution_complete', data: { + toolCallId: 'bad', success: false, + error: { message: 'sensitive-error-marker' }, result: { content: 'sensitive-output-marker' }, + } }); + }); + const spans = telemetry.snapshot(); + assert.equal(spans.filter(span => span.status.code === 2).length, 1); + assert.doesNotMatch(JSON.stringify(spans), /sensitive-(input|error|output)-marker/); + assert.doesNotMatch(JSON.stringify(spans), /exception.stacktrace/); + assert.equal(spans.at(-1)!.attributes['copilot.tool.failures'], 1); + }); +}); + +test('missing completion closes dangling tools, marks errors and fails the invocation', async () => { + await withTelemetry(async telemetry => { + let captured: InvocationTelemetry | undefined; + await assert.rejects(telemetry.invoke('unfinished', async events => { + captured = events; + events.onEvent(start('never-finished')); + }), /correlation/); + const before = telemetry.snapshot(); + assert.equal(before.length, 2); + assert.ok(before.every(span => span.status.code === 2)); + captured!.onEvent(complete('never-finished')); + assert.equal(telemetry.snapshot().length, 2); + }); +}); + +test('orphan completion is reported instead of inventing a tool start time', async () => { + await withTelemetry(async telemetry => { + await assert.rejects(telemetry.invoke('orphan', async events => events.onEvent(complete('missing'))), /correlation/); + assert.equal(telemetry.snapshot().length, 1); + assert.equal(telemetry.snapshot()[0]!.attributes['copilot.correlation.gaps'], 1); + }); +}); + +test('runtime subagent labels cannot replace tenant or agent identity attribution', async () => { + await withTelemetry(async telemetry => { + await telemetry.invoke('subagent', async events => { + events.onEvent({ ...start('child'), agentId: 'runtime-only-label' }); + events.onEvent({ ...complete('child'), agentId: 'runtime-only-label' }); + }); + const tool = telemetry.snapshot()[0]!; + assert.equal(tool.attributes['copilot.runtime.agent_id'], 'runtime-only-label'); + assert.equal(tool.attributes['gen_ai.agent.id'], 'local-copilot-sdk'); + }); +}); + +test('standalone session is restricted to deterministic tools with no ambient hosting or discovery', async () => { + await withTelemetry(async telemetry => { + await telemetry.invoke('configuration', async events => { + const config = sessionConfig(loadConfig({}), events); + assert.ok(config.availableTools instanceof ToolSet); + assert.deepEqual(config.availableTools.toArray(), ['custom:add_numbers', 'custom:fail_deliberately']); + assert.equal(config.excludedTools, undefined); + assert.deepEqual(config.tools?.map(tool => tool.name), ['add_numbers', 'fail_deliberately']); + assert.ok(config.onPermissionRequest); + assert.deepEqual(await config.onPermissionRequest({ + kind: 'read', intention: 'fixture permission request', path: 'fixture.txt', + }, { sessionId: events.sessionId }), { + kind: 'denied-no-approval-rule-and-could-not-request-from-user', + }); + assert.equal(config.enableConfigDiscovery, false); + assert.equal(config.enableOnDemandInstructionDiscovery, false); + assert.equal(config.enableFileHooks, false); + assert.equal(config.enableSkills, false); + assert.equal(config.enableHostGitOperations, false); + assert.equal(config.enableSessionStore, false); + assert.equal(config.remoteSession, 'off'); + assert.deepEqual(config.mcpServers, {}); + assert.deepEqual(config.customAgents, []); + assert.deepEqual(config.infiniteSessions, { enabled: false }); + assert.equal(config.tools?.length, 2); + }); + }); +}); + +type RuntimeScenario = + | 'success' | 'start' | 'version' | 'create' | 'send' | 'unauthenticated' + | 'stop' | 'abort' | 'disconnect' | 'no-response' | 'session-error'; + +function mockClient(scenario: RuntimeScenario) { + const calls: string[] = []; + const client: RuntimeClient = { + async start() { calls.push('start'); if (scenario === 'start') throw new Error('startup failed'); }, + async getStatus() { return { version: scenario === 'version' ? '0.0.0' : RUNTIME_VERSION, protocolVersion: 3 }; }, + async getAuthStatus() { return { isAuthenticated: scenario !== 'unauthenticated' }; }, + async createSession(config: SessionConfig) { + calls.push('create'); + if (scenario === 'create') throw new Error('creation failed'); + return { + async sendAndWait() { + config.onEvent?.(start('pending')); + if (scenario === 'send' || scenario === 'abort') throw new Error('request timed out'); + config.onEvent?.(complete('pending')); + if (scenario === 'no-response') return undefined; + if (scenario === 'session-error') { + config.onEvent?.({ + ...eventMetadata(), type: 'session.error', + data: { errorType: 'query', message: 'sensitive-session-error-marker' }, + }); + } + return { + ...eventMetadata(), type: 'assistant.message' as const, + data: { content: '42', messageId: 'fixture-message' }, + }; + }, + async abort() { calls.push('abort'); if (scenario === 'abort') throw new Error('abort failed'); }, + async disconnect() { calls.push('disconnect'); if (scenario === 'disconnect') throw new Error('disconnect failed'); }, + }; + }, + async stop() { calls.push('stop'); return scenario === 'stop' ? [new Error('stop failed')] : []; }, + }; + return { client, calls }; +} + +test('successful mocked runtime returns the response and closes session and spans', async () => { + await withTelemetry(async telemetry => { + const { client, calls } = mockClient('success'); + assert.equal(await runCopilot(loadConfig({}), telemetry, 'test', client), '42'); + assert.deepEqual(calls, ['start', 'create', 'disconnect', 'stop']); + assert.equal(telemetry.snapshot().length, 2); + assert.ok(telemetry.snapshot().every(span => span.status.code !== 2)); + }); +}); + +for (const stage of [ + 'start', 'version', 'create', 'send', 'unauthenticated', 'stop', + 'abort', 'disconnect', 'no-response', 'session-error', +] as const) { + test(`failure cleanup after ${stage} always stops runtime and disconnects any created session`, async () => { + await withTelemetry(async telemetry => { + const { client, calls } = mockClient(stage); + await assert.rejects(runCopilot(loadConfig({}), telemetry, 'test', client)); + assert.equal(calls.at(-1), 'stop'); + if (stage === 'version' || stage === 'unauthenticated') { + assert.deepEqual(calls, ['start', 'stop']); + } + if (['send', 'abort', 'no-response', 'session-error'].includes(stage)) { + assert.deepEqual(calls.slice(-3), ['abort', 'disconnect', 'stop']); + } + if (stage === 'stop' || stage === 'disconnect') { + assert.deepEqual(calls.slice(-2), ['disconnect', 'stop']); + } + if (stage === 'send' || stage === 'abort') { + assert.equal(telemetry.snapshot().length, 2); + assert.ok(telemetry.snapshot().every(span => span.status.code === 2)); + } + assert.doesNotMatch(JSON.stringify(telemetry.snapshot()), /sensitive-session-error-marker/); + }); + }); +} + +const identity: LiveIdentity = { + tenantId: 'unit-tenant', agentId: 'unit-agent', blueprintClientId: 'unit-blueprint', + clientSecret: 'unit-test-not-a-credential', +}; + +test('resolver caches until expiry skew and coalesces concurrent requests', async () => { + let now = 1_000_000; + let acquisitions = 0; + const resolver = createTokenResolver(identity, async () => { + acquisitions++; + await Promise.resolve(); + return { accessToken: `unit-token-${acquisitions}`, expiresOn: new Date(now + 3600_000) }; + }, () => now); + const result = await Promise.all(Array.from({ length: 5 }, () => resolver(identity.agentId, identity.tenantId))); + assert.equal(new Set(result).size, 1); + assert.equal(acquisitions, 1); + now += 3400_000; + assert.equal(await resolver(identity.agentId, identity.tenantId), 'unit-token-2'); + assert.equal(acquisitions, 2); +}); + +test('resolver rejects mismatched identities/scopes and never returns empty tokens', async () => { + const resolver = createTokenResolver(identity, async () => null); + await assert.rejects(resolver('other-agent', identity.tenantId), /mismatch/); + await assert.rejects(resolver(identity.agentId, 'other-tenant'), /mismatch/); + await assert.rejects(resolver(identity.agentId, identity.tenantId, ['https://graph.microsoft.com/.default']), /scope/); + await assert.rejects(resolver(identity.agentId, identity.tenantId, [OBSERVABILITY_SCOPE]), /usable token/); +}); + +test('failed token acquisition is retried, and stale tokens never fall back to success', async () => { + let calls = 0; + const resolver = createTokenResolver(identity, async (): Promise => { + calls++; + if (calls === 1) throw new Error('unit auth failure'); + return { accessToken: 'unit-token', expiresOn: new Date(Date.now() + 3600_000) }; + }); + await assert.rejects(resolver(identity.agentId, identity.tenantId), /unit auth failure/); + assert.equal(await resolver(identity.agentId, identity.tenantId), 'unit-token'); + const expired = createTokenResolver(identity, async () => ({ accessToken: 'expired', expiresOn: new Date(0) })); + await assert.rejects(expired(identity.agentId, identity.tenantId), /usable token/); +}); diff --git a/nodejs/copilot-sdk/tsconfig.json b/nodejs/copilot-sdk/tsconfig.json new file mode 100644 index 00000000..8b09b6da --- /dev/null +++ b/nodejs/copilot-sdk/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "outDir": "dist", + "rootDir": ".", + "skipLibCheck": true, + "types": ["node"] + }, + "include": ["src/**/*.ts", "test/**/*.ts"] +}