From 37e2d2b2bb3537a720b649f9008c3580cd2ab10f Mon Sep 17 00:00:00 2001 From: Koshkoshinsk Date: Mon, 6 Jul 2026 10:13:46 +0300 Subject: [PATCH] add-teams: prompt for app/bot name and single-vs-multi tenant in both flows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The create step hardcoded --name NanoClaw and single-tenant; both are the human's choice. Two new nc:prompt fences (app_name, tenant) feed a tenant- branched create + env pairing — when:tenant=multi omits TEAMS_APP_TENANT_ID, encoding the 401 pairing rule in the branch itself. Client-secret name and a separate Azure bot name are NOT knobs on the CLI path (secret is auto-named 'default', 2y; --name covers registration+bot+Teams app) — documented, and the manual portal path now names all four asks explicitly. Co-Authored-By: Claude Fable 5 --- .claude/skills/add-teams/SKILL.md | 76 ++++++++++++++---------- setup/channels/run-channel-skill.test.ts | 50 +++++++++++++++- 2 files changed, 91 insertions(+), 35 deletions(-) diff --git a/.claude/skills/add-teams/SKILL.md b/.claude/skills/add-teams/SKILL.md index 65e54943e..5b60f53a9 100644 --- a/.claude/skills/add-teams/SKILL.md +++ b/.claude/skills/add-teams/SKILL.md @@ -110,6 +110,22 @@ reach this machine's webhook server (port 3000, configurable via Paste the public https:// base URL that forwards to this machine's port 3000 (no trailing path) — e.g. https://abcd1234.ngrok.io from `ngrok http 3000`. ``` +### App name and tenant + +Two more choices belong to the human before anything is created. The name is +used everywhere at once: the Entra app registration, the bot, and the Teams +app are all created under it. There is no client-secret name to pick on this +path — the CLI generates the secret itself (Entra displayName `default`, +2-year expiry); rotating it later is in [Troubleshooting](#troubleshooting). + +```nc:prompt app_name when:have_creds=no validate:^[\sA-Za-z0-9._-]{1,30}$ normalize:trim +What should the bot be called? One name covers the Entra app registration, the bot, and the Teams app (letters, digits, spaces, . _ -; max 30 characters) — e.g. NanoClaw. +``` + +```nc:prompt tenant when:have_creds=no validate:^(single|multi)$ normalize:lower +Who should be able to install the bot — answer "single" (only your own Microsoft 365 tenant; the safe default for a self-hosted assistant) or "multi" (any Microsoft 365 tenant). +``` + ### Install the Teams CLI Installed globally with npm — not as a workspace dependency — deliberately: @@ -153,32 +169,43 @@ the cache is not persisting (see Troubleshooting). One command registers the Entra app, generates a client secret (Graph can take ~30s to see the new app — the CLI retries), registers a Teams-managed bot, and uploads the app package to the Teams Developer Portal. It needs the sign-in -from the previous step (`AUTH_REQUIRED` means run that first). The bot is -created single-tenant (`--sign-in-audience myOrg`) — the safe default for a -self-hosted assistant; for a bot other Microsoft 365 tenants can install, see -[Alternatives](#alternatives). Change `--name` first if you want a different -bot name in Teams. +from the previous step (`AUTH_REQUIRED` means run that first). The tenant +answer picks the variant — they differ only in `--sign-in-audience`, and the +single-tenant one also captures the tenant ID (which the multi-tenant `.env` +pairing must omit). A `when:tenant=…` guard implies a fresh create: the tenant +prompt is only asked when the credentials probe answered no, so with existing +credentials both variants are skipped. -```nc:run effect:external when:have_creds=no capture:app_id=.credentials.CLIENT_ID,app_password=.credentials.CLIENT_SECRET,app_tenant_id=.credentials.TENANT_ID,teams_app_id=.teamsAppId,install_link=.installLink validate:^.+$ -"$(npm prefix -g)/bin/teams" app create --name "NanoClaw" --endpoint "{{public_url}}/webhook/teams" --sign-in-audience myOrg --json +```nc:run effect:external when:tenant=single capture:app_id=.credentials.CLIENT_ID,app_password=.credentials.CLIENT_SECRET,app_tenant_id=.credentials.TENANT_ID,teams_app_id=.teamsAppId,install_link=.installLink validate:^.+$ +"$(npm prefix -g)/bin/teams" app create --name "{{app_name}}" --endpoint "{{public_url}}/webhook/teams" --sign-in-audience myOrg --json +``` + +```nc:run effect:external when:tenant=multi capture:app_id=.credentials.CLIENT_ID,app_password=.credentials.CLIENT_SECRET,teams_app_id=.teamsAppId,install_link=.installLink validate:^.+$ +"$(npm prefix -g)/bin/teams" app create --name "{{app_name}}" --endpoint "{{public_url}}/webhook/teams" --sign-in-audience multipleOrgs --json ``` ### Store the credentials The adapter reads these from `.env` (set-if-absent — a value you've already -filled in is never overwritten). The pairing matters: `SingleTenant` requires -`TEAMS_APP_TENANT_ID`, and a multi-tenant app must instead set -`TEAMS_APP_TYPE=MultiTenant` with **no** tenant ID — a mismatch makes the -adapter authenticate against the wrong authority and every message fails with -a 401 from Bot Framework. +filled in is never overwritten). The pairing matters, and the tenant branch +encodes it: `SingleTenant` requires `TEAMS_APP_TENANT_ID`, and a multi-tenant +app must instead set `TEAMS_APP_TYPE=MultiTenant` with **no** tenant ID — a +mismatch makes the adapter authenticate against the wrong authority and every +message fails with a 401 from Bot Framework. -```nc:env-set when:have_creds=no +```nc:env-set when:tenant=single TEAMS_APP_ID={{app_id}} TEAMS_APP_PASSWORD={{app_password}} TEAMS_APP_TENANT_ID={{app_tenant_id}} TEAMS_APP_TYPE=SingleTenant ``` +```nc:env-set when:tenant=multi +TEAMS_APP_ID={{app_id}} +TEAMS_APP_PASSWORD={{app_password}} +TEAMS_APP_TYPE=MultiTenant +``` + ### Install the app in Teams The app package is already uploaded — no manifest zip, no manual sideload. @@ -233,31 +260,14 @@ once you've DM'd the bot, wire this channel with `/init-first-agent` (or ## Alternatives -### Multi-tenant bot - -The Credentials flow above creates a single-tenant bot (only your Microsoft 365 -tenant can install it). For a bot any tenant can install, create it without -`--sign-in-audience` (the CLI defaults to multi-tenant) and store the matching -env pairing — `MultiTenant` with **no** tenant ID: - -```bash -teams app create --name "NanoClaw" --endpoint "https://your-domain/webhook/teams" -``` - -```bash -TEAMS_APP_ID= -TEAMS_APP_PASSWORD= -TEAMS_APP_TYPE=MultiTenant -``` - -Run interactively, the CLI ends with an install menu — pick **Install in Teams**. - ### Manual Azure portal path For tenants where the Teams Developer Portal is blocked. Unlike the CLI path, the Azure Bot resource in step 3 requires an active **Azure subscription**. This is the classic walk; every value it produces maps onto the same `.env` -keys. +keys. The choices are the human's here just as on the CLI path — ask before +creating anything: the app registration name, single vs multi tenant, a +client secret description, and (this path only) a separate Azure Bot handle. 1. **App registration**: in https://portal.azure.com, search "App registrations" → "New registration". Name it (e.g. "NanoClaw"); Supported account types: diff --git a/setup/channels/run-channel-skill.test.ts b/setup/channels/run-channel-skill.test.ts index 57cf565c0..b6b344a39 100644 --- a/setup/channels/run-channel-skill.test.ts +++ b/setup/channels/run-channel-skill.test.ts @@ -210,7 +210,7 @@ describe('runChannelSkill adapter (Option A)', () => { return { ok: true, fields: { STATUS: 'success' } }; }, resolveRemote: () => 'origin', - inputs: { public_url: 'https://acme.example' }, + inputs: { public_url: 'https://acme.example', app_name: 'NanoClaw', tenant: 'single' }, confirm: async (m) => { log.push(`confirm:${m}`); return true; @@ -225,8 +225,10 @@ describe('runChannelSkill adapter (Option A)', () => { // invoked by absolute path — $(npm prefix -g)/bin/teams — so match loosely)… expect(steps.some((c) => c.includes('/bin/teams" login'))).toBe(true); expect(log.some((c) => c.startsWith('exec:') && c.includes(' login'))).toBe(false); - // …create got the collected public URL on the real /webhook/teams route… + // …create got the collected public URL on the real /webhook/teams route, + // and the prompted name + tenant choice landed as --name/--sign-in-audience… expect(log.some((c) => c.includes('--endpoint "https://acme.example/webhook/teams"'))).toBe(true); + expect(log.some((c) => c.includes('--name "NanoClaw"') && c.includes('--sign-in-audience myOrg'))).toBe(true); // …the captured credentials landed in .env with the safe SingleTenant pairing… const env = readFileSync(join(root, '.env'), 'utf8'); expect(env).toContain('TEAMS_APP_ID=12345678-1234-1234-1234-123456789abc'); @@ -247,6 +249,50 @@ describe('runChannelSkill adapter (Option A)', () => { expect(fullyApplied(res)).toBe(true); }); + // The multi-tenant leg of the tenant branch: tenant=multi must pick the + // multipleOrgs create variant and the MultiTenant env pairing, which omits + // TEAMS_APP_TENANT_ID entirely — writing it would flip the adapter to the + // wrong authority (the 401 pairing rule the document itself states). + it('Teams multi-tenant: tenant=multi drives the multipleOrgs create and the tenant-ID-free env pairing', async () => { + const root = mkdtempSync(join(tmpdir(), 'rcs-teams-multi-')); + mkdirSync(join(root, 'src/channels'), { recursive: true }); + writeFileSync(join(root, 'src/channels/index.ts'), '// barrel\n'); + writeFileSync(join(root, '.env'), ''); + writeFileSync(join(root, 'package.json'), '{"name":"scratch"}'); + + const log: string[] = []; + const res = await runSkill('.claude/skills/add-teams', { + projectRoot: root, + exec: (c) => { + log.push(c); + if (c.includes('TEAMS_APP_ID=.')) return 'no'; // the have_creds probe + if (c.includes(' app create ')) { + // TENANT_ID is still present in the CLI's JSON (the creating tenant) — + // the multi branch simply must not capture or store it. + return JSON.stringify({ + teamsAppId: 'tapp-456', + installLink: 'https://teams.microsoft.com/l/app/tapp-456?installAppPackage=true', + credentials: { CLIENT_ID: 'client-456', CLIENT_SECRET: 'secret-456', TENANT_ID: 'tenant-456' }, + }); + } + }, + execStream: async () => ({ ok: true, fields: { STATUS: 'success' } }), + resolveRemote: () => 'origin', + // app_name with a space also exercises the prompt's validate charset. + inputs: { public_url: 'https://acme.example', app_name: 'Acme Bot', tenant: 'multi' }, + confirm: async () => true, + openUrl: async () => undefined, + }); + + expect(log.some((c) => c.includes('--name "Acme Bot"') && c.includes('--sign-in-audience multipleOrgs'))).toBe(true); + expect(log.some((c) => c.includes('--sign-in-audience myOrg'))).toBe(false); // the single variant was skipped + const env = readFileSync(join(root, '.env'), 'utf8'); + expect(env).toContain('TEAMS_APP_ID=client-456'); + expect(env).toContain('TEAMS_APP_TYPE=MultiTenant'); + expect(env).not.toContain('TEAMS_APP_TENANT_ID'); + expect(fullyApplied(res)).toBe(true); + }); + // The engine reads `.claude/skills/add-/SKILL.md` relative to cwd (the // repo root in tests — same as the real add-slack the test above drives), so a // bounce-fixture skill is created there and torn down afterward.