Compare commits

...

8 Commits

Author SHA1 Message Date
Koshkoshinsk 8644e6bedb setup: verify treats a defer-wire channel awaiting its first DM as pending, not failed
Teams can't wire during setup (platform id only exists after the first
inbound), so a fresh teams-only install always ended 'A few things still
need your attention' + a debug offer. Zero groups now passes verify when
every configured channel is defer-wire and service+credentials are healthy;
the block gains WIRING=pending_first_dm and auto.ts prints a one-line
finish-wiring reminder instead. A wire-during-setup channel with zero
groups still fails.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 10:48:26 +03:00
Koshkoshinsk 571d72ba47 add-teams: silence pnpm-inherited npm config noise; document benign libsecret warning
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 10:46:21 +03:00
Koshkoshinsk 37e2d2b2bb add-teams: prompt for app/bot name and single-vs-multi tenant in both flows
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 <noreply@anthropic.com>
2026-07-06 10:13:46 +03:00
Koshkoshinsk ca298d3714 add-teams: invoke the Teams CLI by absolute path — npm's global bin dir is not reliably on PATH
Run #3 on the VM: the global install fully succeeded (bin linked, keytar
binary built — this npm's allowScripts gate is advisory-only), but the box
exposes node via hand-picked symlinks in ~/.local/bin and npm's prefix bin
dir (~/node/bin) is not on PATH, so every 'teams …' step died with command
not found. Classic custom-prefix gotcha, so fix it universally: the login
and create fences call "$(npm prefix -g)/bin/teams"; prose points manual
runs at the same path; new Troubleshooting entry for 'teams: command not
found'. Test matchers updated for the absolute-path command shape.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 01:05:49 +03:00
Koshkoshinsk d9859fd59a add-teams: install the Teams CLI globally via npm — pnpm's build-script policy breaks its credential store
Proven on the VM: as a workspace dep, keytar (msal-node-extensions' native
credential store, required at module load) never gets its binary because
pnpm's supply-chain policy skips dependency install scripts — the whole
store fails to import and teams.cli silently falls back to an in-memory
token cache, so every teams command after login starts logged out
(AUTH_REQUIRED ~1s into create). libsecret alone does not fix it; the
import dies before any keyring is touched.

npm install -g (pinned 3.0.2) runs keytar's install script in the global
env, keeps the workspace's onlyBuiltDependencies policy untouched, and
matches Microsoft's own install instruction. All commands drop the
pnpm exec prefix; REMOVE.md uninstalls globally after teams logout; the
AUTH_REQUIRED troubleshooting entry now names both causes (workspace
install, missing libsecret).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 00:57:04 +03:00
Koshkoshinsk ff557092d8 add-teams: verify sign-in persistence in the login step; diagnose libsecret + user-policy failures
Found on a headless Ubuntu box: teams login succeeded but the session died
with the process — libsecret is absent, so msal-node-extensions silently
falls back to an in-memory cache (debug-level log), and teams app create (a
fresh process) fails in ~1s with AUTH_REQUIRED, two steps away from the
cause.

- The login step now re-reads the session from a fresh process
  (teams status --json | grep loggedIn) before declaring success, so a
  non-persisting cache fails AT the sign-in step with prose naming the fix
  (apt install libsecret-1-0).
- Troubleshooting: new AUTH_REQUIRED-after-login entry; sideloading entry
  now distinguishes tenant-level from per-user App setup policy (the login
  output prints which one is blocking).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 00:31:12 +03:00
Koshkoshinsk f6901e01a4 add-teams: Teams-CLI-first credentials flow in SSF directive grammar
One sign-in and one create command replace the ~7-step Azure portal walk:

- have_creds probe runs FIRST (either credential key in .env skips every
  creation step — prompts included — so re-runs drop straight to Restart;
  either-key semantics prevent a partial .env from pairing a new app with
  stale keys, the guaranteed-401 mismatch).
- teams login runs as nc:run effect:step (browser locally, device code over
  SSH; body emits the terminal status block the streaming exec requires).
- teams app create --json is one effect:external with a five-var JSON
  capture (CLIENT_ID/CLIENT_SECRET/TENANT_ID + teamsAppId + installLink),
  validate:^.+$ so a shape drift bounces instead of writing empty creds.
  Single-tenant (--sign-in-audience myOrg) is the default pairing; the CLI
  registers a Teams-managed bot — no Azure subscription, no manifest zip,
  no manual sideload.
- Webhook endpoint corrected everywhere: /webhook/teams (was
  /api/webhooks/teams, which silently receives nothing).
- Portal walk + multi-tenant variant moved to prose Alternatives; RSC via
  teams app rsc add (CLI path) or manifest --rsc re-sideload (manual path).
- REMOVE.md: teams logout before CLI uninstall (MSAL cache outlives the
  package), Entra deletion required on both paths (it revokes the secret),
  drop the retired data/env mirror step.
- Parity tests rewritten: drop-through fixture (creds present → no create,
  no prompts, fail() spy), runSkill-level fresh-create fixture (injected
  execStream; proves capture→env-set chain + substituted install-link URL
  offer), probe semantics executed from the shipped document, policy gate
  inventory updated (3 operators, 1 barrier).
- claude-assist: point teams handoff file lists at files that exist on
  this branch.

Known deferrals (engine-owned, flagged for the SSF branch owner):
reuseFromEnv offers capture-var env keys on re-runs (needs a one-line
promptShape guard); effect:external is not run-health gated; captures
cannot be marked secret. docs/skill-engine-seam.md line anchors into the
old teams SKILL.md are stale (pre-existing anchoring style).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 00:12:28 +03:00
Koshkoshinsk 36a7e3cf81 teams-manifest: in-process zip writer, --rsc, independent-reader tests
- Write the app-package zip in-process (stored entries, byte-deterministic,
  fixed DOS timestamp) instead of shelling out to an external `zip` binary.
- Add the rsc option: authorization.permissions.resourceSpecific +
  webApplicationInfo (RSC consent binds to webApplicationInfo.id, not
  bots[].botId) and a manifest version bump so a re-upload supersedes the
  original package. Plumbed through teams-manifest-build.ts as --rsc.
- New test suite parses the zip with an independent minimal reader
  (EOCD → central directory → local headers) so a structure Teams would
  reject goes red; builds run in beforeAll with tmpdir cleanup.

Ported from the parked main-based commit (tag backup/teams-cli-main-cf56c10).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 00:12:03 +03:00
11 changed files with 820 additions and 250 deletions
+27 -8
View File
@@ -18,19 +18,38 @@ rm -f src/channels/teams.ts src/channels/teams-registration.test.ts
## 2. Remove credentials
Remove `TEAMS_APP_ID`, `TEAMS_APP_PASSWORD`, `TEAMS_APP_TENANT_ID`, and `TEAMS_APP_TYPE` from `.env`, then re-sync to the container:
```bash
mkdir -p data/env && cp .env data/env/env
```
## 3. Remove the package
Remove `TEAMS_APP_ID`, `TEAMS_APP_PASSWORD`, `TEAMS_APP_TENANT_ID`, and `TEAMS_APP_TYPE` from `.env`.
## 3. Sign out the Teams CLI, then remove the packages
`teams login` caches a Microsoft 365 session on disk that outlives the package —
sign out first (skip if the CLI was never installed):
```bash
teams logout
npm uninstall -g @microsoft/teams.cli
pnpm uninstall @chat-adapter/teams
```
## 4. Rebuild and restart
## 4. Remove local artifacts
```bash
rm -rf data/teams
```
## 5. Clean up cloud resources
Uninstall the app from Teams (Apps > Manage your apps). Then, on **both**
paths, delete the Entra app registration in Azure Portal > App registrations —
that is the step that actually revokes the client secret. Additionally:
- **Teams CLI path**: delete the app listing in the Teams Developer Portal
(https://dev.teams.microsoft.com/apps) — removing it there alone does NOT
revoke the secret.
- **Manual Azure path**: delete the Azure Bot resource, and the `nanoclaw-rg`
resource group if you created one (`az group delete --name nanoclaw-rg`).
## 6. Rebuild and restart
```bash
pnpm run build
+261 -168
View File
@@ -14,11 +14,14 @@ reads the prose and applies them, and a parser can apply them deterministically
from the same document. Every directive is idempotent, so the whole skill is
safe to re-run; anything a parser can't apply falls back to the prose beside it.
Teams is the most involved channel NanoClaw supports — there's no "paste a
token" shortcut. You'll walk through about seven Azure portal steps (app
registration, client secret, Azure Bot resource, messaging endpoint, Teams
channel, app package, sideload). Take them one at a time; the prompts below
collect each value as you produce it.
Teams has no "paste a token" shortcut — a bot has to exist in Microsoft's cloud
before it can receive a message. The Microsoft Teams CLI collapses that into
one sign-in and one create command: it registers the Entra app, generates the
client secret, registers a Teams-managed bot (through the Teams Developer
Portal — **no Azure subscription needed**), uploads the app package, and hands
back an install link. The old ~7-step Azure portal walk survives only as a
fallback in [Alternatives](#alternatives) for tenants where the Developer
Portal is blocked.
## Apply
@@ -71,139 +74,156 @@ runs.
## Credentials
The adapter is installed and registered, but it can't receive a message until a
bot exists in Azure, points at this machine, and is sideloaded into Teams. None
of those steps can be clicked through by a parser, so they're operator
instructions — relay each one, then collect the value it produces.
bot exists, points at this machine, and is installed into Teams. The Teams CLI
does all of that below.
Before you start, tell the user:
### Check for existing credentials
```nc:operator
Re-running `teams app create` provisions a brand-new app registration and bot
each time — it never reuses the first one. So the flow starts with a probe:
when `.env` already carries a Teams credential — either key; a partial pair
means a half-finished setup that creating ANOTHER app would only corrupt —
every step below (prompts included) is skipped and the flow drops straight
through to [Restart](#restart). To rotate credentials or finish a partial
configuration, see [Troubleshooting](#troubleshooting); if your tunnel URL
changed, the fix is `teams app update`, not a re-run (also in Troubleshooting).
```nc:run capture:have_creds
( grep -q '^TEAMS_APP_ID=.' .env 2>/dev/null || grep -q '^TEAMS_APP_PASSWORD=.' .env 2>/dev/null ) && echo yes || echo no
```
Before creating anything, tell the user:
```nc:operator when:have_creds=no
Confirm you have everything Teams setup needs:
1. A Microsoft 365 tenant where you can sideload custom apps — free personal Teams does NOT support this; you need a Microsoft 365 Business / EDU / developer tenant with Teams admin or developer rights.
2. A way to expose an HTTPS endpoint from this machine (ngrok, a Cloudflare Tunnel, or a reverse-proxied VPS). Azure Bot Service delivers activities to it.
1. A Microsoft 365 account that can create Entra app registrations and upload custom apps (sideloading) — free personal Teams does NOT qualify; you need a Microsoft 365 Business / EDU / developer tenant.
2. A way to expose an HTTPS endpoint that forwards to this machine's webhook port 3000 (ngrok, a Cloudflare Tunnel, or a reverse-proxied VPS). Start it now if it isn't running — e.g. `ngrok http 3000` — the create step needs the URL up front.
```
### Public URL
Azure Bot Service delivers messages to an HTTPS endpoint you control; it has to
reach this machine's webhook server (port 3000) at `/api/webhooks/teams`. If you
don't have a tunnel running yet, start one in another terminal first — e.g.
`ngrok http 3000` gives you `https://abcd1234.ngrok.io`.
Microsoft delivers bot messages to an HTTPS endpoint you control; it has to
reach this machine's webhook server (port 3000, configurable via
`WEBHOOK_PORT`) at `/webhook/teams`.
```nc:prompt public_url validate:^https:// normalize:rstrip-slash
Paste your public base URL (https://…, no trailing path) — e.g. https://abcd1234.ngrok.io.
```nc:prompt public_url when:have_creds=no validate:^https:// normalize:rstrip-slash
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`.
```
### Register the Azure app
### App name and tenant
Tell the user:
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:operator
Create the Azure AD app registration:
1. In https://portal.azure.com, search "App registrations" → "New registration".
2. Name it (e.g. "NanoClaw").
3. Supported account types: Single tenant (your org only — most common for self-host) OR Multi tenant (any Microsoft 365 tenant can add the bot).
4. Click Register.
5. On the Overview page, copy the Application (client) ID and, for a single-tenant app, the Directory (tenant) ID.
```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 app_id validate:^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$
Paste the Application (client) ID — App registration Overview page.
```
```nc:prompt app_type validate:^(SingleTenant|MultiTenant)$
Enter the app type — `SingleTenant` or `MultiTenant` (must match the account type you picked).
```
```nc:prompt app_tenant_id when:app_type=SingleTenant validate:^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$
Paste the Directory (tenant) ID — App registration Overview page (Single Tenant only).
```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).
```
### Create a client secret
### Install the Teams CLI
Tell the user:
Installed globally with npm — not as a workspace dependency — deliberately:
the CLI's credential store (keytar) is a native module whose install script
must run to fetch its prebuilt binary, and pnpm's supply-chain policy blocks
dependency build scripts — a workspace install leaves the sign-in unable to
persist. The global install matches Microsoft's own instruction and keeps the
workspace policy intact. Pinned; re-running is a no-op. (If npm reports
EACCES here, your global prefix needs root — prefer a user-level Node like
nvm, or `npm config set prefix ~/.npm-global`.) `--loglevel=error` because
npm runs inside a pnpm script here and warns about every pnpm config var it
inherits — pure noise; real errors still print.
```nc:operator
Create the client secret:
1. In your app registration, open "Certificates & secrets".
2. Click "New client secret" — Description "nanoclaw", Expires 180 days (recommended) or longer.
3. Click Add.
4. COPY THE VALUE NOW — Azure only shows it once (the Value column, not the Secret ID).
```nc:run effect:external when:have_creds=no
npm install -g @microsoft/teams.cli@3.0.2 --loglevel=error
```
```nc:prompt app_password secret validate:^.{20,}$
Paste the client secret Value — Certificates & secrets (shown only once, at least 20 characters).
npm's global bin directory is not reliably on PATH (custom prefixes rarely
are), so every step below calls the CLI by its absolute path,
`$(npm prefix -g)/bin/teams` (stderr of the prefix lookup silenced — same
pnpm-config noise as above). Where this document says to run `teams …` by
hand, use that path too if plain `teams` isn't found.
### Sign in to Microsoft 365
Every `teams` command is a separate process, so the sign-in must survive into
the next one via the CLI's on-disk token cache. On Linux that cache needs the
libsecret library — without it the session silently evaporates when the login
process exits, and the create step fails with `AUTH_REQUIRED`. Install it
first on Linux: `sudo apt-get install -y libsecret-1-0` (Debian/Ubuntu). A
"libsecret not found — token cache will be stored unencrypted" warning on a
headless box is expected and harmless even WITH libsecret installed: there is
no keyring daemon to talk to, so the CLI uses its plaintext cache file, which
persists fine (encryption-at-rest is all you give up). The
step below verifies persistence by re-reading the session from a fresh
process after login. In an interactive terminal the login opens a browser;
on a headless box (SSH) it prints a device code — open
microsoft.com/devicelogin on any machine and enter it. If this step fails,
run `teams login` then `teams status` by hand: status must say logged in, or
the cache is not persisting (see Troubleshooting).
```nc:run effect:step when:have_creds=no
"$(npm prefix -g 2>/dev/null)/bin/teams" login && "$(npm prefix -g 2>/dev/null)/bin/teams" status --json 2>/dev/null | grep -q '"loggedIn": true' && printf '=== NANOCLAW SETUP: TEAMS-LOGIN ===\nSTATUS: success\n=== END ===\n'
```
### Create the bot
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 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: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 2>/dev/null)/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 2>/dev/null)/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, so a value you've already
filled in is never overwritten).
`TEAMS_APP_TENANT_ID` is written only for a Single Tenant app; Multi Tenant
doesn't need it.
The adapter reads these from `.env` (set-if-absent a value you've already
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
```nc:env-set when:tenant=single
TEAMS_APP_ID={{app_id}}
TEAMS_APP_PASSWORD={{app_password}}
TEAMS_APP_TYPE={{app_type}}
```
```nc:env-set when:app_type=SingleTenant
TEAMS_APP_TENANT_ID={{app_tenant_id}}
TEAMS_APP_TYPE=SingleTenant
```
### Create the Azure Bot resource
```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.
Tell the user:
```nc:operator
Create the Azure Bot resource and point it at this machine:
1. In https://portal.azure.com, search "Azure Bot" → Create.
2. Bot handle: a unique name, e.g. nanoclaw-bot.
3. Type of App: {{app_type}} — Creation type: Use existing app registration.
4. App ID: {{app_id}}.
5. After creating, open the bot → Configuration and set Messaging endpoint to {{public_url}}/api/webhooks/teams, then Apply.
```
### Enable the Teams channel
Tell the user (finish every Azure step above before continuing — the package
built next bakes in the app registration, so the Azure app and bot must already
exist):
```nc:operator
Enable the Microsoft Teams channel on the bot:
1. Open your Azure Bot resource → Channels.
2. Click Microsoft Teams → Accept terms → Apply.
When the Azure app, bot resource, and Teams channel are all in place, continue.
```
### Build the Teams app package
The manifest bakes in the Application (client) ID, so the Azure app registration
above must be done first — building with a blank `app_id` produces a package that
no bot can claim. Confirm it's set before generating the zip:
```nc:run effect:check
[ -n "{{app_id}}" ]
```
Generate the zip you'll sideload into Teams (manifest + icons, written to
`data/teams/teams-app-package.zip`). Re-running regenerates a fresh zip, so this
is safe to repeat.
```nc:run effect:external
pnpm exec tsx setup/channels/teams-manifest-build.ts --app-id "{{app_id}}" --url "{{public_url}}"
```
### Sideload the app into Teams
Tell the user (do this before the restart below — the service should come up with
the app already sideloaded):
```nc:operator
Sideload the generated app package into Teams:
1. Open Microsoft Teams → Apps → Manage your apps → Upload an app.
2. Click "Upload a custom app" (or "Upload for me or my teams").
3. Select data/teams/teams-app-package.zip and click Add.
4. If "Upload a custom app" is missing, your tenant admin has disabled sideloading — enable it in Teams Admin Center → Teams apps → Setup policies → Global → Upload custom apps = On.
Once the app is added in Teams, continue.
```nc:operator when:have_creds=no
Install the bot into Teams:
1. Open {{install_link}} — Teams opens with the app's install dialog. Click Add.
2. If you need the link again later, run: teams app get {{teams_app_id}} --install-link
3. If Teams refuses with a custom-app-upload error, a tenant admin must enable sideloading: Teams Admin Center > Teams apps > Setup policies > Global > "Upload custom apps" = On.
Once the app shows up in your Teams sidebar (or app list), continue.
```
## Restart
@@ -224,7 +244,7 @@ bot has seen its first message. Tell the user:
```nc:operator
The Teams adapter is live and the service is running. One thing is left: your Teams bot's platform ID (which NanoClaw needs to wire it to an agent group) only becomes known after you DM the bot for the first time. To finish:
1. Find your bot in Teams (search by name, or via the app you just sideloaded) and send it a message ("hi" is fine).
1. Find your bot in Teams (search by name, or via the app you just installed) and send it a message ("hi" is fine).
2. Tail logs/nanoclaw.log for the inbound — the router auto-creates a row in messaging_groups in data/v2.db.
3. Run scripts/init-first-agent.ts with --channel teams, the discovered platform_id, and your AAD user id — OR run /manage-channels to wire it interactively.
```
@@ -247,57 +267,54 @@ once you've DM'd the bot, wire this channel with `/init-first-agent` (or
## Alternatives
### Auto: Teams CLI
### Manual Azure portal path
The Credentials flow above walks the manual Azure Portal path. If you'd rather
script it, the Microsoft Teams CLI creates the Entra app, client secret, and bot
registration in one command. Requires Node.js 18+, a Microsoft 365 account with
sideloading permissions, and a public HTTPS endpoint (ngrok, Cloudflare Tunnel,
or similar).
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. 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. Install the CLI:
1. **App registration**: in https://portal.azure.com, search "App registrations"
→ "New registration". Name it (e.g. "NanoClaw"); Supported account types:
Single tenant (most common for self-host) or Multi tenant. From the Overview
page copy the **Application (client) ID** and — single tenant only — the
**Directory (tenant) ID**.
2. **Client secret**: in the app registration, "Certificates & secrets" → "New
client secret" (expires 180 days or longer). **Copy the Value now** — Azure
shows it once (the Value column, not the Secret ID).
3. **Azure Bot resource**: search "Azure Bot" → Create. Bot handle: any unique
name; Type of App: must match step 1; Creation type: "Use existing app
registration" with the App ID from step 1. After creating, open the bot →
Configuration and set **Messaging endpoint** to
`https://your-domain/webhook/teams`, then Apply.
4. **Enable the Teams channel**: Azure Bot resource → Channels → Microsoft
Teams → Accept terms → Apply.
5. **Store the credentials** in `.env` (the same 401 pairing rule applies —
`SingleTenant` needs the tenant ID, `MultiTenant` must omit it):
```bash
npm install -g @microsoft/teams.cli@preview
TEAMS_APP_ID=<Application (client) ID>
TEAMS_APP_PASSWORD=<client secret Value>
TEAMS_APP_TYPE=SingleTenant
TEAMS_APP_TENANT_ID=<Directory (tenant) ID>
```
2. Sign in and verify:
6. **Build the app package** (manifest + icons, written in-process to
`data/teams/teams-app-package.zip` — no `zip` binary needed):
```bash
teams login
teams status
pnpm exec tsx setup/channels/teams-manifest-build.ts --app-id YOUR_APP_ID --url https://your-domain
```
7. **Sideload**: Microsoft Teams → Apps → Manage your apps → Upload an app →
"Upload a custom app" → select the zip → Add.
8. Continue from [Restart](#restart).
3. Create the Entra app, client secret, and bot registration:
```bash
teams app create \
--name "NanoClaw" \
--endpoint "https://your-domain/api/webhooks/teams"
```
The CLI prints the credentials as `CLIENT_ID`, `CLIENT_SECRET`, and `TENANT_ID`. Map them to NanoClaw's env keys:
- `CLIENT_ID` → `TEAMS_APP_ID`
- `CLIENT_SECRET` → `TEAMS_APP_PASSWORD`
- `TENANT_ID` → `TEAMS_APP_TENANT_ID`
4. Pick **Install in Teams** from the post-create menu and confirm in the Teams dialog.
### Azure CLI for the bot resource
The Azure Bot resource and Teams channel can also be created with `az` instead of
clicking through the portal:
Or create the bot resource with the Azure CLI instead of the portal:
```bash
az group create --name nanoclaw-rg --location eastus
az bot create \
--resource-group nanoclaw-rg \
--name nanoclaw-bot \
--app-type SingleTenant \
--appid YOUR_APP_ID \
--tenant-id YOUR_TENANT_ID \
--endpoint "https://your-domain/api/webhooks/teams"
az bot create --resource-group nanoclaw-rg --name nanoclaw-bot --app-type SingleTenant --appid YOUR_APP_ID --tenant-id YOUR_TENANT_ID --endpoint "https://your-domain/webhook/teams"
az bot msteams create --resource-group nanoclaw-rg --name nanoclaw-bot
```
@@ -305,43 +322,119 @@ az bot msteams create --resource-group nanoclaw-rg --name nanoclaw-bot
### Receive all channel messages (without @-mention)
By default the bot only receives messages when @-mentioned. To receive every
message in a channel, add an RSC (resource-specific consent) permission to your
Teams app `manifest.json`:
By default the bot only receives messages when @-mentioned. With a CLI-created
bot, grant the resource-specific-consent (RSC) permissions directly — no
manifest edit, no re-upload; the app version is bumped automatically:
```json
{
"authorization": {
"permissions": {
"resourceSpecific": [
{ "name": "ChannelMessage.Read.Group", "type": "Application" }
]
}
}
}
```bash
teams app rsc add <teams-app-id> ChannelMessage.Read.Group --type Application
teams app rsc add <teams-app-id> ChatMessage.Read.Chat --type Application
```
Re-sideload the updated app package for the change to take effect.
Then update/reinstall the app in the team so the new permissions get consented.
(`<teams-app-id>` is the Teams App ID shown in the install step — recover it
any time with `teams app list`, or find the app at
https://dev.teams.microsoft.com/apps.)
On the manual path, regenerate the package with RSC baked in and sideload it
again (the manifest version is bumped so the upload supersedes the original):
```bash
pnpm exec tsx setup/channels/teams-manifest-build.ts --app-id YOUR_APP_ID --url https://your-domain --rsc
```
## Troubleshooting
### "Upload a custom app" is missing in Teams
### "Upload a custom app" is missing / sideloading blocked
`teams status` shows whether sideloading is enabled at both tenant
and user level; the login output prints the same check.
- **Tenant level off**: Teams Admin Center → **Teams apps** → **Setup
policies** → **Global** → **Upload custom apps** = On.
- **"Enabled for the tenant, but your user policy blocks it"**: the per-user
policy is the blocker — Teams Admin Center → **Users** → find the user →
**Policies** → **App setup policy** → assign one with **Upload custom
apps** = On. Policy changes can take a while to propagate.
Your tenant admin has disabled sideloading. Enable it in Teams Admin Center →
**Teams apps** → **Setup policies** → **Global** → **Upload custom apps** = On.
Free personal Teams does not support sideloading at all — use a Microsoft 365
Business / EDU / developer tenant.
### `teams: command not found`
The CLI installed fine but npm's global bin directory isn't on your PATH — a
common state with custom npm prefixes. Find it with `npm prefix -g` (the
binary is at `<prefix>/bin/teams`), then either add that directory to PATH or
symlink the binary somewhere already on it. The skill's own steps are immune —
they invoke the absolute path.
### Create fails immediately with `AUTH_REQUIRED` after a successful sign-in
The sign-in didn't persist: each `teams` command is a separate process, and
when the CLI's credential store can't load it silently falls back to an
in-memory cache that dies with the login process. Symptom check:
`teams status` says logged out right after a login succeeded. Two causes:
- **CLI installed as a pnpm workspace dependency**: pnpm's supply-chain policy
skips dependency build scripts, so keytar (the CLI's native credential
store) never gets its binary and the whole store fails to load. Use the
global npm install this skill performs — and `pnpm uninstall
@microsoft/teams.cli` if a workspace copy lingers, so `teams` resolves to
the global one.
- **libsecret missing (Linux)**: keytar links against it at load time. Fix:
`sudo apt-get install -y libsecret-1-0` (Debian/Ubuntu). On a headless box
with no keyring daemon the CLI then falls back to a plaintext cache file
(with a warning) — expected.
After fixing, sign in again and confirm `teams status` shows logged in, then
re-run this skill.
### Bot never receives messages
1. The tunnel is up and the messaging endpoint matches it: Azure Bot → **Configuration** → **Messaging endpoint** must be `https://<your-domain>/api/webhooks/teams`, and your tunnel (e.g. `ngrok http 3000`) must be forwarding to this machine's port 3000.
2. The adapter started: `grep -i teams logs/nanoclaw.log | tail`.
3. The credentials are in `.env` (`TEAMS_APP_ID`, `TEAMS_APP_PASSWORD`, `TEAMS_APP_TYPE`).
1. The app is actually installed in Teams — if setup was interrupted before
the install step, nothing got installed. Recover the install link:
`teams app list` shows the Teams App ID, then
`teams app get <teams-app-id> --install-link`.
2. The tunnel is up and the messaging endpoint matches it — the endpoint must
be `https://<your-domain>/webhook/teams`, and your tunnel (e.g.
`ngrok http 3000`) must be forwarding to this machine's port 3000. Check
with `teams app doctor <teams-app-id>` (CLI-created bots) or Azure
Bot → **Configuration** (manual path).
3. The adapter started: `grep -i teams logs/nanoclaw.log | tail`.
4. The credentials are in `.env` (`TEAMS_APP_ID`, `TEAMS_APP_PASSWORD`,
`TEAMS_APP_TYPE`).
### Tunnel URL changed
Point the bot at the new endpoint:
`teams app update <teams-app-id> --endpoint "https://new-domain/webhook/teams"`
(manual path: Azure Bot → Configuration → Messaging endpoint).
### `Unauthorized` / 401 from Azure Bot Service
The client secret is wrong or expired, or — for a Single Tenant app — `TEAMS_APP_TENANT_ID` is missing. Regenerate the secret in **Certificates & secrets** (copy the Value, not the Secret ID), update `.env`, and restart.
Either the credential pairing is wrong, or the secret is dead:
- **Pairing**: `TEAMS_APP_TYPE=SingleTenant` requires `TEAMS_APP_TENANT_ID`;
`MultiTenant` must have **no** tenant ID set. A mismatch authenticates
against the wrong authority and every send/receive 401s.
- **Secret**: expired or mispasted. Rotate with
`teams app auth secret create <teams-app-id>` (or Azure portal →
Certificates & secrets), update `TEAMS_APP_PASSWORD` in `.env`, and restart.
### Rotate or recreate credentials
The credentials flow skips creation while `.env` has `TEAMS_APP_ID` **or**
`TEAMS_APP_PASSWORD` — deleting just one line does not make the skill
regenerate it (that would pair a new app with stale keys). To rotate only the
secret, use the 401 section above. To start over completely: delete **all**
`TEAMS_*` lines from `.env`, optionally delete the old app at
https://dev.teams.microsoft.com/apps (CLI path) or in Azure Portal → App
registrations (manual path), then re-run this skill. Re-running
`teams app create` with old credentials still in `.env` would otherwise create
a second, orphaned app.
### Replies land in the wrong place
A Teams bot's platform ID is derived from the first inbound activity, so wire the messaging group that the router auto-creates after you DM the bot — don't guess the platform ID. See **Finish wiring** above.
A Teams bot's platform ID is derived from the first inbound activity, so wire
the messaging group that the router auto-creates after you DM the bot — don't
guess the platform ID. See **Finish wiring** above.
+16 -17
View File
@@ -28,24 +28,18 @@ function operatorBody(md: string, n: number): string {
}
describe('gatePolicy — §5.1 parity table (real skills)', () => {
it('teams: exactly the two authored gatesand NO confirm on the operator-chain head', () => {
// Operators in order: prerequisites, portal app, client secret, bot resource
// (chain head), enable-channel (chain tail), sideload, final handoff.
it('teams: one gate — the install-in-Teams operator pauses before the restart', () => {
// Operators in order (CLI-first flow): prerequisites, install-in-Teams
// (when:have_creds=no), final handoff.
const d = decisions(loadSkill('teams'));
expect(d).toHaveLength(7);
expect(d).toHaveLength(3);
expect(d.map((g) => g.needsConfirm)).toEqual([
false, // → prompt public_url (prompt is the barrier)
false, // → prompt app_id
false, // → prompt app_password
false, // bot-resource block → next compatible is another OPERATOR (rule 2):
// the chain's LAST operator carries the barrier — never double-confirm
true, // enable-channel → run effect:check (the manifest hazard gate)
true, // sideload → run effect:restart
false, // prereqs → prompt public_url (prompt is the barrier)
true, // install-in-Teams → run effect:restart
false, // final handoff → end of document
]);
// Both confirms are completed-work flavor (check/restart, not effect:step).
expect(d[4].flavor).toBe('completed');
expect(d[5].flavor).toBe('completed');
// Completed-work flavor (restart, not effect:step).
expect(d[1].flavor).toBe('completed');
});
it('telegram: the pairing operator gains a readiness pause before the effect:step', () => {
@@ -140,10 +134,15 @@ describe('gatePolicy — rules on synthetic fixtures', () => {
// §5.2 URL-offer inventory — every operator body in the tree, plus the
// normative negative fixture (slack's placeholder URL).
describe('extractOfferUrl — §5.2 inventory', () => {
it('teams: both portal blocks offer a clean https://portal.azure.com (trailing comma stripped)', () => {
it('teams: raw bodies stay offer-free — the install link is a {{var}} until substitution', () => {
const md = loadSkill('teams');
expect(extractOfferUrl(operatorBody(md, 1))).toBe('https://portal.azure.com'); // app registration
expect(extractOfferUrl(operatorBody(md, 3))).toBe('https://portal.azure.com'); // bot resource (new offer)
// 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(extractOfferUrl(operatorBody(md, 0))).toBeUndefined(); // prereqs
expect(extractOfferUrl(operatorBody(md, 1))).toBeUndefined(); // install-in-Teams
expect(extractOfferUrl(operatorBody(md, 2))).toBeUndefined(); // final handoff
});
it('slack — the <your-public-host> placeholder is EXCLUDED (normative negative fixture)', () => {
+8
View File
@@ -642,6 +642,14 @@ async function main(): Promise<void> {
p.outro(k.yellow('Almost there. A few things still need your attention.'));
return;
}
// Deferred wire (Teams): verify passes with zero groups because the
// platform id only exists after the first DM — remind, don't alarm.
if (res.terminal?.fields.WIRING === 'pending_first_dm') {
note(
'• Finish wiring: DM your bot once, then run /init-first-agent (or /manage-channels).',
"What's left",
);
}
}
const rows: [string, string][] = [
+191 -29
View File
@@ -1,9 +1,13 @@
import { describe, it, expect, afterEach, vi } from 'vitest';
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
import { execSync } from 'node:child_process';
import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { runChannelSkill } from './run-channel-skill.js';
import { runSkill } from '../lib/skill-driver.js';
import { fullyApplied } from '../../scripts/skill-apply.js';
import { parseDirectives } from '../../scripts/skill-directives.js';
import { BACK_TO_CHANNEL_SELECTION, backGate } from '../lib/back-nav.js';
// Drive the first-prompt back gate (back-nav's brightSelect) from a queue
@@ -73,25 +77,27 @@ describe('runChannelSkill adapter (Option A)', () => {
// Teams' platform_id only exists after the first inbound, so its SKILL.md
// installs + hands off and runChannelSkill is called with deferWire — it must
// run the skill but never reach the shared wire. This is the driver-policy
// parity fixture: it runs the DEFAULT onEvent handler (never an injected
// onEvent, which would replace the policy — §5.0) and injects the
// confirm/openUrl seams to prove both natural barriers fire and the portal
// URL offer survives from the operator prose alone.
it('deferWire (Teams): default policy fires the gate barriers + portal URL offer, never reaches the shared wire', async () => {
// run the skill but never reach the shared wire. The credentials flow is
// CLI-first and guarded by the have_creds probe: this fixture answers the
// probe with "yes" (credentials already in .env), so every creation step —
// the teams-login step, teams app create, the env writes, the install-link
// operator — is when:-skipped and the run drops straight through to restart.
it('deferWire (Teams): existing credentials skip the whole CLI create flow, never reach the shared wire', async () => {
const root = mkdtempSync(join(tmpdir(), 'rcs-teams-'));
mkdirSync(join(root, 'src/channels'), { recursive: true });
writeFileSync(join(root, 'src/channels/index.ts'), '// barrel\n');
writeFileSync(join(root, '.env'), '');
writeFileSync(join(root, '.env'), 'TEAMS_APP_ID=existing\nTEAMS_APP_PASSWORD=existing-password\n');
writeFileSync(join(root, 'package.json'), '{"name":"scratch"}');
const log: string[] = [];
const opened: string[] = [];
const wired: unknown[] = [];
await runChannelSkill('teams', 'Acme Corp', {
projectRoot: root,
exec: (c) => void log.push(`exec:${c}`),
exec: (c) => {
log.push(`exec:${c}`);
if (c.includes('TEAMS_APP_ID=.')) return 'yes'; // the have_creds probe
},
resolveRemote: () => 'origin',
reuse: false,
deferWire: true,
@@ -102,13 +108,13 @@ describe('runChannelSkill adapter (Option A)', () => {
log.push(`confirm:${m}`);
return true;
},
openUrl: async (u) => void opened.push(u),
// a MultiTenant app, so the SingleTenant-guarded app_tenant_id prompt is skipped
inputs: {
public_url: 'https://acme.example',
app_id: '12345678-1234-1234-1234-123456789abc',
app_type: 'MultiTenant',
app_password: 'a-much-longer-app-password', // 20+ chars — valid for the declared shape
openUrl: async () => undefined,
// NO inputs: the public_url prompt is when:have_creds=no-guarded, so the
// drop-through path must never ask for it. If the guard regressed, the
// prompt would defer (resolveInput undefined) and fail() would be called.
resolveInput: async () => undefined,
fail: async (step, msg) => {
throw new Error(`fail() called on drop-through path: ${step}${msg}`);
},
wire: (a) => {
wired.push(a);
@@ -116,21 +122,177 @@ describe('runChannelSkill adapter (Option A)', () => {
},
});
// install + manifest ran
expect(log.some((c) => c.includes('teams-manifest-build'))).toBe(true);
// …the Azure portal offer came from the operator BODY text (policy §5.2)…
expect(opened.some((u) => /portal\.azure\.com/.test(u))).toBe(true);
// …a natural-barrier confirm (not a URL offer) fired BEFORE the manifest
// build (the manifest-before-the-app hazard fix, now derived from document
// structure instead of an authored gate attr)…
const firstGate = log.findIndex((c) => c.startsWith('confirm:') && !c.startsWith('confirm:Open '));
const manifestAt = log.findIndex((c) => c.includes('teams-manifest-build'));
expect(firstGate).toBeGreaterThanOrEqual(0);
expect(firstGate).toBeLessThan(manifestAt);
// …but the shared wire was never reached (no owner_handle/platform_id needed)
// the adapter install ran, but no bot was created and no login step fired
expect(log.some((c) => c.includes('pnpm add @chat-adapter/teams'))).toBe(true);
expect(log.some((c) => c.includes('app create'))).toBe(false);
expect(log.some((c) => c.includes('login'))).toBe(false);
// …the Teams CLI install is also skipped (nothing to create)…
expect(log.some((c) => c.includes('npm install -g @microsoft/teams.cli'))).toBe(false);
// …the service still restarts (adapter + existing credentials load)…
expect(log.some((c) => c.includes('restart.sh'))).toBe(true);
// …the pre-existing .env values were left alone…
expect(readFileSync(join(root, '.env'), 'utf8')).toContain('TEAMS_APP_ID=existing');
// …and the shared wire was never reached (no owner_handle/platform_id needed)
expect(wired).toHaveLength(0);
});
// The probe's shell one-liner is dispatched by substring in the fixtures
// above — its actual semantics (EITHER key present ⇒ yes; a partial pair
// must NOT trigger a second `teams app create`) are asserted here by running
// the REAL command from the REAL SKILL.md against real .env states. Parsed
// from the document so the test can't drift from what ships.
it('Teams have_creds probe: either credential key present answers yes', () => {
const md = readFileSync(join(process.cwd(), '.claude/skills/add-teams/SKILL.md'), 'utf8');
const probe = parseDirectives(md).find(
(d) => d.kind === 'run' && d.attrs.capture === 'have_creds',
);
expect(probe).toBeDefined();
const cmd = probe!.body.join('\n');
const cases: Array<[string | null, string]> = [
['TEAMS_APP_ID=a\nTEAMS_APP_PASSWORD=b\n', 'yes'],
['TEAMS_APP_ID=a\n', 'yes'], // partial pair: creating another app would corrupt it
['TEAMS_APP_PASSWORD=b\n', 'yes'],
['OTHER=x\n', 'no'],
['TEAMS_APP_ID=\n', 'no'], // empty value counts as unset (mirrors env-set)
[null, 'no'], // no .env at all
];
for (const [env, expected] of cases) {
const dir = mkdtempSync(join(tmpdir(), 'rcs-probe-'));
if (env !== null) writeFileSync(join(dir, '.env'), env);
const out = execSync(cmd, { cwd: dir, shell: '/bin/bash', encoding: 'utf8' }).trim();
expect(out, `env=${JSON.stringify(env)}`).toBe(expected);
rmSync(dir, { recursive: true, force: true });
}
});
// The fresh-create leg of the same document, driven at the runSkill level so
// the effect:step gets an injected streaming exec (runChannelSkill exposes no
// execStream seam — CI must never spawn a real `teams login`). Proves the
// CLI-first chain end-to-end: login step → create's JSON multi-capture → the
// env writes → the substituted install link surviving into the URL offer.
it('Teams fresh create: login step + JSON capture drive the env writes and the install-link offer', async () => {
const root = mkdtempSync(join(tmpdir(), 'rcs-teams-create-'));
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 INSTALL_LINK =
'https://teams.microsoft.com/l/app/tapp-123?installAppPackage=true&appTenantId=tenant-1';
const log: string[] = [];
const opened: string[] = [];
const steps: 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'; // the have_creds probe: nothing configured yet
if (c.includes(' app create ')) {
// the --json shape teams.cli@3.0.2 prints (credentials keys are UPPERCASE)
return JSON.stringify({
appName: 'NanoClaw',
teamsAppId: 'tapp-123',
botId: '12345678-1234-1234-1234-123456789abc',
installLink: INSTALL_LINK,
portalLink: 'https://dev.teams.microsoft.com/apps/tapp-123',
credentials: {
CLIENT_ID: '12345678-1234-1234-1234-123456789abc',
CLIENT_SECRET: 'a-much-longer-app-secret',
TENANT_ID: '87654321-4321-4321-4321-cba987654321',
},
});
}
},
execStream: async (cmd) => {
steps.push(cmd);
return { ok: true, fields: { STATUS: 'success' } };
},
resolveRemote: () => 'origin',
inputs: { public_url: 'https://acme.example', app_name: 'NanoClaw', tenant: 'single' },
confirm: async (m) => {
log.push(`confirm:${m}`);
return true;
},
openUrl: async (u) => void opened.push(u),
});
// the CLI installed globally (npm runs keytar's install script; pnpm's
// build-script policy would leave the credential store unbuildable)…
expect(log.some((c) => c.includes('npm install -g @microsoft/teams.cli@3.0.2'))).toBe(true);
// …the login ran as a streaming step, never a plain exec (the CLI is
// 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,
// 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');
expect(env).toContain('TEAMS_APP_PASSWORD=a-much-longer-app-secret');
expect(env).toContain('TEAMS_APP_TENANT_ID=87654321-4321-4321-4321-cba987654321');
expect(env).toContain('TEAMS_APP_TYPE=SingleTenant');
// …the install-link operator offered the SUBSTITUTED link (policy §5.2 runs on
// the rendered body — an unsubstituted {{var}} would have been excluded)…
expect(opened).toContain(INSTALL_LINK);
// …a natural-barrier confirm fired between the install operator and restart…
const gateAt = log.findIndex((c) => c.startsWith('confirm:') && !c.startsWith('confirm:Open '));
const restartAt = log.findIndex((c) => c.includes('restart.sh'));
expect(gateAt).toBeGreaterThanOrEqual(0);
expect(gateAt).toBeLessThan(restartAt);
// …and the whole document applied with nothing deferred or bounced.
expect(res.deferred).toEqual([]);
expect(res.agentTasks).toEqual([]);
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-<channel>/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.
+9 -4
View File
@@ -5,14 +5,17 @@
* external image deps) already lives in `setup/lib/teams-manifest.ts`; this just
* maps a couple of CLI flags onto it and prints the resulting zip path.
*
* Mirrors the bespoke `stepGenerateManifest` in the old setup/channels/teams.ts:
* the short name comes from NANOCLAW_AGENT_NAME (falling back to "NanoClaw"), the
* The short name comes from NANOCLAW_AGENT_NAME (falling back to "NanoClaw"), the
* description is "<name> personal assistant powered by NanoClaw.", the website
* URL is the operator's public base URL, and the output lands in data/teams/.
*
* `--rsc` adds the resource-specific-consent permissions (receive all channel /
* group-chat messages without @-mention) and bumps the manifest version so the
* re-upload supersedes the original package.
*
* Usage:
* pnpm exec tsx setup/channels/teams-manifest-build.ts \
* --app-id <azure-app-id> --url https://your-domain [--out data/teams]
* --app-id <azure-app-id> --url https://your-domain [--out data/teams] [--rsc]
*/
import { buildTeamsAppPackage } from '../lib/teams-manifest.js';
@@ -24,11 +27,12 @@ function flag(name: string): string | undefined {
const appId = flag('app-id');
const url = flag('url');
const outDir = flag('out') ?? 'data/teams';
const rsc = process.argv.includes('--rsc');
const shortName = process.env.NANOCLAW_AGENT_NAME?.trim() || 'NanoClaw';
if (!appId || !url) {
console.error(
'usage: teams-manifest-build.ts --app-id <azure-app-id> --url <https-url> [--out <dir>]',
'usage: teams-manifest-build.ts --app-id <azure-app-id> --url <https-url> [--out <dir>] [--rsc]',
);
process.exit(2);
}
@@ -39,6 +43,7 @@ const result = buildTeamsAppPackage({
longDescription: `${shortName} personal assistant powered by NanoClaw.`,
websiteUrl: url,
outDir,
rsc,
});
console.log(`Teams app package: ${result.zipPath}`);
+2 -2
View File
@@ -73,8 +73,8 @@ export const STEP_FILES: Record<string, string[]> = {
'slack-validate': ['setup/channels/slack.ts'],
'imessage-install': ['.claude/skills/add-imessage/SKILL.md', 'scripts/skill-apply.ts', 'setup/channels/imessage.ts'],
'imessage': ['setup/channels/imessage.ts'],
'teams-install': ['.claude/skills/add-teams/SKILL.md', 'scripts/skill-apply.ts', 'setup/channels/teams.ts'],
'teams-manifest': ['setup/lib/teams-manifest.ts', 'setup/channels/teams.ts'],
'teams-install': ['.claude/skills/add-teams/SKILL.md', 'scripts/skill-apply.ts', 'setup/channels/run-channel-skill.ts'],
'teams-manifest': ['setup/lib/teams-manifest.ts', 'setup/channels/teams-manifest-build.ts'],
'init-first-agent': [
'scripts/init-first-agent.ts',
'setup/channels/telegram.ts',
+144
View File
@@ -0,0 +1,144 @@
/**
* The zip is written by our own in-process writer (no `zip` binary), so this
* test parses the output with an independent minimal reader: EOCD → central
* directory → local headers → stored data. If the writer emits a structure
* Teams (or any unzip tool) would reject, these assertions go red.
*/
import fs from 'fs';
import os from 'os';
import path from 'path';
import zlib from 'zlib';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { buildTeamsAppPackage } from './teams-manifest.js';
const APP_ID = '11111111-2222-3333-4444-555555555555';
const PNG_SIG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
interface ParsedEntry {
name: string;
crc: number;
data: Buffer;
}
/** Independent stored-entry zip reader — deliberately shares no code with the writer. */
function readZip(zip: Buffer): ParsedEntry[] {
// No archive comment is written, so EOCD is exactly the last 22 bytes.
const eocd = zip.subarray(zip.length - 22);
expect(eocd.readUInt32LE(0)).toBe(0x06054b50);
const entryCount = eocd.readUInt16LE(10);
let pos = eocd.readUInt32LE(16); // central directory offset
const entries: ParsedEntry[] = [];
for (let i = 0; i < entryCount; i++) {
expect(zip.readUInt32LE(pos)).toBe(0x02014b50);
expect(zip.readUInt16LE(pos + 10)).toBe(0); // stored, no compression
const crc = zip.readUInt32LE(pos + 16);
const size = zip.readUInt32LE(pos + 24);
const nameLen = zip.readUInt16LE(pos + 28);
const extraLen = zip.readUInt16LE(pos + 30);
const commentLen = zip.readUInt16LE(pos + 32);
const localOffset = zip.readUInt32LE(pos + 42);
const name = zip.subarray(pos + 46, pos + 46 + nameLen).toString('ascii');
expect(zip.readUInt32LE(localOffset)).toBe(0x04034b50);
expect(zip.readUInt32LE(localOffset + 14)).toBe(crc);
const localNameLen = zip.readUInt16LE(localOffset + 26);
const localExtraLen = zip.readUInt16LE(localOffset + 28);
const dataStart = localOffset + 30 + localNameLen + localExtraLen;
entries.push({ name, crc, data: zip.subarray(dataStart, dataStart + size) });
pos += 46 + nameLen + extraLen + commentLen;
}
return entries;
}
function build(outDir: string) {
return buildTeamsAppPackage({
appId: APP_ID,
shortName: 'TestBot',
longDescription: 'TestBot assistant for the manifest test.',
websiteUrl: 'https://nanoclaw.example.test',
outDir,
});
}
describe('buildTeamsAppPackage', () => {
// Built in beforeAll (not at describe-collection time) so a writer regression
// fails the tests that own the assertions instead of erroring the whole suite.
let outDir: string;
let rscDir: string;
let result: ReturnType<typeof build>;
let zip: Buffer;
let entries: ParsedEntry[];
beforeAll(() => {
outDir = fs.mkdtempSync(path.join(os.tmpdir(), 'teams-manifest-'));
rscDir = fs.mkdtempSync(path.join(os.tmpdir(), 'teams-manifest-rsc-'));
result = build(outDir);
zip = fs.readFileSync(result.zipPath);
entries = readZip(zip);
});
afterAll(() => {
fs.rmSync(outDir, { recursive: true, force: true });
fs.rmSync(rscDir, { recursive: true, force: true });
});
it('packages exactly manifest.json + both icons, flat', () => {
expect(entries.map((e) => e.name)).toEqual(['manifest.json', 'outline.png', 'color.png']);
});
it('stores each entry byte-identical to the loose file, with a correct CRC', () => {
const loose: Record<string, string> = {
'manifest.json': result.manifestPath,
'outline.png': result.outlinePath,
'color.png': result.colorPath,
};
for (const entry of entries) {
expect(entry.data.equals(fs.readFileSync(loose[entry.name]))).toBe(true);
// zlib.crc32 is public API from Node 20.15 — cross-check when present.
if (typeof zlib.crc32 === 'function') {
expect(entry.crc).toBe(zlib.crc32(entry.data));
}
}
});
it('writes a valid manifest wired to the app id', () => {
const manifest = JSON.parse(entries[0].data.toString('utf8'));
expect(manifest.id).toBe(APP_ID);
expect(manifest.bots[0].botId).toBe(APP_ID);
expect(manifest.validDomains).toEqual(['nanoclaw.example.test']);
expect(manifest.icons).toEqual({ outline: 'outline.png', color: 'color.png' });
});
it('emits real PNGs for both icons', () => {
expect(entries[1].data.subarray(0, 8).equals(PNG_SIG)).toBe(true);
expect(entries[2].data.subarray(0, 8).equals(PNG_SIG)).toBe(true);
});
it('is deterministic and idempotent across rebuilds', () => {
const again = build(outDir);
expect(fs.readFileSync(again.zipPath).equals(zip)).toBe(true);
});
it('adds RSC permissions and bumps the version when rsc is set', () => {
const rscResult = buildTeamsAppPackage({
appId: APP_ID,
shortName: 'TestBot',
longDescription: 'TestBot assistant for the manifest test.',
websiteUrl: 'https://nanoclaw.example.test',
outDir: rscDir,
rsc: true,
});
const manifest = JSON.parse(fs.readFileSync(rscResult.manifestPath, 'utf8'));
expect(manifest.version).toBe('1.1.0');
expect(manifest.authorization.permissions.resourceSpecific).toEqual([
{ name: 'ChannelMessage.Read.Group', type: 'Application' },
{ name: 'ChatMessage.Read.Chat', type: 'Application' },
]);
// RSC consent binds to webApplicationInfo.id — required alongside the block above.
expect(manifest.webApplicationInfo).toEqual({ id: APP_ID, resource: 'https://notapplicable' });
});
});
+106 -20
View File
@@ -7,16 +7,16 @@
* - outline.png — 32×32 transparent outline icon
* - color.png — 192×192 full-color icon
*
* Icons are generated in-process using a minimal PNG encoder so we don't
* need ImageMagick or vendor binary icon blobs into the repo. The outline
* icon is a simple rounded square outline; the color icon is a brand-blue
* filled square with a small white "N" blocked in by pixel setting. Good
* enough for a working sideload — teams admins who care can replace the
* icons later.
* Icons are generated in-process using a minimal PNG encoder, and the zip is
* written in-process too, so we need neither ImageMagick nor a `zip` binary
* on the host, nor vendored binary blobs in the repo. The outline icon is a
* simple rounded square outline; the color icon is a brand-blue filled
* square with a small white "N" blocked in by pixel setting. Good enough
* for a working sideload — teams admins who care can replace the icons
* later.
*
* The manifest is pinned to schema v1.16 to match the skill doc.
*/
import { execSync } from 'child_process';
import fs from 'fs';
import path from 'path';
import zlib from 'zlib';
@@ -36,6 +36,11 @@ export interface ManifestOptions {
websiteUrl: string;
/** Out-dir for the generated zip + loose files. */
outDir: string;
/**
* Include RSC permissions (ChannelMessage.Read.Group, ChatMessage.Read.Chat)
* so the bot receives all channel/group-chat messages without @-mention.
*/
rsc?: boolean;
}
export interface ManifestResult {
@@ -54,28 +59,93 @@ export function buildTeamsAppPackage(opts: ManifestOptions): ManifestResult {
const colorPath = path.join(opts.outDir, 'color.png');
const zipPath = path.join(opts.outDir, 'teams-app-package.zip');
fs.writeFileSync(manifestPath, renderManifest(opts));
fs.writeFileSync(outlinePath, encodeOutlineIcon());
fs.writeFileSync(colorPath, encodeColorIcon());
const manifest = Buffer.from(renderManifest(opts));
const outline = encodeOutlineIcon();
const color = encodeColorIcon();
// Fresh zip every run — idempotent, no stale files.
try {
fs.unlinkSync(zipPath);
} catch {
// noop if missing
}
execSync(`zip -j -q "${zipPath}" "${manifestPath}" "${outlinePath}" "${colorPath}"`, {
stdio: ['ignore', 'ignore', 'inherit'],
});
fs.writeFileSync(manifestPath, manifest);
fs.writeFileSync(outlinePath, outline);
fs.writeFileSync(colorPath, color);
fs.writeFileSync(
zipPath,
buildZip([
{ name: 'manifest.json', data: manifest },
{ name: 'outline.png', data: outline },
{ name: 'color.png', data: color },
]),
);
return { zipPath, manifestPath, outlinePath, colorPath };
}
// ─── Minimal ZIP writer (no external deps, no `zip` binary) ───────────────
//
// Entries are stored uncompressed (method 0): the package is three tiny
// files, and stored entries keep this trivially small and byte-deterministic
// (fixed 1980-01-01 timestamp), which the test relies on.
interface ZipEntry {
name: string;
data: Buffer;
}
// DOS-format date for 1980-01-01: bits 159 year-1980, 85 month, 40 day.
const ZIP_DOS_DATE = (1 << 5) | 1;
function buildZip(entries: ZipEntry[]): Buffer {
const locals: Buffer[] = [];
const centrals: Buffer[] = [];
let offset = 0;
for (const { name, data } of entries) {
const nameBuf = Buffer.from(name, 'ascii');
const crc = crc32(data);
const local = Buffer.alloc(30);
local.writeUInt32LE(0x04034b50, 0); // local file header signature
local.writeUInt16LE(20, 4); // version needed to extract (2.0)
local.writeUInt16LE(0, 8); // compression method: stored
local.writeUInt16LE(ZIP_DOS_DATE, 12);
local.writeUInt32LE(crc, 14);
local.writeUInt32LE(data.length, 18); // compressed size
local.writeUInt32LE(data.length, 22); // uncompressed size
local.writeUInt16LE(nameBuf.length, 26);
locals.push(local, nameBuf, data);
const central = Buffer.alloc(46);
central.writeUInt32LE(0x02014b50, 0); // central directory header signature
central.writeUInt16LE(20, 4); // version made by
central.writeUInt16LE(20, 6); // version needed to extract
central.writeUInt16LE(0, 10); // compression method: stored
central.writeUInt16LE(ZIP_DOS_DATE, 14);
central.writeUInt32LE(crc, 16);
central.writeUInt32LE(data.length, 20); // compressed size
central.writeUInt32LE(data.length, 24); // uncompressed size
central.writeUInt16LE(nameBuf.length, 28);
central.writeUInt32LE(offset, 42); // offset of local header
centrals.push(central, nameBuf);
offset += 30 + nameBuf.length + data.length;
}
const centralDir = Buffer.concat(centrals);
const eocd = Buffer.alloc(22);
eocd.writeUInt32LE(0x06054b50, 0); // end-of-central-directory signature
eocd.writeUInt16LE(entries.length, 8); // entries on this disk
eocd.writeUInt16LE(entries.length, 10); // entries total
eocd.writeUInt32LE(centralDir.length, 12);
eocd.writeUInt32LE(offset, 16); // central directory offset
return Buffer.concat([...locals, centralDir, eocd]);
}
function renderManifest(opts: ManifestOptions): string {
const manifest = {
$schema: MANIFEST_SCHEMA,
manifestVersion: MANIFEST_VERSION,
version: '1.0.0',
// Teams app-update flows want a higher version than the already-uploaded
// package, so the RSC variant (typically a re-upload) bumps it.
version: opts.rsc ? '1.1.0' : '1.0.0',
id: opts.appId,
packageName: 'com.nanoclaw.bot',
developer: {
@@ -104,6 +174,21 @@ function renderManifest(opts: ManifestOptions): string {
],
permissions: ['identity', 'messageTeamMembers'],
validDomains: [new URL(opts.websiteUrl).host],
...(opts.rsc && {
// RSC grants bind to webApplicationInfo.id, not bots[].botId — without
// this block the permissions are never attached to the app and the bot
// silently keeps requiring @-mention. `resource` must be non-empty but
// its value is unused for RSC-only apps.
webApplicationInfo: { id: opts.appId, resource: 'https://notapplicable' },
authorization: {
permissions: {
resourceSpecific: [
{ name: 'ChannelMessage.Read.Group', type: 'Application' },
{ name: 'ChatMessage.Read.Chat', type: 'Application' },
],
},
},
}),
};
return JSON.stringify(manifest, null, 2) + '\n';
}
@@ -269,3 +354,4 @@ function setWhite(pixels: Uint8Array, size: number, x: number, y: number): void
pixels[i + 2] = 255;
pixels[i + 3] = 255;
}
+31
View File
@@ -22,6 +22,37 @@ describe('determineVerifyStatus', () => {
).toBe('failed');
});
// Deferred wire (Teams): configured but zero groups is pending operator
// action (first DM), not a broken install — success, not failed.
it('accepts zero groups when wiring is pending a first DM', () => {
expect(
determineVerifyStatus({
...healthyBase,
registeredGroups: 0,
wiringPending: true,
}),
).toBe('success');
});
it('pending wiring never rescues a stopped service or missing credentials', () => {
expect(
determineVerifyStatus({
...healthyBase,
registeredGroups: 0,
wiringPending: true,
service: 'stopped',
}),
).toBe('failed');
expect(
determineVerifyStatus({
...healthyBase,
registeredGroups: 0,
wiringPending: true,
credentials: 'missing',
}),
).toBe('failed');
});
it('fails when the service is not running', () => {
expect(
determineVerifyStatus({
+25 -2
View File
@@ -217,15 +217,27 @@ export async function run(_args: string[]): Promise<void> {
mountAllowlist = 'configured';
}
// Deferred-wire channels can't have a group yet: their platform id only
// exists after the first inbound DM (see add-teams' "Finish wiring"), so
// configured-but-unwired is pending operator action, not a broken install.
// Only claim pending when EVERY configured channel is defer-wire — a
// wire-during-setup channel (slack, telegram, …) with zero groups is a
// genuine failure this must not mask.
const wiringPending =
registeredGroups === 0 &&
configuredChannels.length > 0 &&
configuredChannels.every((c) => DEFER_WIRE_CHANNELS.has(c));
// Determine overall status. The cli-agent step earlier in setup already
// proved the agent round-trip works; verify is a static health check.
const status = determineVerifyStatus({
service,
credentials,
registeredGroups,
wiringPending,
});
log.info('Verification complete', { status, channelAuth });
log.info('Verification complete', { status, channelAuth, wiringPending });
emitStatus('VERIFY', {
SERVICE: service,
@@ -235,6 +247,7 @@ export async function run(_args: string[]): Promise<void> {
CHANNEL_AUTH: JSON.stringify(channelAuth),
REGISTERED_GROUPS: registeredGroups,
MOUNT_ALLOWLIST: mountAllowlist,
...(wiringPending ? { WIRING: 'pending_first_dm' } : {}),
STATUS: status,
LOG: 'logs/setup.log',
});
@@ -242,14 +255,24 @@ export async function run(_args: string[]): Promise<void> {
if (status === 'failed') process.exit(1);
}
/**
* Channels whose wiring only completes after the first inbound message
* the platform id doesn't exist until the bot is DM'd, so setup ends with
* the channel configured but no group wired. Kept in lockstep with the
* deferWire call sites in setup/auto.ts.
*/
export const DEFER_WIRE_CHANNELS = new Set(['teams']);
export function determineVerifyStatus(input: {
service: 'not_found' | 'stopped' | 'running' | 'running_other_checkout';
credentials: string;
registeredGroups: number;
/** Zero groups but every configured channel defers wiring to the first DM. */
wiringPending?: boolean;
}): 'success' | 'failed' {
return input.service === 'running' &&
input.credentials !== 'missing' &&
input.registeredGroups > 0
(input.registeredGroups > 0 || input.wiringPending === true)
? 'success'
: 'failed';
}