diff --git a/.claude/skills/add-teams/SKILL.md b/.claude/skills/add-teams/SKILL.md index 15791faa9..f1936946d 100644 --- a/.claude/skills/add-teams/SKILL.md +++ b/.claude/skills/add-teams/SKILL.md @@ -201,13 +201,34 @@ TEAMS_APP_TYPE=SingleTenant ### Who owns this bot The account signed into the Teams CLI is the account that just created the -bot — that human is the owner the wiring step needs. Its identity comes from -the CLI session, so this runs before the sign-out step below: +bot — that human is the wiring target this flow suggests. Its identity comes +from the CLI session, so this runs before the sign-out step below: ```nc:run effect:fetch when:have_creds=no capture:owner_upn=.username,owner_aad_id=.userObjectId validate:^.+$ "$(npm prefix -g 2>/dev/null)/bin/teams" status --json 2>/dev/null ``` +### Confirm the wiring target + +Nothing is wired without a yes here. Show the human who was detected, then +ask — a no skips the whole [Link the bot to your account](#link-the-bot-to-your-account) +chain, setup finishes unwired, and the note below explains how to wire the +right person afterwards: + +```nc:operator when:have_creds=no +Detected the account that created the bot: {{owner_upn}}. Wiring the assistant to it means its first message arrives in that account's Teams DMs. +``` + +```nc:prompt wire_owner when:have_creds=no validate:^(yes|no)$ normalize:lower +Wire the assistant to this account? +``` + +```nc:operator when:wire_owner=no +Setup will finish without wiring. To wire the right Teams user afterwards: +1. Easiest — no IDs needed: have that person DM the bot once in Teams ("hi" works). NanoClaw registers their identity and chat from that first message; then run /init-first-agent with your coding agent and pick them. +2. To wire proactively instead (the assistant messages them first), your coding agent needs that person's Microsoft Entra object ID — found at entra.microsoft.com > Users > (person) > Overview > Object ID, or Teams admin center > Manage users. Hand it to /manage-channels. +``` + ### Install the app in Teams The app package is already uploaded — no manifest zip, no manual sideload. @@ -223,7 +244,9 @@ Once the app shows up in your Teams sidebar (or app list), continue. ### Link the bot to your account -Nothing to do in Teams yet — these are background API calls. Same move as +Nothing to do in Teams yet — these are background API calls, and the whole +chain runs only after the yes in +[Confirm the wiring target](#confirm-the-wiring-target). Same move as Slack's `conversations.open` and Discord's `users/@me/channels`: create the bot↔owner 1:1 conversation proactively with the bot's own credentials, so the assistant messages the human first — nobody has to DM the @@ -233,7 +256,7 @@ below can fail once — re-running the skill is safe. First a Bot Framework token from the app credentials: -```nc:run effect:fetch when:have_creds=no capture:bot_token validate:^eyJ +```nc:run effect:fetch when:wire_owner=yes capture:bot_token validate:^eyJ curl -sf -X POST "https://login.microsoftonline.com/{{app_tenant_id}}/oauth2/v2.0/token" --data-urlencode "grant_type=client_credentials" --data-urlencode "client_id={{app_id}}" --data-urlencode "client_secret={{app_password}}" --data-urlencode "scope=https://api.botframework.com/.default" | jq -er '.access_token' ``` @@ -241,7 +264,7 @@ Create the 1:1 conversation (the AAD object id from the CLI session is a valid member id; `smba.trafficmanager.net/teams` is the global service URL — the same default the adapter itself uses): -```nc:run effect:fetch when:have_creds=no capture:conversation_id validate:^.+$ +```nc:run effect:fetch when:wire_owner=yes capture:conversation_id validate:^.+$ curl -sf -X POST "https://smba.trafficmanager.net/teams/v3/conversations" -H "Authorization: Bearer {{bot_token}}" -H "Content-Type: application/json" -d '{"bot":{"id":"28:{{app_id}}"},"members":[{"id":"{{owner_aad_id}}","name":"","role":"user"}],"tenantId":"{{app_tenant_id}}","channelData":{"tenant":{"id":"{{app_tenant_id}}"}},"isGroup":false}' | jq -er '.id' ``` @@ -253,24 +276,17 @@ filter only guards against channels that list the bot itself (`28:` ids). (Don't select by `.aadObjectId` here — the field is not reliably present in this response and its GUID casing varies.) -```nc:run effect:fetch when:have_creds=no capture:owner_handle=.id,owner_name=.name validate:^.+$ +```nc:run effect:fetch when:wire_owner=yes capture:owner_handle=.id,owner_name=.name validate:^.+$ curl -sf "https://smba.trafficmanager.net/teams/v3/conversations/{{conversation_id}}/members" -H "Authorization: Bearer {{bot_token}}" | jq -er '[.[] | select((.id // "") | startswith("28:") | not)][0] | {id, name: (.name // .givenName // "Teams user")}' ``` Compose the platform id exactly as the adapter encodes thread ids (`teams:{b64url conversation}:{b64url service url}`): -```nc:run when:have_creds=no capture:platform_id validate:^teams: +```nc:run when:wire_owner=yes capture:platform_id validate:^teams: node -e 'const c=process.argv[1];const s="https://smba.trafficmanager.net/teams/";console.log("teams:"+Buffer.from(c).toString("base64url")+":"+Buffer.from(s).toString("base64url"))' "{{conversation_id}}" ``` -Tell the user who the wiring targets — if this isn't them, they should stop -here and wire manually with `/manage-channels` instead: - -```nc:operator when:have_creds=no -The bot will be wired to {{owner_name}} ({{owner_upn}}) — the account that created it — and the assistant's first message will arrive in that account's Teams DMs. If that's not you, stop here (Ctrl-C) and wire manually with /manage-channels later. -``` - ### Sign out of the Teams CLI The Microsoft 365 session was only needed to create the bot and identify its @@ -309,9 +325,10 @@ skill outside the wizard? Run the same wire yourself: pnpm exec tsx scripts/init-first-agent.ts --channel teams --user-id "teams:" --platform-id "" --display-name "" --agent-name "" --role owner ``` -**Fallback (re-runs, or the link step failed):** with credentials already in -`.env` the resolve steps are skipped, so there is nothing new to wire — the -first run's wiring still stands. If the install was never wired at all, the +**Fallback (re-runs, a no at the wiring confirm, or the link step failed):** +with credentials already in `.env` the resolve steps are skipped, so there is +nothing new to wire — the first run's wiring still stands. If the install was +never wired at all — including a deliberate no at the confirm — the DM-first path always works: DM the bot once ("hi" is fine) — the router auto-creates the messaging group row in `data/v2.db` from that first inbound — then run `/init-first-agent` (or `/manage-channels`) with your coding diff --git a/scripts/skill-policy.test.ts b/scripts/skill-policy.test.ts index 68af0cc9c..bb42426a6 100644 --- a/scripts/skill-policy.test.ts +++ b/scripts/skill-policy.test.ts @@ -29,18 +29,20 @@ function operatorBody(md: string, n: number): string { describe('gatePolicy — §5.1 parity table (real skills)', () => { it('teams: one gate — the install-in-Teams operator pauses before the DM-open fetches', () => { - // Operators in order (CLI-first flow): prerequisites, install-in-Teams - // (when:have_creds=no), the wiring-identity note. The finish-wiring - // handoff is prose only — the wizard wires inline from the resolved vars. + // Operators in order (CLI-first flow): prerequisites, the detected-owner + // note, the wire-declined note (when:wire_owner=no), install-in-Teams. + // The finish-wiring handoff is prose only — the wizard wires inline from + // the resolved vars. const d = decisions(loadSkill('teams')); - expect(d).toHaveLength(3); + expect(d).toHaveLength(4); expect(d.map((g) => g.needsConfirm)).toEqual([ false, // prereqs → prompt public_url (prompt is the barrier) + false, // detected-owner note → prompt wire_owner (prompt is the barrier) + false, // wire-declined note → install operator (last operator of the chain carries the barrier) true, // install-in-Teams → the DM-open effect:fetch chain - false, // identity note → prompt signout (prompt is the barrier) ]); // Completed-work flavor (fetch/external, not effect:step). - expect(d[1].flavor).toBe('completed'); + expect(d[3].flavor).toBe('completed'); }); it('telegram: the pairing operator gains a readiness pause before the effect:step', () => { @@ -140,10 +142,11 @@ describe('extractOfferUrl — §5.2 inventory', () => { // The install block's URL is {{install_link}} in the AUTHORED body — no // candidate matches here; the offer materializes at runtime from the // rendered body (proven in run-channel-skill.test.ts's fresh-create case). - expect(operatorBody(md, 1)).toContain('{{install_link}}'); + expect(operatorBody(md, 3)).toContain('{{install_link}}'); expect(extractOfferUrl(operatorBody(md, 0))).toBeUndefined(); // prereqs - expect(extractOfferUrl(operatorBody(md, 1))).toBeUndefined(); // install-in-Teams - expect(extractOfferUrl(operatorBody(md, 2))).toBeUndefined(); // wiring-identity note + expect(extractOfferUrl(operatorBody(md, 1))).toBeUndefined(); // detected-owner note + expect(extractOfferUrl(operatorBody(md, 2))).toBeUndefined(); // wire-declined note (entra link is schemeless on purpose) + expect(extractOfferUrl(operatorBody(md, 3))).toBeUndefined(); // install-in-Teams }); it('slack — the placeholder is EXCLUDED (normative negative fixture)', () => { diff --git a/setup/channels/run-channel-skill.test.ts b/setup/channels/run-channel-skill.test.ts index e1ce9fbf4..fddd47e02 100644 --- a/setup/channels/run-channel-skill.test.ts +++ b/setup/channels/run-channel-skill.test.ts @@ -225,7 +225,7 @@ describe('runChannelSkill adapter (Option A)', () => { return { ok: true, fields: { STATUS: 'success' } }; }, resolveRemote: () => 'origin', - inputs: { public_url: 'https://acme.example', app_name: 'NanoClaw', signout: 'yes' }, + inputs: { public_url: 'https://acme.example', app_name: 'NanoClaw', wire_owner: 'yes', signout: 'yes' }, confirm: async (m) => { log.push(`confirm:${m}`); return true; @@ -245,7 +245,7 @@ describe('runChannelSkill adapter (Option A)', () => { 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 DM-open chain resolved the wire inputs: the owner's 29: id from the - // conversation members (selected by AAD id) and the adapter-encoded + // conversation members (first non-bot member) and the adapter-encoded // platform id from the created conversation… expect(res.vars.owner_handle).toBe('29:owner-xyz'); expect(res.vars.owner_name).toBe('Dan Mill'); @@ -273,6 +273,53 @@ describe('runChannelSkill adapter (Option A)', () => { expect(fullyApplied(res)).toBe(true); }); + // A "no" at the wiring confirm skips the whole DM-open chain: no Bot + // Framework token is fetched, no conversation is created, and the wire + // inputs stay unresolved — the wizard's deferred (DM-first) ending takes + // over. The create/install/env half still completes. + it('Teams fresh create, wiring declined: the DM-open chain never runs and nothing resolves', async () => { + const root = mkdtempSync(join(tmpdir(), 'rcs-teams-decline-')); + 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(`exec:${c}`); + if (c.includes('TEAMS_APP_ID=.')) return 'no'; + if (c.includes(' app create ')) { + return JSON.stringify({ + teamsAppId: 'tapp-123', + installLink: 'https://teams.microsoft.com/l/app/tapp-123', + credentials: { CLIENT_ID: 'app-1', CLIENT_SECRET: 'a-much-longer-app-secret', TENANT_ID: 'tenant-1' }, + }); + } + if (c.includes('status --json')) { + return JSON.stringify({ loggedIn: true, username: 'dan@acme.example', userObjectId: 'aad-owner-1' }); + } + }, + execStream: async () => ({ ok: true, fields: { STATUS: 'success' } }), + resolveRemote: () => 'origin', + inputs: { public_url: 'https://acme.example', app_name: 'NanoClaw', wire_owner: 'no', signout: 'yes' }, + confirm: async () => true, + openUrl: async () => {}, + }); + + // The chain never started: no token fetch, no conversation create… + expect(log.some((c) => c.includes('login.microsoftonline.com'))).toBe(false); + expect(log.some((c) => c.includes('/v3/conversations'))).toBe(false); + // …the wire inputs stayed unresolved (wireIfResolved will defer)… + expect(res.vars.owner_handle).toBeUndefined(); + expect(res.vars.platform_id).toBeUndefined(); + // …while the credentials still landed and nothing bounced to an agent. + expect(readFileSync(join(root, '.env'), 'utf8')).toContain('TEAMS_APP_ID=app-1'); + expect(res.agentTasks).toEqual([]); + expect(fullyApplied(res)).toBe(true); + }); + // The resolved leg of wireIfResolved, driven with a minimal fixture skill // (the real teams document needs a streaming exec runChannelSkill doesn't