Compare commits

..

25 Commits

Author SHA1 Message Date
Koshkoshinsk a61b519176 add-teams: apply the mascot icons to the CLI-created app
The committed icons only reached the manual-path sideload zip; the CLI path's
package is generated by `teams app create` with placeholder icons. A new
creation-side fence runs `teams app update --color-icon --outline-icon` with
the captured teams app id, before the install operator so the install dialog
already shows the mascot. Cosmetic: a failure logs and continues, never
blocking setup. No operator adjacency touched — gate policy unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 13:24:58 +03:00
Koshkoshinsk eff7811930 add-teams: drop the dead deferWire option
wireIfResolved replaced it at the only call site (setup/auto.ts) and its
test was already deleted; the option, its two branches, and stale comment
references survived. The live drop-through fallback (wireIfResolved's
unresolved return + verify's DEFER_WIRE_CHANNELS pending path) is unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 12:08:08 +03:00
Koshkoshinsk 1292bf3db2 add-teams: app-package assets as data — manifest template, mascot icons, extracted zip writer
The manifest is now a checked-in manifest.template.json (code only patches
the per-install fields: app id, name, domain, optional RSC block), the two
icons are committed PNGs rendered from the NanoClaw mascot instead of a
160-line in-process PNG encoder, and the minimal stored-zip writer moves to
setup/lib/zip.ts. buildTeamsAppPackage's interface is unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 12:02:09 +03:00
Koshkoshinsk 384231ab0b add-teams: wiring confirm — yes/no, then logged-in-account or other-account; no skip, no raw IDs
The CLI login is often not the person setting up NanoClaw. The confirm stays
yes/no; a no shows "You're currently logged in to Teams as {upn}" and asks
which Teams user to wire: logged-in-account (rebind straight back to the yes
branch — recovers a hesitant no, no ID typed or shown) or other-account
(entra-instructions infobox, GUID prompt, rebind with the provided id).
Either rebind re-enters the same link chain — no duplicated steps.

The provide/skip select, the deferred-wire option, and the raw-object-ID
display are gone: someone is always wired on a fresh create, and identities
show by sign-in name only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 16:44:37 +03:00
Koshkoshinsk 852cbbac6c add-teams: the declined-wire note leads with the operator's own Entra object ID
The ID was already captured from the CLI session (teams status .userObjectId)
and operator bodies interpolate — so a no now shows it: paste it via "provide"
to wire yourself after all, fetch a different user's ID from Entra / Teams
admin center, or skip to the DM-first ending. A no out of uncertainty no
longer locks the operator out of wiring without a re-run.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 15:26:16 +03:00
Koshkoshinsk 20bcb2408c add-teams: declined wire offers wiring a provided Entra object ID; skip defers to a DM-first ending
A no at the wiring confirm now leads somewhere: an infobox explains the
Microsoft Entra object ID (and where to find it), a provide/skip select asks
for it right there, and a provided ID rebinds owner_aad_id + flips
wire_owner=yes via a capture fence — the link chain runs unchanged against
the target user, so the assistant messages the desired person first.

Skip binds a real value (a deferred prompt would abort channel setup via
runChannelSkill's fullyApplied gate), the chain guard-skips cleanly, and the
wizard's closing What's-left banner now carries the DM-first guidance: have
the desired person DM the bot once, then /init-first-agent picks them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 14:24:28 +03:00
Koshkoshinsk 2ef9f11588 add-teams: ask before wiring the detected owner — a no defers with manual-wire guidance
The identity note ("stop here (Ctrl-C) if that's not you") becomes a real
decision: an operator note names the detected account ({{owner_upn}}), then
nc:prompt wire_owner (^(yes|no)$ — renders as a select under the driver's
enum-prompt rendering) gates the whole DM-open chain via when:wire_owner=yes.
wire_owner is only prompted on fresh creates, and an unbound var fails any
when: guard, so drop-through re-runs skip the chain exactly as before.

On no: a note explains how to wire the desired Teams user afterwards (DM-first
path needs no IDs; proactive wiring needs the person's Entra object ID and
where to find it), the wire inputs stay unresolved, and wireIfResolved falls
through to the deferred ending. Install/create/env still complete either way.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 14:06:33 +03:00
Koshkoshinsk e7ecfb9955 add-teams: trim the tunnel-URL and sign-out prompts; rename "Open the owner DM"
- tunnel-URL prompt down to one line; the port-3000 / no-trailing-path detail
  moved into the pre-flight note just above it (an operator block followed by a
  prompt adds no extra confirm — gate policy rule 3)
- sign-out question reworded for the driver's select rendering of ^(yes|no)$
  prompts (degrades to a validated text prompt until that lands on the base)
- "Open the owner DM" -> "Link the bot to your account": spinner captions are
  heading-derived, and four "Open the owner DM (n/4)" rows read as something to
  do in Teams rather than background API calls

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 12:50:47 +03:00
Koshkoshinsk eba05bb67e add-teams: take the first non-bot conversation member — the aadObjectId select came back empty on a live run
The 1:1 is created with exactly one member (the owner), so first-non-bot is
correct by construction; .aadObjectId is not reliably present in the members
response and its GUID casing varies. Also default the display name.
Live-run evidence (VM run #7): token + create-conversation with the 28: bot
id and an AAD member id both work; only the members select failed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 17:55:43 +03:00
Koshkoshinsk 66b0dbd5c7 add-teams: wire the owner inline — the assistant messages the human first
Same move as Slack/Discord: after the app install, open the bot<->owner 1:1
proactively with the bot credentials. The owner is the Teams-CLI account
that created the bot (teams status --json -> username + AAD object id); the
conversation-members fetch converts that to the 29: id inbound senders carry
and doubles as an identity cross-check; the platform id is composed exactly
as the adapter encodes thread ids. An operator note names who gets wired.
runChannelSkill gains wireIfResolved: a fresh create wires + welcomes like
any channel, a drop-through re-run resolves nothing and keeps the deferred
ending. Logout is now the operator's choice (yes/no prompt) instead of
automatic, and runs after the identity resolve.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 17:26:06 +03:00
Koshkoshinsk fc5d7a6b38 add-teams: retire the finish-wiring infobox; the closing What's-left note carries it
The 4-line operator block duplicated the wizard's ending note and was the
'too much for the average joe' feedback. Setup now says it once, at the very
end; the SKILL.md keeps a one-line prose version for agents applying the
skill outside the wizard. The note gains 'with your coding agent'.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 15:22:52 +03:00
Koshkoshinsk 125aec0617 add-teams: drop ngrok references; Cloudflare Tunnel is the suggestive example
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 14:40:03 +03:00
Koshkoshinsk 72f6c7e8b2 add-teams: drop the install-libsecret guidance; mark the sideloading probe advisory
The libsecret warning fires even with libsecret installed (headless = no
keyring daemon) and the plaintext fallback persists fine — with the logout
step, nothing lingers either way. The warning is now a one-line 'safe to
ignore'; the AUTH_REQUIRED troubleshooting keeps only the real cause (pnpm
workspace install). The login step's sideloading probe flapped between runs
on the same account — documented as advisory; the install link is the
authoritative test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 14:35:58 +03:00
Koshkoshinsk b45dc2dc42 add-teams: sign out of the Teams CLI once the bot exists
The M365 session is only needed to create the bot — the adapter runs on the
.env app credentials. On a headless box the session is a plaintext token
file, so setup no longer leaves it on disk. teams logout is idempotent;
later CLI maintenance just needs a fresh teams login (device code).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 13:52:05 +03:00
Koshkoshinsk 0bcc3a74b1 add-teams: note that the login step's 'Azure CLI: not installed' line is informational
The CLI-first flow creates a Teams-managed bot so az is never needed; the
line only matters for --azure bots and the manual portal path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 13:40:00 +03:00
Koshkoshinsk 8f9ecd9592 setup: on a deferred wire the last box is the remaining action, not 'go say hi'
Teams ended with two conflicting banners: 'What's left: DM your bot' followed
by 'Check your Teams — your assistant is saying hi' (no welcome DM exists on
a deferred wire). The pending flag now gates the ending: What's left moves to
the final slot in the bright banner style, Go say hi only shows for channels
that actually sent a welcome.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 12:12:26 +03:00
Koshkoshinsk 8772306627 add-teams: default to single-tenant without asking; multi-tenant becomes a manual alternative
The tenant fork cost every user a question that is almost always 'single'.
Create is unconditional --sign-in-audience myOrg again; the prereqs infobox
notes the default and points multi-tenant users at Alternatives, which
regains a manual multi-tenant section (create command + MultiTenant env
pairing without tenant ID).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 11:41:54 +03:00
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
20 changed files with 1329 additions and 681 deletions
+3 -5
View File
@@ -83,8 +83,7 @@ Create the Slack app (Socket Mode):
3. App Home → enable the Messages Tab, and check "Allow users to send Slash commands and messages from the messages tab."
4. Basic Information → App-Level Tokens → "Generate Token and Scopes" → add the connections:write scope → copy the token (starts with xapp-).
5. Socket Mode → toggle "Enable Socket Mode" on.
6. Event Subscriptions → toggle "Enable Events" on, then under "Subscribe to bot events" add: message.channels, message.groups, message.im, app_mention. Save Changes. (No Request URL is needed in Socket Mode.)
7. Install to Workspace, then copy the Bot User OAuth Token (starts with xoxb-).
6. Install to Workspace, then copy the Bot User OAuth Token (starts with xoxb-).
```
For webhook delivery, tell the user:
@@ -123,9 +122,8 @@ SLACK_SIGNING_SECRET={{signing_secret}}
With webhook delivery, the bridge serves port 3000 at `/webhook/slack`
automatically; to receive replies, that port must be reachable from the internet
and registered with Slack as the Request URL (Socket Mode needs no public URL —
with the bot events subscribed above, events arrive over the socket as soon as
the service restarts). Tell the user:
and registered with Slack (Socket Mode skips all of this — events arrive over
the socket as soon as the service restarts). Tell the user:
```nc:operator when:connection=webhook
Set up event delivery (needs a public HTTPS URL for port 3000 — ngrok, a Cloudflare Tunnel, or a reverse proxy on a VPS):
+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
+402 -165
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,268 @@ 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 (e.g. a Cloudflare Tunnel, or a reverse-proxied VPS). Start it now if it isn't running — e.g. `cloudflared tunnel --url http://localhost:3000` — the create step needs the URL up front. The next prompt asks for its public base URL: just the https:// origin, no trailing path.
Note: the bot is created single-tenant (only your own Microsoft 365 tenant can install it) — the right default for a self-hosted assistant. If you need a bot other tenants can install, set it up manually via the Alternatives section of this skill instead.
```
### 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 your tunnel's public https:// URL — e.g. https://your-tunnel.trycloudflare.com
```
### Register the Azure app
### App name
Tell the user:
One more choice belongs 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).
### Install the Teams CLI
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:run effect:external when:have_creds=no
npm install -g @microsoft/teams.cli@3.0.2 --loglevel=error
```
### Create a client secret
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.
Tell the user:
### Sign in to Microsoft 365
```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).
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. A "libsecret not found —
token cache will be stored unencrypted" warning here is safe to ignore: the
CLI falls back to a plaintext cache file that persists fine, and setup signs
the session out at the end anyway. The login output may
also report "Azure CLI: not installed" — informational only; this flow
creates a Teams-managed bot precisely so the Azure CLI is never needed (it
only matters for `--azure` bots and the manual portal path). 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'
```
```nc:prompt app_password secret validate:^.{20,}$
Paste the client secret Value — Certificates & secrets (shown only once, at least 20 characters).
### 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 bot is
always created single-tenant (`--sign-in-audience myOrg`) — the right default
for a self-hosted assistant, applied without asking; for a bot other
Microsoft 365 tenants can install, set it up manually per
[Alternatives](#alternatives).
```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 2>/dev/null)/bin/teams" app create --name "{{app_name}}" --endpoint "{{public_url}}/webhook/teams" --sign-in-audience myOrg --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: `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:have_creds=no
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
### Set the app icons
The CLI-created app ships with placeholder icons; this swaps in the NanoClaw
mascot (the same PNGs the manual-path package bakes into its zip), so the
install dialog below already shows it. Cosmetic — a failure is logged and
skipped, never blocking setup. Re-runnable any time while signed in to the
Teams CLI:
```nc:run effect:external when:have_creds=no
"$(npm prefix -g 2>/dev/null)/bin/teams" app update {{teams_app_id}} --color-icon setup/assets/teams/color.png --outline-icon setup/assets/teams/outline.png --json || echo "Icon update failed — cosmetic only, continuing."
```
### Who owns this bot
The account signed into the Teams CLI is the account that just created the
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 confirmed target, and someone is always wired —
there is no skip. The account signed into the Teams CLI is often NOT the
person setting up NanoClaw, so a no leads to a clarifying choice: wire the
logged-in Teams user after all, or a different Teams user by Microsoft Entra
object ID. Identities are shown by sign-in name, never a raw ID:
```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
You're currently logged in to Teams as {{owner_upn}}.
- To wire the assistant to this logged-in Teams user, choose "logged-in-account".
- To wire a different Teams user, get their Microsoft Entra object ID — found at entra.microsoft.com > Users > (person) > Overview > Object ID, or Teams admin center > Manage users — and choose "other-account". Once wired, the assistant messages them first.
```
```nc:prompt wire_target when:wire_owner=no validate:^(logged-in-account|other-account)$ normalize:lower
Which Teams user should the assistant be wired to?
```
```nc:prompt target_aad_id when:wire_target=other-account 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}$ normalize:trim
Paste the Microsoft Entra object ID of the Teams user to wire (a GUID like 00000000-0000-0000-0000-000000000000).
```
Either choice re-enters the exact same path as a yes above — rebind the
wiring target and flip the branch, so the link chain below needs no second
copy per branch:
```nc:run when:wire_target=other-account capture:owner_aad_id=.aad,wire_owner=.wire validate:^.+$
printf '{"aad":"%s","wire":"yes"}' "{{target_aad_id}}"
```
```nc:run when:wire_target=logged-in-account capture:wire_owner validate:^yes$
echo yes
```
### 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.
```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.
```
### Enable the Teams channel
### Link the bot to your account
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):
Nothing to do in Teams yet — these are background API calls, and the whole
chain runs only for a confirmed target from
[Confirm the wiring target](#confirm-the-wiring-target) (the detected owner
on a yes, or the provided Entra object ID). 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
bot to bootstrap it. This only works now that the app is installed (the step
above); if Microsoft hasn't finished propagating the install yet, the create
below can fail once — re-running the skill is safe.
```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.
First a Bot Framework token from the app credentials:
```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'
```
### Build the Teams app package
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):
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}}" ]
```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'
```
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.
The adapter identifies inbound senders by their Bot Framework `29:` id, not
the AAD id — the owner must be wired under that handle or their replies
would not be recognized. The conversation was created with exactly one
member (the owner), so its member list is the owner by construction; the
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:external
pnpm exec tsx setup/channels/teams-manifest-build.ts --app-id "{{app_id}}" --url "{{public_url}}"
```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")}'
```
### Sideload the app into Teams
Compose the platform id exactly as the adapter encodes thread ids
(`teams:{b64url conversation}:{b64url service url}`):
Tell the user (do this before the restart below — the service should come up with
the app already sideloaded):
```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}}"
```
```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.
### Sign out of the Teams CLI
The Microsoft 365 session was only needed to create the bot and identify its
owner — the running adapter authenticates with the app credentials in
`.env`, never with your account. On a headless box that session is a
plaintext token file, which is worth removing unless you plan more `teams …`
commands (rotate secret, endpoint update, RSC — each just needs a fresh
`teams login`, a ~30-second device code):
```nc:prompt signout when:have_creds=no validate:^(yes|no)$ normalize:lower
Sign out of the Teams CLI now? The bot doesn't need this login to run — signing out is recommended on shared or headless boxes, and `teams login` gets you back any time.
```
```nc:run effect:external when:signout=yes
"$(npm prefix -g 2>/dev/null)/bin/teams" logout
```
## Restart
@@ -217,23 +349,29 @@ bash setup/lib/restart.sh
## Finish wiring
Unlike Discord or Slack, a Teams bot's platform ID isn't known until you DM the
bot for the first time — the adapter derives it from the inbound activity. So
this skill installs the adapter and stops here; you finish the wiring once the
bot has seen its first message. Tell the user:
On a fresh create, [Link the bot to your account](#link-the-bot-to-your-account) already resolved
everything the wire needs — `owner_handle` (the owner's `29:` id) and
`platform_id` (the bot↔owner DM). The setup wizard wires automatically from
those and the welcome message lands in the owner's Teams DMs. Applying this
skill outside the wizard? Run the same wire yourself:
```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).
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.
```bash
pnpm exec tsx scripts/init-first-agent.ts --channel teams --user-id "teams:<owner_handle>" --platform-id "<platform_id>" --display-name "<the human's name>" --agent-name "<assistant 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
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
agent.
## Next Steps
If you're in the middle of `/setup`, return to the setup flow now. Otherwise,
once you've DM'd the bot, wire this channel with `/init-first-agent` (or
`/manage-channels`).
If you're in the middle of `/setup`, return to the setup flow now — it wires
the owner automatically from the resolved values. Otherwise wire per
[Finish wiring](#finish-wiring).
## Channel Info
@@ -247,57 +385,78 @@ once you've DM'd the bot, wire this channel with `/init-first-agent` (or
## Alternatives
### Auto: Teams CLI
### Multi-tenant bot
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).
The Credentials flow above always creates a single-tenant bot (only your
Microsoft 365 tenant can install it) — the right default for a self-hosted
assistant, so the skill doesn't ask. For a bot any tenant can install, run
the create by hand with `multipleOrgs` and store the matching env pairing —
`MultiTenant` with **no** tenant ID (the same 401 pairing rule from the
credentials step):
1. Install the CLI:
```bash
"$(npm prefix -g)/bin/teams" app create --name "YourBot" --endpoint "https://your-domain/webhook/teams" --sign-in-audience multipleOrgs --json
```
```bash
TEAMS_APP_ID=<CLIENT_ID from the output>
TEAMS_APP_PASSWORD=<CLIENT_SECRET from the output>
TEAMS_APP_TYPE=MultiTenant
```
Install via the `installLink` in the output, then continue from
[Restart](#restart). If this skill already created a single-tenant app,
start over first — see Rotate or recreate credentials in
[Troubleshooting](#troubleshooting).
### 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. Ask the human 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:
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 +464,121 @@ 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.
The login step's sideloading probe is **advisory** — policy edits can take
hours to propagate and the probe has been seen flapping between runs on the
same account. The authoritative test is whether the install link's Add
actually works; only act on the probe if the install itself refuses.
### `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. The known
cause: the **CLI was 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. (The "libsecret not found → stored unencrypted" warning is
NOT this failure — that fallback persists fine and is safe to ignore.)
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.
`cloudflared tunnel --url http://localhost: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.
+21 -18
View File
@@ -28,24 +28,21 @@ function operatorBody(md: string, n: number): string {
}
describe('gatePolicy — §5.1 parity table (real skills)', () => {
it('teams: exactly the two authored gates — and 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 DM-open fetches', () => {
// 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(7);
expect(d).toHaveLength(4);
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, // final handoff → end of document
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
]);
// 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 (fetch/external, not effect:step).
expect(d[3].flavor).toBe('completed');
});
it('telegram: the pairing operator gains a readiness pause before the effect:step', () => {
@@ -140,10 +137,16 @@ 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, 3)).toContain('{{install_link}}');
expect(extractOfferUrl(operatorBody(md, 0))).toBeUndefined(); // prereqs
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 <your-public-host> placeholder is EXCLUDED (normative negative fixture)', () => {
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

+36
View File
@@ -0,0 +1,36 @@
{
"$schema": "https://developer.microsoft.com/en-us/json-schemas/teams/v1.16/MicrosoftTeams.schema.json",
"manifestVersion": "1.16",
"version": "1.0.0",
"id": "00000000-0000-0000-0000-000000000000",
"packageName": "com.nanoclaw.bot",
"developer": {
"name": "NanoClaw",
"websiteUrl": "https://nanoclaw.invalid",
"privacyUrl": "https://nanoclaw.invalid",
"termsOfUseUrl": "https://nanoclaw.invalid"
},
"name": {
"short": "NanoClaw",
"full": "NanoClaw Assistant"
},
"description": {
"short": "Your personal assistant in Teams.",
"full": "NanoClaw personal assistant."
},
"icons": {
"outline": "outline.png",
"color": "color.png"
},
"accentColor": "#4A90D9",
"bots": [
{
"botId": "00000000-0000-0000-0000-000000000000",
"scopes": ["personal", "team", "groupchat"],
"supportsFiles": false,
"isNotificationOnly": false
}
],
"permissions": ["identity", "messageTeamMembers"],
"validDomains": ["nanoclaw.invalid"]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 392 B

+21 -5
View File
@@ -555,9 +555,7 @@ async function main(): Promise<void> {
}
let result: void | typeof BACK_TO_CHANNEL_SELECTION;
// Every channel now runs through the SKILL.md-driven flow — the whole
// connect+wire procedure lives in each add-<channel>/SKILL.md. Teams
// defers the wire (its platform_id only exists after the first inbound),
// so it installs + hands off rather than wiring inline.
// connect+wire procedure lives in each add-<channel>/SKILL.md.
if (channelChoice === 'telegram') {
result = await runChannelSkill('telegram', displayName!, { offerBack: true });
} else if (channelChoice === 'discord') {
@@ -567,7 +565,10 @@ async function main(): Promise<void> {
} else if (channelChoice === 'signal') {
result = await runChannelSkill('signal', displayName!, { offerBack: true });
} else if (channelChoice === 'teams') {
result = await runChannelSkill('teams', displayName!, { deferWire: true, offerBack: true });
// Fresh create resolves the owner DM proactively and wires inline (the
// welcome message reaches the human first); a drop-through re-run
// resolves nothing and falls back to the deferred-wire ending.
result = await runChannelSkill('teams', displayName!, { wireIfResolved: true, offerBack: true });
} else if (channelChoice === 'slack') {
result = await runChannelSkill('slack', displayName!, { offerBack: true });
} else if (channelChoice === 'imessage') {
@@ -588,6 +589,12 @@ async function main(): Promise<void> {
}
}
// Deferred wire (Teams): verify passes with zero groups because the
// platform id only exists after the first DM. Tracked here so the ENDING
// changes too — the last box must be the one remaining action, not a
// premature "your assistant is saying hi" (no welcome DM exists yet).
let wiringPending = false;
if (!skip.has('verify')) {
const res = await runQuietStep('verify', {
running: 'Making sure everything works together…',
@@ -642,6 +649,7 @@ async function main(): Promise<void> {
p.outro(k.yellow('Almost there. A few things still need your attention.'));
return;
}
wiringPending = res.terminal?.fields.WIRING === 'pending_first_dm';
}
const rows: [string, string][] = [
@@ -667,7 +675,15 @@ async function main(): Promise<void> {
phEmit('setup_completed', { duration_ms: Date.now() - RUN_START });
const dmTarget = channelDmLabel(channelChoice);
if (dmTarget) {
if (wiringPending) {
// No welcome DM exists yet — the one remaining action is the last thing
// on screen, in the same bright framed style as the "go say hi" banner.
note(
`${brandBold('→')} ${k.bold(`Have the person you want wired DM the bot once in ${dmTarget ?? 'your chat app'} ("hi" works).`)}\nNanoClaw registers their identity and chat from that first message; then run /init-first-agent with your coding agent and pick them.`,
"What's left",
);
p.outro(k.green("You're set — one DM to go."));
} else if (dmTarget) {
// Bright framed banner (not dim) — the whole point of the feedback was
// that the welcome-message signal was too easy to miss. Use p.note so it
// renders with a visible box, cyan-bold the directive line, and put it
+362 -32
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
@@ -71,30 +75,31 @@ describe('runChannelSkill adapter (Option A)', () => {
expect(cmds.some((c) => c.startsWith('ncl '))).toBe(false);
});
// 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 () => {
// Teams wires inline only when a fresh create resolved the owner DM
// (wireIfResolved). This fixture answers the have_creds probe with "yes"
// (credentials already in .env), so every creation + resolve step — the
// teams-login step, teams app create, the env writes, the DM-open chain,
// the install-link operator — is when:-skipped, the wire inputs stay
// unresolved, and the run drops through to restart without wiring.
it('wireIfResolved (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,
wireIfResolved: true,
// The injectable interaction seams — the default handler consults them for
// the URL offer and the natural-barrier confirms, so no real clack confirm
// (which would hang in CI) and no real browser open is reached.
@@ -102,13 +107,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 +121,346 @@ 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('app update'))).toBe(false); // icon step is creation-side too
expect(log.some((c) => c.includes('login'))).toBe(false);
// …no logout either — the drop-through path never signed in, and must not
// sign out a session the operator may be using for something else
expect(log.some((c) => c.includes('logout'))).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[] = [];
// The DM-open chain's expected results. The exec mock returns each
// command's FINAL stdout (post-jq), matching what the engine captures.
const EXPECTED_PLATFORM_ID = `teams:${Buffer.from('a:1conv').toString('base64url')}:${Buffer.from('https://smba.trafficmanager.net/teams/').toString('base64url')}`;
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',
},
});
}
// owner identity from the CLI session (status --json fence, plain exec)
if (c.includes('status --json')) {
return JSON.stringify({ loggedIn: true, username: 'dan@acme.example', tenantId: 'tenant-1', userObjectId: 'aad-owner-1' });
}
if (c.includes('login.microsoftonline.com')) return 'eyJfake.bot.token';
// /members is a sub-path of /v3/conversations — match it FIRST
if (c.includes('/members')) return JSON.stringify({ id: '29:owner-xyz', name: 'Dan Mill' });
if (c.includes('/v3/conversations')) return 'a:1conv';
if (c.includes('node -e')) return EXPECTED_PLATFORM_ID;
},
execStream: async (cmd) => {
steps.push(cmd);
return { ok: true, fields: { STATUS: 'success' } };
},
resolveRemote: () => 'origin',
inputs: { public_url: 'https://acme.example', app_name: 'NanoClaw', wire_owner: 'yes', signout: 'yes' },
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,
// the prompted name, and the unconditional single-tenant default…
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 mascot icons were applied to the created app (captured teams app id,
// both committed assets) before the install-link operator…
expect(
log.some(
(c) =>
c.includes(' app update tapp-123') &&
c.includes('--color-icon setup/assets/teams/color.png') &&
c.includes('--outline-icon setup/assets/teams/outline.png'),
),
).toBe(true);
// …the DM-open chain resolved the wire inputs: the owner's 29: id from the
// 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');
expect(res.vars.platform_id).toBe(EXPECTED_PLATFORM_ID);
// …the M365 session was signed out on the operator's "yes" (the adapter
// runs on the .env app credentials; staying signed in is now a choice)…
expect(log.some((c) => c.includes('/bin/teams" logout'))).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);
});
// A no at the wiring confirm then "other-account" collects a different
// user's Entra object ID, rebinds the wiring target, and re-enters the yes
// branch: the conversation is created with the PROVIDED id (not the CLI
// account's), and the wire inputs resolve to that person — the assistant
// messages the desired user first. There is no skip: someone is always wired.
it('Teams fresh create, wiring another account by Entra object ID: the chain runs against the target user', async () => {
const root = mkdtempSync(join(tmpdir(), 'rcs-teams-target-'));
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 TARGET_AAD = 'aaaabbbb-cccc-dddd-eeee-ffff00001111';
const EXPECTED_PLATFORM_ID = `teams:${Buffer.from('a:2conv').toString('base64url')}:${Buffer.from('https://smba.trafficmanager.net/teams/').toString('base64url')}`;
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' });
}
// the rebind fence: printf its own substituted JSON back
if (c.includes('"wire":"yes"')) return `{"aad":"${TARGET_AAD}","wire":"yes"}`;
if (c.includes('login.microsoftonline.com')) return 'eyJfake.bot.token';
if (c.includes('/members')) return JSON.stringify({ id: '29:target-xyz', name: 'Desired Person' });
if (c.includes('/v3/conversations')) return 'a:2conv';
if (c.includes('node -e')) return EXPECTED_PLATFORM_ID;
},
execStream: async () => ({ ok: true, fields: { STATUS: 'success' } }),
resolveRemote: () => 'origin',
inputs: {
public_url: 'https://acme.example',
app_name: 'NanoClaw',
wire_owner: 'no',
wire_target: 'other-account',
target_aad_id: TARGET_AAD,
signout: 'yes',
},
confirm: async () => true,
openUrl: async () => {},
});
// The conversation was created with the PROVIDED id, not the CLI account's…
const create = log.find((c) => c.includes('/v3/conversations') && !c.includes('/members'));
expect(create).toContain(TARGET_AAD);
expect(create).not.toContain('aad-owner-1');
// …and the wire inputs resolved to the target user.
expect(res.vars.owner_handle).toBe('29:target-xyz');
expect(res.vars.owner_name).toBe('Desired Person');
expect(res.vars.platform_id).toBe(EXPECTED_PLATFORM_ID);
expect(res.agentTasks).toEqual([]);
expect(fullyApplied(res)).toBe(true);
});
// A hesitant no recovered via "logged-in-account": the rebind flips the
// branch back to yes with the CLI account's own id — same outcome as a yes
// at the first ask, no ID ever typed or shown.
it('Teams fresh create, no then logged-in-account: the chain runs against the CLI account', async () => {
const root = mkdtempSync(join(tmpdir(), 'rcs-teams-loggedin-'));
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'; // probe first — it also contains "echo yes"
if (c.trim() === 'echo yes') return 'yes'; // the logged-in-account rebind
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' });
}
if (c.includes('login.microsoftonline.com')) return 'eyJfake.bot.token';
if (c.includes('/members')) return JSON.stringify({ id: '29:owner-xyz', name: 'Dan Mill' });
if (c.includes('/v3/conversations')) return 'a:3conv';
if (c.includes('node -e')) return 'teams:b64:b64';
},
execStream: async () => ({ ok: true, fields: { STATUS: 'success' } }),
resolveRemote: () => 'origin',
inputs: {
public_url: 'https://acme.example',
app_name: 'NanoClaw',
wire_owner: 'no',
wire_target: 'logged-in-account',
signout: 'yes',
},
confirm: async () => true,
openUrl: async () => {},
});
// The conversation was created with the CLI account's own id…
const create = log.find((c) => c.includes('/v3/conversations') && !c.includes('/members'));
expect(create).toContain('aad-owner-1');
// …and the wire inputs resolved exactly as a first-ask yes would.
expect(res.vars.owner_handle).toBe('29:owner-xyz');
expect(res.vars.platform_id).toBe('teams:b64:b64');
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
// expose): when the skill binds owner_handle + platform_id, the adapter asks
// nothing extra (agentName/role injected) and reaches the shared wire with
// the composed teams user id.
const wireChannel = 'wiretest';
const wireSkillDir = join(process.cwd(), '.claude/skills', `add-${wireChannel}`);
afterEach(() => rmSync(wireSkillDir, { recursive: true, force: true }));
it('wireIfResolved: a run that resolves owner_handle + platform_id wires through init-first-agent', async () => {
const root = mkdtempSync(join(tmpdir(), 'rcs-wiretest-'));
writeFileSync(join(root, 'package.json'), '{"name":"scratch"}');
writeFileSync(join(root, '.env'), '');
mkdirSync(wireSkillDir, { recursive: true });
writeFileSync(
join(wireSkillDir, 'SKILL.md'),
[
`# add ${wireChannel}`,
'',
'## Resolve',
'```nc:run capture:owner_handle effect:fetch',
'echo-owner',
'```',
'```nc:run capture:platform_id effect:fetch',
'echo-platform',
'```',
'',
].join('\n'),
);
const wired: Array<Record<string, unknown>> = [];
await runChannelSkill(wireChannel, 'Dan Mill', {
projectRoot: root,
exec: (c) => {
if (c === 'echo-owner') return '29:owner-xyz\n';
if (c === 'echo-platform') return 'teams:enc-conv:enc-url\n';
},
resolveRemote: () => 'origin',
wireIfResolved: true,
agentName: 'Nano',
role: 'owner',
wire: (a) => {
wired.push(a);
return true;
},
});
expect(wired).toHaveLength(1);
expect(wired[0]).toMatchObject({
channel: wireChannel,
userId: `${wireChannel}:29:owner-xyz`,
platformId: 'teams:enc-conv:enc-url',
displayName: 'Dan Mill',
agentName: 'Nano',
role: 'owner',
});
});
// 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.
+33 -35
View File
@@ -15,12 +15,9 @@
* So the wire lives in exactly one place (init-first-agent) and is never
* duplicated across channel skills.
*/
import { writeFileSync } from 'node:fs';
import * as p from '@clack/prompts';
import { firstFailureHint, fullyApplied } from '../../scripts/skill-apply.js';
import * as setupLog from '../logs.js';
import { BACK_TO_CHANNEL_SELECTION, backGate, type ChannelFlowResult } from '../lib/back-nav.js';
import { askOperatorRole, type OperatorRole } from '../lib/role-prompt.js';
import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js';
@@ -76,17 +73,18 @@ export interface ChannelSkillOverrides extends Partial<RunSkillOptions> {
/** The shared wire; defaults to init-first-agent. Injectable for tests. */
wire?: (args: WireArgs) => Promise<boolean> | boolean;
/**
* Skip the resolve+wire half. For a channel whose platform_id only exists
* after the first inbound (Teams): the SKILL.md installs the adapter and ends
* with an `nc:operator` handoff telling the operator to DM the bot and finish
* wiring later. No owner_handle/platform_id is expected, no agent name/role is
* asked, and init-first-agent is not run.
* Wire only when the skill resolved owner_handle + platform_id this run
* (Teams: the guarded DM-open steps only run on a fresh create). Resolved
* ask agent name/role and wire like any channel; unresolved (a drop-through
* re-run the first run's wiring still stands) skip the wire and let the
* SKILL's prose own the handoff. The name/role prompts are deferred until
* after the skill run so the drop-through path asks nothing.
*/
deferWire?: boolean;
wireIfResolved?: boolean;
/**
* Offer the "← Back to channel selection" gate as the very first prompt,
* before any side effect (agent-name/role prompts, the skill run, the wire
* and the deferWire/Teams path too). On back, returns the
* before any side effect (agent-name/role prompts, the skill run, the
* wire). On back, returns the
* `BACK_TO_CHANNEL_SELECTION` sentinel and does nothing else. Opt-in so
* headless callers (and the existing tests) never see the extra prompt.
*/
@@ -103,7 +101,7 @@ export async function runChannelSkill(
overrides: ChannelSkillOverrides = {},
): Promise<ChannelFlowResult> {
// First-prompt back gate — the very first thing, before any side effect
// (agent-name/role prompts, the skill run, the wire; covers deferWire too).
// (agent-name/role prompts, the skill run, the wire).
// Opt-in via offerBack so headless callers + existing tests are unaffected.
if (overrides.offerBack) {
const label = channel.charAt(0).toUpperCase() + channel.slice(1);
@@ -113,9 +111,12 @@ export async function runChannelSkill(
const projectRoot = overrides.projectRoot ?? process.cwd();
const failWith = overrides.fail ?? fail;
// The agent name + role are wire inputs — skip the prompts when the wire is deferred.
const agentName = overrides.deferWire ? '' : overrides.agentName ?? (await resolveAgentName());
const role = overrides.deferWire ? undefined : overrides.role ?? (await askOperatorRole(channel));
// The agent name + role are wire inputs — in wireIfResolved mode, defer the
// prompts past the skill run (only a fresh create resolves the wire inputs;
// a drop-through re-run asks nothing).
const askLater = overrides.wireIfResolved;
let agentName = askLater ? '' : overrides.agentName ?? (await resolveAgentName());
let role = askLater ? undefined : overrides.role ?? (await askOperatorRole(channel));
// Channel-specific: install adapter, collect credentials, resolve the wire
// inputs. The whole channel-specific procedure lives in the SKILL.md.
@@ -140,21 +141,12 @@ export async function runChannelSkill(
});
if (!fullyApplied(res)) {
if (res.deferred.length) p.log.warn(`Still needs: ${res.deferred.join(', ')}`);
// A bounced reason can carry a full stderr dump (a Node stacktrace). The
// terminal gets ONE line per bounce — the first line, which hostExec
// composes as `exit <code>: <first stderr line>` — and the full text goes
// to a raw step log, written only when there's actually more than one line
// to keep (SSF-004; the reference prose is deliberately not dumped either).
let rawLog: string | undefined;
if (res.agentTasks.some((t) => t.reason.includes('\n'))) {
rawLog = setupLog.stepRawLog(`${channel}-install-bounce`);
writeFileSync(rawLog, res.agentTasks.map((t) => `## ${t.kind} (line ${t.line})\n${t.reason}\n`).join('\n'));
}
for (const t of res.agentTasks) {
const lines = t.reason.split('\n').map((l) => l.trim()).filter(Boolean);
const more = lines.length > 1 ? ` (+${lines.length - 1} more lines in ${rawLog})` : '';
p.log.warn(`Needs an agent (${t.kind}): ${lines[0] ?? t.reason}${more}`);
}
for (const t of res.agentTasks) p.log.warn(`Needs an agent (${t.kind}): ${t.reason}`);
// On a real bounce, also surface the skill's reference floor (Alternatives /
// Optional configuration / Troubleshooting) — the same prose a human reader
// would scroll to when a step doesn't apply cleanly. Only on a bounce
// (agentTasks), and only when the skill actually ships a reference section.
if (res.agentTasks.length && res.referenceProse) p.log.info(res.referenceProse);
// Surface the bounced step's OWN prose as the failure hint + Claude-handoff
// context (fail() dims the hint and forwards it to offerClaudeOnFailure),
// instead of a generic "couldn't finish" message. Only a real bounce yields a
@@ -164,18 +156,20 @@ export async function runChannelSkill(
`${channel}-install`,
diag?.headline ?? `Couldn't finish setting up ${channel}.`,
diag?.hint ?? 'See logs/setup-steps/ for details, then retry setup.',
rawLog,
);
}
// Identity confirmation captured by the skill (e.g. add-slack's auth.test).
if (res.vars.connected_as) p.log.success(`Connected to ${channel} as ${res.vars.connected_as}.`);
// Deferred wire (Teams): the SKILL's operator handoff owns the rest. Done here.
if (overrides.deferWire) return;
const ownerHandle = res.vars.owner_handle;
const platformId = res.vars.platform_id;
if (overrides.wireIfResolved && (!ownerHandle || !platformId)) {
// Drop-through re-run: the guarded resolve steps were skipped, so there is
// nothing new to wire — the first run's wiring still stands (verify's
// pending path covers a truly unwired install).
return;
}
if (!ownerHandle || !platformId) {
await failWith(
`${channel}-resolve`,
@@ -183,9 +177,13 @@ export async function runChannelSkill(
'The skill did not produce owner_handle + platform_id.',
);
}
if (overrides.wireIfResolved) {
agentName = overrides.agentName ?? (await resolveAgentName());
role = overrides.role ?? (await askOperatorRole(channel));
}
// Shared wire — the same procedure for every channel. role is defined here:
// it's only undefined in deferWire mode, which returned above.
// it's only undefined in an unresolved wireIfResolved run (returned above).
const wire = overrides.wire ?? initFirstAgent;
const ok = await wire({ channel, userId: `${channel}:${ownerHandle}`, platformId, displayName, agentName, role: role! });
if (!ok) {
+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',
+6 -79
View File
@@ -3,7 +3,7 @@ import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, chmodSync } from '
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { runSkill, hostExec, hostExecStream, labelOrdinals, literalChoices, promptValidator, clackResolveInput, type RunSkillOptions } from './skill-driver.js';
import { runSkill, hostExec, hostExecStream, labelOrdinals, promptValidator, clackResolveInput, type RunSkillOptions } from './skill-driver.js';
import { fullyApplied, type ApplyEvent } from '../../scripts/skill-apply.js';
// Shared test state for the clack + claude-handoff mocks (hoisted so the vi.mock
@@ -15,7 +15,6 @@ const ce = vi.hoisted(() => ({
handoffSpy: vi.fn(async (_ctx: { channel: string; step: string; stepDescription: string }) => true),
answers: [] as string[],
lastValidate: { fn: undefined as undefined | ((v: string) => string | Error | void | undefined) },
lastSelectOptions: { values: undefined as undefined | string[] },
}));
// Keep isHelpEscape + validateWithHelpEscape real (clackResolveInput uses them);
@@ -33,11 +32,7 @@ vi.mock('@clack/prompts', async (importActual) => {
ce.lastValidate.fn = o?.validate;
return ce.answers.shift() ?? '';
};
const fromQueueSelect = async (o: { options: Array<{ value: string }> }): Promise<string> => {
ce.lastSelectOptions.values = o.options.map((x) => x.value);
return ce.answers.shift() ?? o.options[0].value;
};
return { ...actual, text: vi.fn(fromQueue), password: vi.fn(fromQueue), select: vi.fn(fromQueueSelect) };
return { ...actual, text: vi.fn(fromQueue), password: vi.fn(fromQueue) };
});
// A small SKILL.md exercising the three things the driver wires: an operator
@@ -118,39 +113,18 @@ describe('thin skill driver', () => {
expect(starts).toEqual([{ kind: 'run', label: 'Wire' }]);
});
it('hostExec puts the project bin/ on PATH so a bare command resolves to it', async () => {
it('hostExec puts the project bin/ on PATH so a bare command resolves to it', () => {
const root = mkdtempSync(join(tmpdir(), 'driver-bin-'));
mkdirSync(join(root, 'bin'));
writeFileSync(join(root, 'bin/greet'), '#!/usr/bin/env bash\necho hi-from-bin\n');
chmodSync(join(root, 'bin/greet'), 0o755);
const out = await hostExec(root)('greet'); // bare name, not ./bin/greet
const out = hostExec(root)('greet'); // bare name, not ./bin/greet
expect(String(out).trim()).toBe('hi-from-bin');
});
it('hostExec returns stdout so a capture run can bind it', async () => {
it('hostExec returns stdout so a capture run can bind it', () => {
const root = mkdtempSync(join(tmpdir(), 'driver-cap-'));
expect(String(await hostExec(root)('echo D0CHANNEL')).trim()).toBe('D0CHANNEL');
});
it('hostExec recomposes a failure as `exit <code>: <first stderr line>` with the full stderr kept below', async () => {
const root = mkdtempSync(join(tmpdir(), 'driver-fail-'));
const run = (): Promise<string> => hostExec(root)('echo "boom: first line" >&2; echo "stack line two" >&2; exit 7');
await expect(run).rejects.toThrow(/^exit 7: boom: first line/); // one-line consumers read this
await expect(run).rejects.toThrow(/stack line two/); // full stderr survives for the agentTask reason
});
it('hostExec tees each command + stdout/stderr to the raw log, success and failure alike', async () => {
const root = mkdtempSync(join(tmpdir(), 'driver-tee-'));
const rawLog = join(root, 'raw.log');
const exec = hostExec(root, rawLog);
await exec('echo out-line; echo warn-line >&2');
await expect(exec('echo dying-gasp >&2; exit 3')).rejects.toThrow(/exit 3/);
const log = readFileSync(rawLog, 'utf8');
expect(log).toContain('$ echo out-line; echo warn-line >&2');
expect(log).toContain('out-line');
expect(log).toContain('warn-line'); // stderr captured, not echoed to the wizard
expect(log).toContain('$ echo dying-gasp >&2; exit 3');
expect(log).toContain('dying-gasp'); // the failing command's output survives too
expect(String(hostExec(root)('echo D0CHANNEL')).trim()).toBe('D0CHANNEL');
});
it('hostExecStream runs a step and captures the terminal status block fields (for effect:step)', async () => {
@@ -162,20 +136,6 @@ describe('thin skill driver', () => {
expect(out.fields.PLATFORM_ID).toBe('telegram:42');
});
it('hostExecStream children run with LOG_LEVEL=warn — host logger info noise stays off the wizard', async () => {
const root = mkdtempSync(join(tmpdir(), 'driver-loglevel-'));
const prev = process.env.LOG_LEVEL;
delete process.env.LOG_LEVEL; // simulate an operator who didn't set one
try {
const out = await hostExecStream(root)(
'echo "=== NANOCLAW SETUP: ENV ==="; echo "STATUS: success"; echo "LVL: $LOG_LEVEL"; echo "=== END ==="',
);
expect(out.fields.LVL).toBe('warn');
} finally {
if (prev !== undefined) process.env.LOG_LEVEL = prev;
}
});
function reuseScratch(): { root: string; skill: string } {
const root = mkdtempSync(join(tmpdir(), 'reuse-'));
const skill = mkdtempSync(join(tmpdir(), 'reuse-skill-'));
@@ -419,18 +379,6 @@ describe('thin skill driver', () => {
}
});
it('clackResolveInput renders an either/or validate as an arrow-key select over the literal choices', async () => {
ce.answers = ['webhook'];
ce.lastSelectOptions.values = undefined;
const ans = await clackResolveInput()('connection', {
question: 'How should Slack deliver events?',
secret: false,
validate: '^(socket|webhook)$',
});
expect(ans).toBe('webhook');
expect(ce.lastSelectOptions.values).toEqual(['socket', 'webhook']); // the options came from the regex
});
it('clackResolveInput passes a normal answer straight through — no handoff', async () => {
ce.handoffSpy.mockClear();
ce.answers = ['just-a-token'];
@@ -454,27 +402,6 @@ describe('thin skill driver', () => {
});
});
// An either/or `nc:prompt` renders as a select — the choices come from the
// validate regex itself (no grammar addition). Only a fully-anchored
// pure-literal alternation qualifies; anything with real regex syntax stays a
// text prompt (SSF-003).
describe('literalChoices (either/or prompt → select)', () => {
it('extracts the choices from a pure-literal alternation', () => {
expect(literalChoices('^(socket|webhook)$')).toEqual(['socket', 'webhook']);
expect(literalChoices('^(qr|pairing-code)$')).toEqual(['qr', 'pairing-code']);
expect(literalChoices('^(SingleTenant|MultiTenant)$')).toEqual(['SingleTenant', 'MultiTenant']);
});
it('leaves prefixes, format unions, and non-alternations as text prompts', () => {
expect(literalChoices('^xoxb-')).toBeNull(); // unanchored prefix
expect(literalChoices('^https?://')).toBeNull(); // regex metachars
expect(literalChoices('^(\\+\\d{8,15}|[^\\s@]+@[^\\s@]+\\.[^\\s@]+)$')).toBeNull(); // imessage phone|email union
expect(literalChoices('^[0-9a-zA-Z-]+$')).toBeNull(); // char class, no alternation
expect(literalChoices('^(solo)$')).toBeNull(); // one option is not a choice
expect(literalChoices(undefined)).toBeNull();
});
});
// Two steps under one heading share a spinner caption (build + test both read
// "Build and validate") — the ordinal suffix marks them as a sequence, not a
// stuttered duplicate. Solo captions stay unsuffixed.
+18 -91
View File
@@ -13,8 +13,8 @@
* the URL offer, the prose-derived validation message.
*/
import { execSync, spawn } from 'node:child_process';
import { appendFileSync, readFileSync, writeFileSync } from 'node:fs';
import { basename, join } from 'node:path';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import * as p from '@clack/prompts';
@@ -30,7 +30,6 @@ import {
} from '../../scripts/skill-apply.js';
import { parseDirectives, promptVar } from '../../scripts/skill-directives.js';
import { extractOfferUrl, gatePolicy } from '../../scripts/skill-policy.js';
import * as setupLog from '../logs.js';
import { isHeadless } from '../platform.js';
import { openUrl } from './browser.js';
import { isHelpEscape, offerClaudeHandoff, validateWithHelpEscape } from './claude-handoff.js';
@@ -57,19 +56,6 @@ export function promptValidator(
return (v) => (re.test((v ?? '').trim()) ? undefined : `That doesn't match the expected format. ${question}`);
}
/**
* The literal alternatives of a fully-anchored pure-literal alternation
* `^(socket|webhook)$` ['socket', 'webhook'] or null for anything else
* (an unanchored prefix like `^xoxb-`, a format union with real regex syntax
* like imessage's `^(\+\d{8,15}|…)$`). This is what lets an either/or
* `nc:prompt` render as an arrow-key select with no grammar addition: the
* validate regex already enumerates the choices. Exported for tests.
*/
export function literalChoices(validate: string | undefined): string[] | null {
const m = validate?.match(/^\^\(([A-Za-z0-9_-]+(?:\|[A-Za-z0-9_-]+)+)\)\$$/);
return m ? m[1].split('|') : null;
}
/**
* Handoff context for the `?` help-escape (Step 8 / mechanism M3). A lone `?` at
* any prompt hands the operator to interactive Claude with this context, then
@@ -87,10 +73,9 @@ export interface PrompterContext {
/**
* The wizard's `resolveInput` implementation: collect an `nc:prompt` through
* clack (password for secrets, an arrow-key select for an either/or validate
* regex, text otherwise; a cancel defers), running the interactive re-ask loop
* against the prompt's declared `validate:`/`flags:` (the engine's
* validate-at-bind is the programmatic backstop, not the UX).
* clack (password for secrets, text otherwise; a cancel defers), running the
* interactive re-ask loop against the prompt's declared `validate:`/`flags:`
* (the engine's validate-at-bind is the programmatic backstop, not the UX).
*/
export function clackResolveInput(ctx: PrompterContext = {}): (name: string, meta: InputMeta) => Promise<string | undefined> {
// The `?` help-escape is only meaningful at a real terminal: it hands the
@@ -105,15 +90,9 @@ export function clackResolveInput(ctx: PrompterContext = {}): (name: string, met
const guarded = validateWithHelpEscape(check);
// clearOnError wipes a rejected secret so the operator re-pastes cleanly
// (a half-pasted token isn't left masked in the field).
// An either/or prompt renders as an arrow-key select — the options come
// straight from the validate regex (literalChoices). No re-ask loop and no
// `?` help-escape there: every choice is valid and self-describing.
const choices = meta.secret ? null : literalChoices(meta.validate);
const ans = choices
? await p.select({ message: meta.question, options: choices.map((c) => ({ value: c, label: c })) })
: meta.secret
? await p.password({ message: meta.question, validate: guarded, clearOnError: true })
: await p.text({ message: meta.question, validate: guarded });
const ans = meta.secret
? await p.password({ message: meta.question, validate: guarded, clearOnError: true })
: await p.text({ message: meta.question, validate: guarded });
if (p.isCancel(ans)) return undefined; // cancelled ⇒ defer
if (isHelpEscape(ans) && process.stdout.isTTY) {
// Operator asked for help: hand off to interactive Claude with this
@@ -245,45 +224,14 @@ async function reuseFromEnv(
* `run capture:<var>` can bind it. Puts the project's `bin/` on PATH so a bare
* `ncl …` in a wire directive resolves to `bin/ncl` even when it isn't
* symlinked onto the operator's PATH.
*
* Async (spawn, not execSync) so the step spinner keeps animating: a sync exec
* blocks the event loop for the whole command and freezes every ticker in the
* process. A failure rejects with the FIRST line as the actionable summary
* `exit <code>: <first stderr line>` and the full stderr kept below, so
* one-line consumers (run-channel-skill's bounce warn) stay readable while the
* agentTask reason an agent fixes from still carries everything.
*
* Non-step effects are captured-output steps the spinner is the only UI, and
* stderr is piped, never echoed (a chatty tool's warnings don't belong on the
* wizard screen). When `rawLog` is given, every command's stdout+stderr is
* appended there (level 3, like runner.ts's per-step raw logs) so the silenced
* noise stays inspectable.
*/
export function hostExec(projectRoot: string, rawLog?: string): (cmd: string) => Promise<string> {
const tee = (cmd: string, stdout: string, stderr: string): void => {
if (!rawLog) return;
const body = [stdout, stderr].filter(Boolean).join('');
appendFileSync(rawLog, `$ ${cmd}\n${body}${body && !body.endsWith('\n') ? '\n' : ''}\n`);
};
export function hostExec(projectRoot: string): (cmd: string) => string {
return (cmd) =>
new Promise((resolve, reject) => {
const child = spawn('bash', ['-c', cmd], {
cwd: projectRoot,
env: { ...process.env, PATH: `${join(projectRoot, 'bin')}:${process.env.PATH ?? ''}` },
stdio: ['ignore', 'pipe', 'pipe'],
});
let out = '';
let err = '';
child.stdout.on('data', (c: Buffer) => { out += c.toString('utf8'); });
child.stderr.on('data', (c: Buffer) => { err += c.toString('utf8'); });
child.on('error', reject);
child.on('close', (code) => {
tee(cmd, out, err);
if (code === 0) return resolve(out);
const stderr = err.trim();
const head = stderr.split('\n').map((l) => l.trim()).find(Boolean) ?? 'command failed';
reject(new Error(`exit ${code ?? '?'}: ${head}${stderr ? `\n${stderr}` : ''}`));
});
execSync(cmd, {
cwd: projectRoot,
shell: '/bin/bash',
encoding: 'utf8',
env: { ...process.env, PATH: `${join(projectRoot, 'bin')}:${process.env.PATH ?? ''}` },
});
}
@@ -300,20 +248,7 @@ export function hostExecStream(projectRoot: string): (cmd: string) => Promise<St
new Promise((resolve) => {
const child = spawn('bash', ['-c', cmd], {
cwd: projectRoot,
env: {
...process.env,
PATH: `${join(projectRoot, 'bin')}:${process.env.PATH ?? ''}`,
// A step renders curated operator UI (a code card, a QR) — the host
// logger's info noise doesn't belong on the wizard screen, and it
// always emits ANSI so it can't be filtered by stream. Warnings and
// errors still pass. An operator-set LOG_LEVEL wins (debugging).
LOG_LEVEL: process.env.LOG_LEVEL ?? 'warn',
// The child's stdout is a pipe, so picocolors would strip its clack
// rendering to bare box chars that clash with the wizard theme.
// When the OPERATOR's terminal is a real TTY, the teed lines land
// there — force color so the child's card matches the parent.
...(process.stdout.isTTY ? { FORCE_COLOR: '1' } : {}),
},
env: { ...process.env, PATH: `${join(projectRoot, 'bin')}:${process.env.PATH ?? ''}` },
stdio: ['inherit', 'pipe', 'pipe'],
});
const blocks: Array<{ fields: Record<string, string> }> = [];
@@ -416,7 +351,7 @@ function defaultOnEvent(
return;
}
// operator: note → URL offer → natural-barrier confirm.
p.note(e.text, 'Your turn');
p.note(e.text, 'Do this');
const url = extractOfferUrl(e.text);
if (url !== undefined && (await confirm(`Open ${url} in your browser?`))) await open(url);
const gate = gates.get(e.line);
@@ -447,7 +382,7 @@ export interface RunSkillOptions {
*/
resolveInput?: (name: string, meta: InputMeta) => Promise<string | undefined>;
/** Defaults to `hostExec`. */
exec?: (cmd: string) => string | void | Promise<string | void>;
exec?: (cmd: string) => string | void;
/** Defaults to `hostExecStream`. Streaming exec for `nc:run effect:step`. */
execStream?: (cmd: string) => Promise<StepOutcome>;
/** Defaults to the fork-aware channels-branch resolver. */
@@ -510,19 +445,11 @@ export async function runSkill(skillDir: string, opts: RunSkillOptions = {}): Pr
} catch {
// missing SKILL.md — the engine will produce an empty result anyway
}
// One raw log per skill apply (level 3): every default-exec command appends
// its `$ cmd` + output there. Allocated only when the default exec is used —
// an injected exec (tests, agent relay) owns its own capture.
let rawLog: string | undefined;
if (!opts.exec) {
rawLog = setupLog.stepRawLog(`skill-${basename(skillDir)}`);
writeFileSync(rawLog, `# skill ${basename(skillDir)}${new Date().toISOString()}\n\n`);
}
return applySkill(skillDir, projectRoot, {
inputs,
resolveInput: opts.resolveInput ?? clackResolveInput({ channel: opts.channel, step: opts.step }),
onEvent: opts.onEvent ?? defaultOnEvent(md, confirm, open),
exec: opts.exec ?? hostExec(projectRoot, rawLog),
exec: opts.exec ?? hostExec(projectRoot),
execStream: opts.execStream ?? hostExecStream(projectRoot),
resolveRemote: opts.resolveRemote ?? channelsRemote(projectRoot),
skipEffects: opts.skipEffects,
+150
View File
@@ -0,0 +1,150 @@
/**
* 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('leaves no template placeholder values in the rendered manifest', () => {
const raw = entries[0].data.toString('utf8');
expect(raw).not.toContain('00000000-0000-0000-0000-000000000000');
expect(raw).not.toContain('nanoclaw.invalid');
});
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' });
});
});
+73 -219
View File
@@ -7,23 +7,19 @@
* - 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.
*
* The manifest is pinned to schema v1.16 to match the skill doc.
* The static parts live in setup/assets/teams/ manifest.template.json
* (pinned to schema v1.16 to match the skill doc) plus the two icons so
* the manifest is reviewable as plain JSON. This module only fills in the
* per-install fields (app id, name, domain, optional RSC block) and zips the
* three files in-process via ./zip.ts, so no `zip` binary is needed on the
* host.
*/
import { execSync } from 'child_process';
import fs from 'fs';
import path from 'path';
import zlib from 'zlib';
const MANIFEST_SCHEMA =
'https://developer.microsoft.com/en-us/json-schemas/teams/v1.16/MicrosoftTeams.schema.json';
const MANIFEST_VERSION = '1.16';
import { buildZip } from './zip.js';
const ASSETS_DIR = new URL('../assets/teams/', import.meta.url);
export interface ManifestOptions {
/** The Azure AD app ID (same value used for `bots[0].botId`). */
@@ -36,6 +32,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 {
@@ -45,6 +46,19 @@ export interface ManifestResult {
colorPath: string;
}
/** The manifest.template.json fields this module rewrites per install. */
interface ManifestTemplate {
version: string;
id: string;
developer: { name: string; websiteUrl: string; privacyUrl: string; termsOfUseUrl: string };
name: { short: string; full: string };
description: { short: string; full: string };
bots: [{ botId: string }];
validDomains: string[];
webApplicationInfo?: { id: string; resource: string };
authorization?: { permissions: { resourceSpecific: Array<{ name: string; type: string }> } };
}
/** Build the full app package zip and return the paths. */
export function buildTeamsAppPackage(opts: ManifestOptions): ManifestResult {
fs.mkdirSync(opts.outDir, { recursive: true });
@@ -54,218 +68,58 @@ 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 = fs.readFileSync(new URL('outline.png', ASSETS_DIR));
const color = fs.readFileSync(new URL('color.png', ASSETS_DIR));
// 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 };
}
function renderManifest(opts: ManifestOptions): string {
const manifest = {
$schema: MANIFEST_SCHEMA,
manifestVersion: MANIFEST_VERSION,
version: '1.0.0',
id: opts.appId,
packageName: 'com.nanoclaw.bot',
developer: {
name: 'NanoClaw',
websiteUrl: opts.websiteUrl,
privacyUrl: opts.websiteUrl,
termsOfUseUrl: opts.websiteUrl,
},
name: {
short: opts.shortName.slice(0, 30),
full: `${opts.shortName} Assistant`,
},
description: {
short: 'Your personal assistant in Teams.',
full: opts.longDescription,
},
icons: { outline: 'outline.png', color: 'color.png' },
accentColor: '#4A90D9',
bots: [
{
botId: opts.appId,
scopes: ['personal', 'team', 'groupchat'],
supportsFiles: false,
isNotificationOnly: false,
const manifest = JSON.parse(
fs.readFileSync(new URL('manifest.template.json', ASSETS_DIR), 'utf8'),
) as ManifestTemplate;
manifest.id = opts.appId;
manifest.bots[0].botId = opts.appId;
manifest.name.short = opts.shortName.slice(0, 30);
manifest.name.full = `${opts.shortName} Assistant`;
manifest.description.full = opts.longDescription;
manifest.developer.websiteUrl = opts.websiteUrl;
manifest.developer.privacyUrl = opts.websiteUrl;
manifest.developer.termsOfUseUrl = opts.websiteUrl;
manifest.validDomains = [new URL(opts.websiteUrl).host];
if (opts.rsc) {
// Teams app-update flows want a higher version than the already-uploaded
// package, so the RSC variant (typically a re-upload) bumps it.
manifest.version = '1.1.0';
// 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.
manifest.webApplicationInfo = { id: opts.appId, resource: 'https://notapplicable' };
manifest.authorization = {
permissions: {
resourceSpecific: [
{ name: 'ChannelMessage.Read.Group', type: 'Application' },
{ name: 'ChatMessage.Read.Chat', type: 'Application' },
],
},
],
permissions: ['identity', 'messageTeamMembers'],
validDomains: [new URL(opts.websiteUrl).host],
};
};
}
return JSON.stringify(manifest, null, 2) + '\n';
}
// ─── Minimal PNG encoder (solid color, no external deps) ──────────────────
const PNG_SIG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
// Precompute the CRC-32 table per the PNG spec. Node doesn't expose CRC32
// directly (zlib.crc32 isn't part of the public API), so we roll our own.
const CRC_TABLE = (() => {
const table = new Uint32Array(256);
for (let n = 0; n < 256; n++) {
let c = n;
for (let k = 0; k < 8; k++) {
c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
}
table[n] = c >>> 0;
}
return table;
})();
function crc32(buf: Buffer): number {
let c = 0xffffffff;
for (let i = 0; i < buf.length; i++) {
c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
}
return (c ^ 0xffffffff) >>> 0;
}
function chunk(type: string, data: Buffer): Buffer {
const len = Buffer.alloc(4);
len.writeUInt32BE(data.length, 0);
const typeBuf = Buffer.from(type, 'ascii');
const crcBuf = Buffer.alloc(4);
crcBuf.writeUInt32BE(crc32(Buffer.concat([typeBuf, data])), 0);
return Buffer.concat([len, typeBuf, data, crcBuf]);
}
/**
* Encode a solid-color RGBA image as a PNG. `pixels` is a width*height*4
* byte array (R, G, B, A per pixel, row-major, top-to-bottom).
*/
function encodePng(width: number, height: number, pixels: Uint8Array): Buffer {
// IHDR
const ihdr = Buffer.alloc(13);
ihdr.writeUInt32BE(width, 0);
ihdr.writeUInt32BE(height, 4);
ihdr[8] = 8; // bit depth
ihdr[9] = 6; // color type: RGBA
ihdr[10] = 0; // compression
ihdr[11] = 0; // filter
ihdr[12] = 0; // interlace
// IDAT: scanlines with filter byte 0 (None) prepended per row.
const rowBytes = width * 4;
const raw = Buffer.alloc(height * (rowBytes + 1));
for (let y = 0; y < height; y++) {
raw[y * (rowBytes + 1)] = 0;
for (let x = 0; x < rowBytes; x++) {
raw[y * (rowBytes + 1) + 1 + x] = pixels[y * rowBytes + x];
}
}
const idat = zlib.deflateSync(raw);
return Buffer.concat([
PNG_SIG,
chunk('IHDR', ihdr),
chunk('IDAT', idat),
chunk('IEND', Buffer.alloc(0)),
]);
}
/**
* Outline icon: 32×32 transparent background with a simple white rounded-
* square outline. Teams renders it against a colored background so the
* outline needs to be visible on both light and dark.
*/
function encodeOutlineIcon(): Buffer {
const size = 32;
const pixels = new Uint8Array(size * size * 4);
const inset = 4;
const stroke = 2;
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
const onBorder =
((x >= inset && x < inset + stroke) || (x >= size - inset - stroke && x < size - inset)) &&
y >= inset &&
y < size - inset;
const onTopBot =
((y >= inset && y < inset + stroke) || (y >= size - inset - stroke && y < size - inset)) &&
x >= inset &&
x < size - inset;
const i = (y * size + x) * 4;
if (onBorder || onTopBot) {
pixels[i] = 255;
pixels[i + 1] = 255;
pixels[i + 2] = 255;
pixels[i + 3] = 255;
} else {
pixels[i] = 0;
pixels[i + 1] = 0;
pixels[i + 2] = 0;
pixels[i + 3] = 0; // transparent
}
}
}
return encodePng(size, size, pixels);
}
/**
* Color icon: 192×192 brand-blue filled square with a white "N" shape drawn
* with simple bars (left vertical, right vertical, diagonal from top-right
* to bottom-left). Crude but recognizable at a glance.
*/
function encodeColorIcon(): Buffer {
const size = 192;
const pixels = new Uint8Array(size * size * 4);
// Brand blue #4A90D9
const BG_R = 0x4a;
const BG_G = 0x90;
const BG_B = 0xd9;
const thickness = 24;
const margin = 40;
const leftBarX = margin;
const rightBarX = size - margin - thickness;
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
const i = (y * size + x) * 4;
pixels[i] = BG_R;
pixels[i + 1] = BG_G;
pixels[i + 2] = BG_B;
pixels[i + 3] = 255;
}
}
// Vertical bars
for (let y = margin; y < size - margin; y++) {
for (let dx = 0; dx < thickness; dx++) {
setWhite(pixels, size, leftBarX + dx, y);
setWhite(pixels, size, rightBarX + dx, y);
}
}
// Diagonal from top-right of left bar to bottom-left of right bar
const diagSteps = size - margin * 2;
for (let s = 0; s < diagSteps; s++) {
const t = s / (diagSteps - 1);
const cx = Math.round(leftBarX + thickness + t * (rightBarX - leftBarX - thickness));
const cy = Math.round(margin + t * (size - margin * 2 - 1));
for (let dx = -Math.floor(thickness / 2); dx < Math.ceil(thickness / 2); dx++) {
for (let dy = -2; dy <= 2; dy++) {
setWhite(pixels, size, cx + dx, cy + dy);
}
}
}
return encodePng(size, size, pixels);
}
function setWhite(pixels: Uint8Array, size: number, x: number, y: number): void {
if (x < 0 || x >= size || y < 0 || y >= size) return;
const i = (y * size + x) * 4;
pixels[i] = 255;
pixels[i + 1] = 255;
pixels[i + 2] = 255;
pixels[i + 3] = 255;
}
+87
View File
@@ -0,0 +1,87 @@
/**
* Minimal in-process ZIP writer needs no external dep and no `zip` binary
* on the host (minimal Linux images often lack one).
*
* Entries are stored uncompressed (method 0): callers package a few tiny
* files, and stored entries keep the output trivially small and
* byte-deterministic (fixed 1980-01-01 timestamp), which tests rely on.
* ASCII entry names only; no zip64 fine below 4 GB and 65k entries.
*/
export 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;
export 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]);
}
// Precompute the CRC-32 table per the ZIP spec. zlib.crc32 only became
// public API in Node 20.15/22.2, so we roll our own rather than gamble on
// the host's Node version.
const CRC_TABLE = (() => {
const table = new Uint32Array(256);
for (let n = 0; n < 256; n++) {
let c = n;
for (let k = 0; k < 8; k++) {
c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
}
table[n] = c >>> 0;
}
return table;
})();
function crc32(buf: Buffer): number {
let c = 0xffffffff;
for (let i = 0; i < buf.length; i++) {
c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
}
return (c ^ 0xffffffff) >>> 0;
}
+22 -16
View File
@@ -2,9 +2,9 @@
* Step: pair-telegram issue a one-time pairing code and wait for the
* operator to send the code from the chat they want to register.
*
* Renders the human-facing code card itself (see printCodeCard) and emits
* machine-readable status blocks alongside for the programmatic callers
* (/manage-channels, /init-first-agent) that parse them.
* Emits machine-readable status blocks only. The parent driver
* (`setup:auto`) renders the code / attempt / success UI with clack. Running
* this step directly will look sparse that's intentional.
*
* Blocks emitted:
* PAIR_TELEGRAM_CODE { CODE, REASON=initial|regenerated }
@@ -20,8 +20,6 @@
*/
import path from 'path';
import * as p from '@clack/prompts';
import {
createPairing,
waitForPairing,
@@ -58,26 +56,34 @@ function intentToString(intent: PairingIntent): string {
}
/**
* Render the pairing code card with clack's STATIC primitives (note/log).
* Render the pairing code and live feedback as PLAIN stdout lines.
*
* The Option A driver's streaming exec (setup/lib/skill-driver.ts
* `hostExecStream`) CONSUMES the `=== NANOCLAW SETUP: … ===` status blocks (it
* does not show them) and tees every OTHER stdout line verbatim to the
* operator's terminal. Static clack output is just lines, so it survives that
* tee and reads like the rest of the wizard only INTERACTIVE/animated clack
* widgets need the real TTY the piped child doesn't have (SSF-002).
* does not show them) and tees every OTHER stdout line straight to the
* operator's terminal. So the human-facing code card has to be printed as plain
* lines here the bespoke setup/channels/telegram.ts used to render these from
* the blocks, and that rendering now lives in the step itself. The structured
* blocks are still emitted alongside for the agent-driven callers
* (/manage-channels, /init-first-agent) that parse them.
*/
function printCodeCard(code: string, reason: 'initial' | 'regenerated'): void {
const spaced = code.split('').join(' ');
p.note(
`${spaced}\n\nSend these 4 digits to your bot from Telegram.`,
reason === 'initial' ? 'Your pairing code is ready' : 'That code was used up — here is a fresh one',
console.log('');
console.log(
reason === 'initial'
? 'Your pairing code is ready.'
: 'That code was used up — here is a fresh one.',
);
p.log.message('Waiting for you to send the code…');
console.log('');
console.log(` ${spaced}`);
console.log('');
console.log('Send these 4 digits to your bot from Telegram.');
console.log('Waiting for you to send the code…');
}
function printAttempt(candidate: string): void {
p.log.warn(`Got "${candidate}", which doesn't match — waiting for the correct code…`);
console.log(`Got "${candidate}", which doesn't match — waiting for the correct code…`);
}
export async function run(args: string[]): Promise<void> {
@@ -109,7 +115,7 @@ export async function run(args: string[]): Promise<void> {
},
});
p.log.success('Telegram paired.');
console.log('\nTelegram paired.');
emitStatus('PAIR_TELEGRAM', {
STATUS: 'success',
CODE: record.code,
+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({
+26 -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,25 @@ 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
* wireIfResolved call site in setup/auto.ts (its unresolved drop-through
* leaves the channel configured but unwired).
*/
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';
}