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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
- `a365 setup admin` — new command for Global Administrators to complete tenant-wide AllPrincipals OAuth2 permission grants after `a365 setup all` has been run by an Agent ID Admin
### Changed
- `a365 publish` updates manifest IDs, creates `manifest.zip`, and prints concise upload instructions for Microsoft 365 Admin Center (Agents > All agents > Upload custom agent). Interactive prompts only occur in interactive terminals; redirect stdin to suppress them in scripts.
- App Service Plan SKU recommendation updated to B1 (Basic) for Node.js/TypeScript agents; F1 (Free) is now flagged as unsuitable due to the 230s cold-start limit being routinely exceeded during Oryx remote builds (#318)

### Fixed
- Node.js/TypeScript deployments: skip Oryx remote build when `dist/` already exists in the publish output, preventing `tsc: not found` failures caused by Oryx running `npm install --production` which excludes devDependencies (#318)
- Intermittent `ConnectionResetError (10054)` failures on corporate networks with TLS inspection proxies (Zscaler, Netskope) — Graph and ARM API calls now use direct MSAL.NET token acquisition instead of `az account get-access-token` subprocesses, bypassing the Python HTTP stack that triggered proxy resets (#321)
- `a365 cleanup` blueprint deletion now succeeds for Global Administrators even when the blueprint was created by a different user
- `a365 setup all` no longer times out for non-admin users — the CLI immediately surfaces a consent URL to share with an administrator instead of waiting for a browser prompt
Expand Down
23 changes: 15 additions & 8 deletions docs/agent365-guided-setup/a365-setup-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,13 +101,15 @@ Required **delegated** Microsoft Graph permissions (all must have **admin consen
| `DelegatedPermissionGrant.ReadWrite.All` | Grant delegated permissions |
| `Directory.Read.All` | Read directory data |

> **NOTE:** The `AgentIdentityBlueprint.*` delegated permission grants are visible in the Entra admin center. All delegated permission grants for this app are managed via the Graph API `oauth2PermissionGrants` endpoint.

If the app does not exist, permissions are missing, or admin consent has not been granted, see "What to do if validation fails" below.

**If validation fails** (app not found, permissions missing, or no admin consent):

1. STOP — do not proceed to run any `a365` CLI commands.
2. Inform the user the custom client app registration is missing or incomplete.
3. Direct the user to the official setup guide: register the app, configure as a Public client with redirect URI `http://localhost:8400`, add all five permissions above, and have a Global Admin grant admin consent.
3. Direct the user to the official setup guide: register the app, configure as a Public client with redirect URI `http://localhost:8400`, add all five permissions above, and apply all delegated permission grants via the Graph API `oauth2PermissionGrants` endpoint.
4. Wait for the user to confirm the app is properly configured, then re-run the same validation command above.

Save the `clientAppId` value — it will be used automatically in Step 3 (do NOT ask the user for it again).
Expand Down Expand Up @@ -559,12 +561,15 @@ Ask the user: **"Please review and update your manifest.json file with your agen

### Publish the agent manifest

Run `a365 publish`. This step updates the agent's manifest identifiers and publishes the agent package to Microsoft Online Services (specifically, it registers the agent with the Microsoft 365 admin center under your tenant). What this does:
Run `a365 publish`. This command updates the agent's manifest identifiers and packages the manifest files into a zip ready for **manual** upload to the Microsoft 365 Admin Center. What this does:

- Updates `manifest.json` and `agenticUserTemplateManifest.json` with your agent blueprint ID.
- Creates `manifest/manifest.zip` in your project directory.
- Prints the manual upload URL (Microsoft 365 Admin Center > Agents > All agents > Upload custom agent).

- It takes your project's `manifest.json` (which should define your agent's identity and capabilities) and updates certain identifiers in it (the CLI will inject the Azure AD application blueprint ID where needed).
- It then publishes the agent manifest/package to your tenant's catalog (so that the agent can be "hired" or installed in Teams and other apps).
> **Important:** `a365 publish` does **not** automatically upload to the Microsoft 365 Admin Center. After the command completes, you must upload `manifest.zip` manually through the admin center (a browser-only step). Follow the printed instructions or see the post-deployment section below.

Watch for output messages. Successful publish will indicate that the agent manifest is updated and that you can proceed to create an instance of the agent. If there's an error during publish, read it closely. For example, if the CLI complains about being unable to update some manifest or reach the admin center, ensure your account has the necessary privileges and that the custom app registration has the permissions for `Application.ReadWrite.All` (since publish might call Graph to update applications). Also, ensure your internet connectivity is good.
Watch for output messages indicating the package was created successfully. If there is an error, check that `agentBlueprintId` is populated in your config (run `a365 setup all` first if it is not).

### Deploy the agent code to Azure

Expand Down Expand Up @@ -620,9 +625,11 @@ Provide the user with the following instructions:

Provide the user with the following instructions:

1. Open **Teams > Apps** and search for your agent name
2. Select your agent and click **Request Instance** (or **Create Instance**)
3. Teams sends the request to your tenant admin for approval
> **Note (Frontier preview):** During Frontier preview, the blueprint may be discoverable via **Microsoft 365 Copilot > Apps** or **Teams > Apps** depending on tenant rollout. If the agent does not appear in one, try the other. After an instance is created, the agent becomes accessible in Teams chat.

1. Open **Microsoft 365 Copilot > Apps** or **Teams > Apps** and search for your agent name.
2. Select your agent and click **Request Instance** (or **Create Instance**).
3. Teams sends the request to your tenant admin for approval.

Admins can review and approve requests from the [Microsoft admin center - Requested Agents](https://admin.cloud.microsoft/#/agents/all/requested) page. After approval, Teams creates the agent instance and makes it available.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ public NodeBuildFailedException(string projectDirectory, string? npmErrorOutput)
{
"Run 'npm run build' locally in the project directory and fix any TypeScript/webpack/build errors.",
"Verify that the 'build' script is defined correctly in package.json.",
"If the error is 'tsc: not found', ensure TypeScript is installed by running 'npm install' " +
"(not 'npm install --production'). If 'typescript' is listed under 'devDependencies', " +
"it will not be installed when the --production flag is used.",
"If the build depends on environment variables or private packages, ensure those are configured on the machine running 'a365 deploy'.",
"After resolving the build issues, rerun 'a365 deploy'."
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -595,8 +595,12 @@ private string PromptForAppServicePlanSku(Agent365Config? existingConfig)
Console.WriteLine(" 4. S1 - Standard (auto-scale, staging slots)");
Console.WriteLine(" 5. P1V3 - Premium V3 (high performance)");
Console.WriteLine();
Console.WriteLine("NOTE: Free tier (F1) is recommended for development and testing.");
Console.WriteLine(" Basic tier (B1) often has zero quota by default - may require quota increase.");
Console.WriteLine("NOTE: B1 (Basic) is the recommended default. It handles the Node.js/TypeScript");
Console.WriteLine(" Oryx remote build (npm install + tsc) within the startup timeout.");
Console.WriteLine(" F1 (Free) has a 230s cold-start limit that is routinely exceeded by");
Console.WriteLine(" TypeScript agent projects during remote build — avoid F1 for Node.js/TS.");
Console.WriteLine(" B1 may have zero quota in new subscriptions — if creation fails, request");
Console.WriteLine(" a quota increase or try a different Azure region.");
Console.WriteLine();

var defaultSku = existingConfig?.AppServicePlanSku ?? ConfigConstants.DefaultAppServicePlanSku;
Expand Down
28 changes: 24 additions & 4 deletions src/Microsoft.Agents.A365.DevTools.Cli/Services/NodeBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -249,10 +249,30 @@ public async Task<OryxManifest> CreateManifestAsync(string projectDir, string pu
var buildValue = buildScript.GetString();
if (!string.IsNullOrWhiteSpace(buildValue))
{
// We always call through npm so it picks up the script from package.json
buildCommand = "npm run build";
buildRequired = true;
_logger.LogInformation("Detected build script; using Oryx build command: {Command}", buildCommand);
// If a TypeScript project's dist/ folder was already produced by the local build,
// do NOT ask Oryx to re-run npm run build on Azure. Azure App Service Oryx runs
// `npm install --production` which skips devDependencies, so tools like `tsc`
// (commonly in devDependencies) are not available, causing the Oryx build to fail
// with "sh: tsc: not found". When dist/ exists the compiled output is already in
// the publish package. We scope this skip to TypeScript projects (tsconfig.json
// present) to avoid silently skipping builds for JavaScript-only projects that
// use dist/ as a webpack/rollup output directory.
var distPath = Path.Combine(publishPath, "dist");
var tsConfigPath = Path.Combine(projectDir, "tsconfig.json");
if (Directory.Exists(distPath) && File.Exists(tsConfigPath))
{
buildCommand = "";
buildRequired = false;
_logger.LogInformation("dist/ folder found in publish output for TypeScript project; skipping npm run build step in Oryx manifest " +
"(TypeScript compiled locally - Oryx will still run npm install but will not invoke the build script).");
}
else
{
// We always call through npm so it picks up the script from package.json
buildCommand = "npm run build";
buildRequired = true;
_logger.LogInformation("Detected build script; using Oryx build command: {Command}", buildCommand);
}
}
}
else
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

using FluentAssertions;
using Microsoft.Agents.A365.DevTools.Cli.Services;
using Microsoft.Extensions.Logging;
using NSubstitute;

namespace Microsoft.Agents.A365.DevTools.Cli.Tests.Services;

public class NodeBuilderTests : IDisposable
{
private readonly ILogger<NodeBuilder> _logger;
private readonly NodeBuilder _builder;
private readonly List<string> _tempDirectories;

public NodeBuilderTests()
{
_logger = Substitute.For<ILogger<NodeBuilder>>();
var executorLogger = Substitute.For<ILogger<CommandExecutor>>();
var mockExecutor = Substitute.ForPartsOf<CommandExecutor>(executorLogger);
_builder = new NodeBuilder(_logger, mockExecutor);
_tempDirectories = new List<string>();
}

public void Dispose()
{
foreach (var dir in _tempDirectories)
{
if (Directory.Exists(dir))
Directory.Delete(dir, recursive: true);
}
}

[Fact]
public async Task CreateManifestAsync_TypeScriptProjectWithDistFolder_SkipsOryxBuild()
{
// Arrange
var projectDir = CreateTempDirectory();
var publishPath = CreateTempDirectory();

WritePackageJson(projectDir, buildScript: "tsc", startScript: "node dist/index.js");
File.WriteAllText(Path.Combine(projectDir, "tsconfig.json"), "{}");
Directory.CreateDirectory(Path.Combine(publishPath, "dist"));

// Act
var manifest = await _builder.CreateManifestAsync(projectDir, publishPath);

// Assert
manifest.BuildRequired.Should().BeFalse(
because: "when dist/ exists and tsconfig.json is present, TypeScript was compiled locally " +
"and Oryx must not re-run npm run build — Oryx's production install skips devDependencies " +
"so tsc would not be found, causing deployment failure");
manifest.BuildCommand.Should().BeEmpty(
because: "no build command should be set when the Oryx remote build is skipped");
}

[Fact]
public async Task CreateManifestAsync_TypeScriptProjectWithoutDistFolder_UsesOryxBuild()
{
// Arrange
var projectDir = CreateTempDirectory();
var publishPath = CreateTempDirectory();

WritePackageJson(projectDir, buildScript: "tsc", startScript: "node dist/index.js");
File.WriteAllText(Path.Combine(projectDir, "tsconfig.json"), "{}");
// No dist/ in publish output — TypeScript not yet compiled

// Act
var manifest = await _builder.CreateManifestAsync(projectDir, publishPath);

// Assert
manifest.BuildRequired.Should().BeTrue(
because: "when dist/ is absent the TypeScript project was not pre-compiled so Oryx must run npm run build");
manifest.BuildCommand.Should().Be("npm run build");
}

[Fact]
public async Task CreateManifestAsync_JavaScriptProjectWithDistFolder_UsesOryxBuild()
{
// Arrange
var projectDir = CreateTempDirectory();
var publishPath = CreateTempDirectory();

WritePackageJson(projectDir, buildScript: "webpack", startScript: "node dist/bundle.js");
// No tsconfig.json — JavaScript-only project with webpack producing dist/
Directory.CreateDirectory(Path.Combine(publishPath, "dist"));

// Act
var manifest = await _builder.CreateManifestAsync(projectDir, publishPath);

// Assert
manifest.BuildRequired.Should().BeTrue(
because: "JavaScript-only projects without tsconfig.json should still use Oryx remote build " +
"even when dist/ exists — skipping would be incorrect since the build script produces the bundle");
manifest.BuildCommand.Should().Be("npm run build");
}

[Fact]
public async Task CreateManifestAsync_WithoutBuildScript_DoesNotSetBuildRequired()
{
// Arrange
var projectDir = CreateTempDirectory();
var publishPath = CreateTempDirectory();

WritePackageJson(projectDir, buildScript: null, startScript: "node server.js");

// Act
var manifest = await _builder.CreateManifestAsync(projectDir, publishPath);

// Assert
manifest.BuildRequired.Should().BeFalse(
because: "no build script in package.json means Oryx only runs npm install, not a build step");
manifest.BuildCommand.Should().BeEmpty();
}

private static void WritePackageJson(string projectDir, string? buildScript, string startScript)
{
var scripts = buildScript is not null
? $@"""build"": ""{buildScript}"", ""start"": ""{startScript}"""
: $@"""start"": ""{startScript}""";

File.WriteAllText(Path.Combine(projectDir, "package.json"), $$"""
{
"scripts": {
{{scripts}}
}
}
""");
}

private string CreateTempDirectory()
{
var dir = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName());
Directory.CreateDirectory(dir);
_tempDirectories.Add(dir);
return dir;
}
}
Loading