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." 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-). 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. 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.) 6. Install to Workspace, then copy the Bot User OAuth Token (starts with xoxb-).
7. Install to Workspace, then copy the Bot User OAuth Token (starts with xoxb-).
``` ```
For webhook delivery, tell the user: 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` With webhook delivery, the bridge serves port 3000 at `/webhook/slack`
automatically; to receive replies, that port must be reachable from the internet 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 — and registered with Slack (Socket Mode skips all of this — events arrive over
with the bot events subscribed above, events arrive over the socket as soon as the socket as soon as the service restarts). Tell the user:
the service restarts). Tell the user:
```nc:operator when:connection=webhook ```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): 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 ## 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: Remove `TEAMS_APP_ID`, `TEAMS_APP_PASSWORD`, `TEAMS_APP_TENANT_ID`, and `TEAMS_APP_TYPE` from `.env`.
```bash ## 3. Sign out the Teams CLI, then remove the packages
mkdir -p data/env && cp .env data/env/env
``` `teams login` caches a Microsoft 365 session on disk that outlives the package —
sign out first (skip if the CLI was never installed):
## 3. Remove the package
```bash ```bash
teams logout
npm uninstall -g @microsoft/teams.cli
pnpm uninstall @chat-adapter/teams 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 ```bash
pnpm run build 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 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. 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 Teams has no "paste a token" shortcut — a bot has to exist in Microsoft's cloud
token" shortcut. You'll walk through about seven Azure portal steps (app before it can receive a message. The Microsoft Teams CLI collapses that into
registration, client secret, Azure Bot resource, messaging endpoint, Teams one sign-in and one create command: it registers the Entra app, generates the
channel, app package, sideload). Take them one at a time; the prompts below client secret, registers a Teams-managed bot (through the Teams Developer
collect each value as you produce it. 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 ## Apply
@@ -71,139 +74,268 @@ runs.
## Credentials ## Credentials
The adapter is installed and registered, but it can't receive a message until a 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 bot exists, points at this machine, and is installed into Teams. The Teams CLI
of those steps can be clicked through by a parser, so they're operator does all of that below.
instructions — relay each one, then collect the value it produces.
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: 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. 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 from this machine (ngrok, a Cloudflare Tunnel, or a reverse-proxied VPS). Azure Bot Service delivers activities to it. 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 ### Public URL
Azure Bot Service delivers messages to an HTTPS endpoint you control; it has to Microsoft delivers bot messages to an HTTPS endpoint you control; it has to
reach this machine's webhook server (port 3000) at `/api/webhooks/teams`. If you reach this machine's webhook server (port 3000, configurable via
don't have a tunnel running yet, start one in another terminal first — e.g. `WEBHOOK_PORT`) at `/webhook/teams`.
`ngrok http 3000` gives you `https://abcd1234.ngrok.io`.
```nc:prompt public_url validate:^https:// normalize:rstrip-slash ```nc:prompt public_url when:have_creds=no validate:^https:// normalize:rstrip-slash
Paste your public base URL (https://…, no trailing path) — e.g. https://abcd1234.ngrok.io. 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 ```nc:prompt app_name when:have_creds=no validate:^[\sA-Za-z0-9._-]{1,30}$ normalize:trim
Create the Azure AD app registration: 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.
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_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}$ ### Install the Teams CLI
Paste the Application (client) ID — App registration Overview page.
``` Installed globally with npm — not as a workspace dependency — deliberately:
```nc:prompt app_type validate:^(SingleTenant|MultiTenant)$ the CLI's credential store (keytar) is a native module whose install script
Enter the app type — `SingleTenant` or `MultiTenant` (must match the account type you picked). 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
```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}$ persist. The global install matches Microsoft's own instruction and keeps the
Paste the Directory (tenant) ID — App registration Overview page (Single Tenant only). 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 Every `teams` command is a separate process, so the sign-in must survive into
Create the client secret: the next one via the CLI's on-disk token cache. A "libsecret not found —
1. In your app registration, open "Certificates & secrets". token cache will be stored unencrypted" warning here is safe to ignore: the
2. Click "New client secret" — Description "nanoclaw", Expires 180 days (recommended) or longer. CLI falls back to a plaintext cache file that persists fine, and setup signs
3. Click Add. the session out at the end anyway. The login output may
4. COPY THE VALUE NOW — Azure only shows it once (the Value column, not the Secret ID). 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,}$ ### Create the bot
Paste the client secret Value — Certificates & secrets (shown only once, at least 20 characters).
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 ### Store the credentials
The adapter reads these from `.env` (set-if-absent, so a value you've already The adapter reads these from `.env` (set-if-absent a value you've already
filled in is never overwritten). filled in is never overwritten). The pairing matters: `SingleTenant` requires
`TEAMS_APP_TENANT_ID` is written only for a Single Tenant app; Multi Tenant `TEAMS_APP_TENANT_ID`, and a multi-tenant app must instead set
doesn't need it. `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_ID={{app_id}}
TEAMS_APP_PASSWORD={{app_password}} 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_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: Tell the user:
```nc:operator ```nc:operator when:have_creds=no
Create the Azure Bot resource and point it at this machine: Install the bot into Teams:
1. In https://portal.azure.com, search "Azure Bot" → Create. 1. Open {{install_link}} — Teams opens with the app's install dialog. Click Add.
2. Bot handle: a unique name, e.g. nanoclaw-bot. 2. If you need the link again later, run: teams app get {{teams_app_id}} --install-link
3. Type of App: {{app_type}} — Creation type: Use existing app registration. 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.
4. App ID: {{app_id}}. Once the app shows up in your Teams sidebar (or app list), continue.
5. After creating, open the bot → Configuration and set Messaging endpoint to {{public_url}}/api/webhooks/teams, then Apply.
``` ```
### Enable the Teams channel ### Link the bot to your account
Tell the user (finish every Azure step above before continuing — the package Nothing to do in Teams yet — these are background API calls, and the whole
built next bakes in the app registration, so the Azure app and bot must already chain runs only for a confirmed target from
exist): [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 First a Bot Framework token from the app credentials:
Enable the Microsoft Teams channel on the bot:
1. Open your Azure Bot resource → Channels. ```nc:run effect:fetch when:wire_owner=yes capture:bot_token validate:^eyJ
2. Click Microsoft Teams → Accept terms → Apply. 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'
When the Azure app, bot resource, and Teams channel are all in place, continue.
``` ```
### 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 ```nc:run effect:fetch when:wire_owner=yes capture:conversation_id validate:^.+$
above must be done first — building with a blank `app_id` produces a package that 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'
no bot can claim. Confirm it's set before generating the zip:
```nc:run effect:check
[ -n "{{app_id}}" ]
``` ```
Generate the zip you'll sideload into Teams (manifest + icons, written to The adapter identifies inbound senders by their Bot Framework `29:` id, not
`data/teams/teams-app-package.zip`). Re-running regenerates a fresh zip, so this the AAD id — the owner must be wired under that handle or their replies
is safe to repeat. 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 ```nc:run effect:fetch when:wire_owner=yes capture:owner_handle=.id,owner_name=.name validate:^.+$
pnpm exec tsx setup/channels/teams-manifest-build.ts --app-id "{{app_id}}" --url "{{public_url}}" 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 ```nc:run when:wire_owner=yes capture:platform_id validate:^teams:
the app already sideloaded): 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 ### Sign out of the Teams CLI
Sideload the generated app package into Teams:
1. Open Microsoft Teams → Apps → Manage your apps → Upload an app. The Microsoft 365 session was only needed to create the bot and identify its
2. Click "Upload a custom app" (or "Upload for me or my teams"). owner — the running adapter authenticates with the app credentials in
3. Select data/teams/teams-app-package.zip and click Add. `.env`, never with your account. On a headless box that session is a
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. plaintext token file, which is worth removing unless you plan more `teams …`
Once the app is added in Teams, continue. 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 ## Restart
@@ -217,23 +349,29 @@ bash setup/lib/restart.sh
## Finish wiring ## Finish wiring
Unlike Discord or Slack, a Teams bot's platform ID isn't known until you DM the On a fresh create, [Link the bot to your account](#link-the-bot-to-your-account) already resolved
bot for the first time — the adapter derives it from the inbound activity. So everything the wire needs — `owner_handle` (the owner's `29:` id) and
this skill installs the adapter and stops here; you finish the wiring once the `platform_id` (the bot↔owner DM). The setup wizard wires automatically from
bot has seen its first message. Tell the user: 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 ```bash
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: 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
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.
``` ```
**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 ## Next Steps
If you're in the middle of `/setup`, return to the setup flow now. Otherwise, If you're in the middle of `/setup`, return to the setup flow now — it wires
once you've DM'd the bot, wire this channel with `/init-first-agent` (or the owner automatically from the resolved values. Otherwise wire per
`/manage-channels`). [Finish wiring](#finish-wiring).
## Channel Info ## Channel Info
@@ -247,57 +385,78 @@ once you've DM'd the bot, wire this channel with `/init-first-agent` (or
## Alternatives ## Alternatives
### Auto: Teams CLI ### Multi-tenant bot
The Credentials flow above walks the manual Azure Portal path. If you'd rather The Credentials flow above always creates a single-tenant bot (only your
script it, the Microsoft Teams CLI creates the Entra app, client secret, and bot Microsoft 365 tenant can install it) — the right default for a self-hosted
registration in one command. Requires Node.js 18+, a Microsoft 365 account with assistant, so the skill doesn't ask. For a bot any tenant can install, run
sideloading permissions, and a public HTTPS endpoint (ngrok, Cloudflare Tunnel, the create by hand with `multipleOrgs` and store the matching env pairing —
or similar). `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 ```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>
``` ```
6. **Build the app package** (manifest + icons, written in-process to
2. Sign in and verify: `data/teams/teams-app-package.zip` — no `zip` binary needed):
```bash ```bash
teams login pnpm exec tsx setup/channels/teams-manifest-build.ts --app-id YOUR_APP_ID --url https://your-domain
teams status
``` ```
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: Or create the bot resource with the Azure CLI instead of the portal:
```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:
```bash ```bash
az group create --name nanoclaw-rg --location eastus az group create --name nanoclaw-rg --location eastus
az bot create \ 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"
--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 msteams create --resource-group nanoclaw-rg --name nanoclaw-bot 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) ### Receive all channel messages (without @-mention)
By default the bot only receives messages when @-mentioned. To receive every By default the bot only receives messages when @-mentioned. With a CLI-created
message in a channel, add an RSC (resource-specific consent) permission to your bot, grant the resource-specific-consent (RSC) permissions directly — no
Teams app `manifest.json`: manifest edit, no re-upload; the app version is bumped automatically:
```json ```bash
{ teams app rsc add <teams-app-id> ChannelMessage.Read.Group --type Application
"authorization": { teams app rsc add <teams-app-id> ChatMessage.Read.Chat --type Application
"permissions": {
"resourceSpecific": [
{ "name": "ChannelMessage.Read.Group", "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 ## 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 Free personal Teams does not support sideloading at all — use a Microsoft 365
Business / EDU / developer tenant. 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 ### 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. 1. The app is actually installed in Teams — if setup was interrupted before
2. The adapter started: `grep -i teams logs/nanoclaw.log | tail`. the install step, nothing got installed. Recover the install link:
3. The credentials are in `.env` (`TEAMS_APP_ID`, `TEAMS_APP_PASSWORD`, `TEAMS_APP_TYPE`). `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 ### `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 ### 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)', () => { describe('gatePolicy — §5.1 parity table (real skills)', () => {
it('teams: exactly the two authored gates — and NO confirm on the operator-chain head', () => { it('teams: one gate — the install-in-Teams operator pauses before the DM-open fetches', () => {
// Operators in order: prerequisites, portal app, client secret, bot resource // Operators in order (CLI-first flow): prerequisites, the detected-owner
// (chain head), enable-channel (chain tail), sideload, final handoff. // 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')); const d = decisions(loadSkill('teams'));
expect(d).toHaveLength(7); expect(d).toHaveLength(4);
expect(d.map((g) => g.needsConfirm)).toEqual([ expect(d.map((g) => g.needsConfirm)).toEqual([
false, // → prompt public_url (prompt is the barrier) false, // prereqs → prompt public_url (prompt is the barrier)
false, // → prompt app_id false, // detected-owner note → prompt wire_owner (prompt is the barrier)
false, // → prompt app_password false, // wire-declined note → install operator (last operator of the chain carries the barrier)
false, // bot-resource block → next compatible is another OPERATOR (rule 2): true, // install-in-Teams → the DM-open effect:fetch chain
// 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
]); ]);
// Both confirms are completed-work flavor (check/restart, not effect:step). // Completed-work flavor (fetch/external, not effect:step).
expect(d[4].flavor).toBe('completed'); expect(d[3].flavor).toBe('completed');
expect(d[5].flavor).toBe('completed');
}); });
it('telegram: the pairing operator gains a readiness pause before the effect:step', () => { 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 // §5.2 URL-offer inventory — every operator body in the tree, plus the
// normative negative fixture (slack's placeholder URL). // normative negative fixture (slack's placeholder URL).
describe('extractOfferUrl — §5.2 inventory', () => { 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'); const md = loadSkill('teams');
expect(extractOfferUrl(operatorBody(md, 1))).toBe('https://portal.azure.com'); // app registration // The install block's URL is {{install_link}} in the AUTHORED body — no
expect(extractOfferUrl(operatorBody(md, 3))).toBe('https://portal.azure.com'); // bot resource (new offer) // 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)', () => { 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; let result: void | typeof BACK_TO_CHANNEL_SELECTION;
// Every channel now runs through the SKILL.md-driven flow — the whole // Every channel now runs through the SKILL.md-driven flow — the whole
// connect+wire procedure lives in each add-<channel>/SKILL.md. Teams // connect+wire procedure lives in each add-<channel>/SKILL.md.
// defers the wire (its platform_id only exists after the first inbound),
// so it installs + hands off rather than wiring inline.
if (channelChoice === 'telegram') { if (channelChoice === 'telegram') {
result = await runChannelSkill('telegram', displayName!, { offerBack: true }); result = await runChannelSkill('telegram', displayName!, { offerBack: true });
} else if (channelChoice === 'discord') { } else if (channelChoice === 'discord') {
@@ -567,7 +565,10 @@ async function main(): Promise<void> {
} else if (channelChoice === 'signal') { } else if (channelChoice === 'signal') {
result = await runChannelSkill('signal', displayName!, { offerBack: true }); result = await runChannelSkill('signal', displayName!, { offerBack: true });
} else if (channelChoice === 'teams') { } 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') { } else if (channelChoice === 'slack') {
result = await runChannelSkill('slack', displayName!, { offerBack: true }); result = await runChannelSkill('slack', displayName!, { offerBack: true });
} else if (channelChoice === 'imessage') { } 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')) { if (!skip.has('verify')) {
const res = await runQuietStep('verify', { const res = await runQuietStep('verify', {
running: 'Making sure everything works together…', 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.')); p.outro(k.yellow('Almost there. A few things still need your attention.'));
return; return;
} }
wiringPending = res.terminal?.fields.WIRING === 'pending_first_dm';
} }
const rows: [string, string][] = [ const rows: [string, string][] = [
@@ -667,7 +675,15 @@ async function main(): Promise<void> {
phEmit('setup_completed', { duration_ms: Date.now() - RUN_START }); phEmit('setup_completed', { duration_ms: Date.now() - RUN_START });
const dmTarget = channelDmLabel(channelChoice); 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 // 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 // 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 // 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 { 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 { tmpdir } from 'node:os';
import { join } from 'node:path'; import { join } from 'node:path';
import { runChannelSkill } from './run-channel-skill.js'; 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'; import { BACK_TO_CHANNEL_SELECTION, backGate } from '../lib/back-nav.js';
// Drive the first-prompt back gate (back-nav's brightSelect) from a queue // 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); expect(cmds.some((c) => c.startsWith('ncl '))).toBe(false);
}); });
// Teams' platform_id only exists after the first inbound, so its SKILL.md // Teams wires inline only when a fresh create resolved the owner DM
// installs + hands off and runChannelSkill is called with deferWire — it must // (wireIfResolved). This fixture answers the have_creds probe with "yes"
// run the skill but never reach the shared wire. This is the driver-policy // (credentials already in .env), so every creation + resolve step — the
// parity fixture: it runs the DEFAULT onEvent handler (never an injected // teams-login step, teams app create, the env writes, the DM-open chain,
// onEvent, which would replace the policy — §5.0) and injects the // the install-link operator — is when:-skipped, the wire inputs stay
// confirm/openUrl seams to prove both natural barriers fire and the portal // unresolved, and the run drops through to restart without wiring.
// URL offer survives from the operator prose alone. it('wireIfResolved (Teams): existing credentials skip the whole CLI create flow, never reach the shared wire', async () => {
it('deferWire (Teams): default policy fires the gate barriers + portal URL offer, never reaches the shared wire', async () => {
const root = mkdtempSync(join(tmpdir(), 'rcs-teams-')); const root = mkdtempSync(join(tmpdir(), 'rcs-teams-'));
mkdirSync(join(root, 'src/channels'), { recursive: true }); mkdirSync(join(root, 'src/channels'), { recursive: true });
writeFileSync(join(root, 'src/channels/index.ts'), '// barrel\n'); 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"}'); writeFileSync(join(root, 'package.json'), '{"name":"scratch"}');
const log: string[] = []; const log: string[] = [];
const opened: string[] = [];
const wired: unknown[] = []; const wired: unknown[] = [];
await runChannelSkill('teams', 'Acme Corp', { await runChannelSkill('teams', 'Acme Corp', {
projectRoot: root, 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', resolveRemote: () => 'origin',
reuse: false, reuse: false,
deferWire: true, wireIfResolved: true,
// The injectable interaction seams — the default handler consults them for // The injectable interaction seams — the default handler consults them for
// the URL offer and the natural-barrier confirms, so no real clack confirm // 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. // (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}`); log.push(`confirm:${m}`);
return true; return true;
}, },
openUrl: async (u) => void opened.push(u), openUrl: async () => undefined,
// a MultiTenant app, so the SingleTenant-guarded app_tenant_id prompt is skipped // NO inputs: the public_url prompt is when:have_creds=no-guarded, so the
inputs: { // drop-through path must never ask for it. If the guard regressed, the
public_url: 'https://acme.example', // prompt would defer (resolveInput undefined) and fail() would be called.
app_id: '12345678-1234-1234-1234-123456789abc', resolveInput: async () => undefined,
app_type: 'MultiTenant', fail: async (step, msg) => {
app_password: 'a-much-longer-app-password', // 20+ chars — valid for the declared shape throw new Error(`fail() called on drop-through path: ${step}${msg}`);
}, },
wire: (a) => { wire: (a) => {
wired.push(a); wired.push(a);
@@ -116,21 +121,346 @@ describe('runChannelSkill adapter (Option A)', () => {
}, },
}); });
// install + manifest ran // the adapter install ran, but no bot was created and no login step fired
expect(log.some((c) => c.includes('teams-manifest-build'))).toBe(true); expect(log.some((c) => c.includes('pnpm add @chat-adapter/teams'))).toBe(true);
// …the Azure portal offer came from the operator BODY text (policy §5.2)… expect(log.some((c) => c.includes('app create'))).toBe(false);
expect(opened.some((u) => /portal\.azure\.com/.test(u))).toBe(true); expect(log.some((c) => c.includes('app update'))).toBe(false); // icon step is creation-side too
// …a natural-barrier confirm (not a URL offer) fired BEFORE the manifest expect(log.some((c) => c.includes('login'))).toBe(false);
// build (the manifest-before-the-app hazard fix, now derived from document // …no logout either — the drop-through path never signed in, and must not
// structure instead of an authored gate attr) // sign out a session the operator may be using for something else
const firstGate = log.findIndex((c) => c.startsWith('confirm:') && !c.startsWith('confirm:Open ')); expect(log.some((c) => c.includes('logout'))).toBe(false);
const manifestAt = log.findIndex((c) => c.includes('teams-manifest-build')); // …the Teams CLI install is also skipped (nothing to create)…
expect(firstGate).toBeGreaterThanOrEqual(0); expect(log.some((c) => c.includes('npm install -g @microsoft/teams.cli'))).toBe(false);
expect(firstGate).toBeLessThan(manifestAt); // …the service still restarts (adapter + existing credentials load)…
// …but the shared wire was never reached (no owner_handle/platform_id needed) 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); 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 // 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 // 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. // 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 * So the wire lives in exactly one place (init-first-agent) and is never
* duplicated across channel skills. * duplicated across channel skills.
*/ */
import { writeFileSync } from 'node:fs';
import * as p from '@clack/prompts'; import * as p from '@clack/prompts';
import { firstFailureHint, fullyApplied } from '../../scripts/skill-apply.js'; 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 { BACK_TO_CHANNEL_SELECTION, backGate, type ChannelFlowResult } from '../lib/back-nav.js';
import { askOperatorRole, type OperatorRole } from '../lib/role-prompt.js'; import { askOperatorRole, type OperatorRole } from '../lib/role-prompt.js';
import { ensureAnswer, fail, runQuietChild } from '../lib/runner.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. */ /** The shared wire; defaults to init-first-agent. Injectable for tests. */
wire?: (args: WireArgs) => Promise<boolean> | boolean; wire?: (args: WireArgs) => Promise<boolean> | boolean;
/** /**
* Skip the resolve+wire half. For a channel whose platform_id only exists * Wire only when the skill resolved owner_handle + platform_id this run
* after the first inbound (Teams): the SKILL.md installs the adapter and ends * (Teams: the guarded DM-open steps only run on a fresh create). Resolved →
* with an `nc:operator` handoff telling the operator to DM the bot and finish * ask agent name/role and wire like any channel; unresolved (a drop-through
* wiring later. No owner_handle/platform_id is expected, no agent name/role is * re-run — the first run's wiring still stands) → skip the wire and let the
* asked, and init-first-agent is not run. * 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, * 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 — * before any side effect (agent-name/role prompts, the skill run, the
* and the deferWire/Teams path too). On back, returns the * wire). On back, returns the
* `BACK_TO_CHANNEL_SELECTION` sentinel and does nothing else. Opt-in so * `BACK_TO_CHANNEL_SELECTION` sentinel and does nothing else. Opt-in so
* headless callers (and the existing tests) never see the extra prompt. * headless callers (and the existing tests) never see the extra prompt.
*/ */
@@ -103,7 +101,7 @@ export async function runChannelSkill(
overrides: ChannelSkillOverrides = {}, overrides: ChannelSkillOverrides = {},
): Promise<ChannelFlowResult> { ): Promise<ChannelFlowResult> {
// First-prompt back gate — the very first thing, before any side effect // 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. // Opt-in via offerBack so headless callers + existing tests are unaffected.
if (overrides.offerBack) { if (overrides.offerBack) {
const label = channel.charAt(0).toUpperCase() + channel.slice(1); const label = channel.charAt(0).toUpperCase() + channel.slice(1);
@@ -113,9 +111,12 @@ export async function runChannelSkill(
const projectRoot = overrides.projectRoot ?? process.cwd(); const projectRoot = overrides.projectRoot ?? process.cwd();
const failWith = overrides.fail ?? fail; const failWith = overrides.fail ?? fail;
// The agent name + role are wire inputs — skip the prompts when the wire is deferred. // The agent name + role are wire inputs — in wireIfResolved mode, defer the
const agentName = overrides.deferWire ? '' : overrides.agentName ?? (await resolveAgentName()); // prompts past the skill run (only a fresh create resolves the wire inputs;
const role = overrides.deferWire ? undefined : overrides.role ?? (await askOperatorRole(channel)); // 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 // Channel-specific: install adapter, collect credentials, resolve the wire
// inputs. The whole channel-specific procedure lives in the SKILL.md. // inputs. The whole channel-specific procedure lives in the SKILL.md.
@@ -140,21 +141,12 @@ export async function runChannelSkill(
}); });
if (!fullyApplied(res)) { if (!fullyApplied(res)) {
if (res.deferred.length) p.log.warn(`Still needs: ${res.deferred.join(', ')}`); 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 for (const t of res.agentTasks) p.log.warn(`Needs an agent (${t.kind}): ${t.reason}`);
// terminal gets ONE line per bounce — the first line, which hostExec // On a real bounce, also surface the skill's reference floor (Alternatives /
// composes as `exit <code>: <first stderr line>` — and the full text goes // Optional configuration / Troubleshooting) — the same prose a human reader
// to a raw step log, written only when there's actually more than one line // would scroll to when a step doesn't apply cleanly. Only on a bounce
// to keep (SSF-004; the reference prose is deliberately not dumped either). // (agentTasks), and only when the skill actually ships a reference section.
let rawLog: string | undefined; if (res.agentTasks.length && res.referenceProse) p.log.info(res.referenceProse);
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}`);
}
// Surface the bounced step's OWN prose as the failure hint + Claude-handoff // Surface the bounced step's OWN prose as the failure hint + Claude-handoff
// context (fail() dims the hint and forwards it to offerClaudeOnFailure), // context (fail() dims the hint and forwards it to offerClaudeOnFailure),
// instead of a generic "couldn't finish" message. Only a real bounce yields a // instead of a generic "couldn't finish" message. Only a real bounce yields a
@@ -164,18 +156,20 @@ export async function runChannelSkill(
`${channel}-install`, `${channel}-install`,
diag?.headline ?? `Couldn't finish setting up ${channel}.`, diag?.headline ?? `Couldn't finish setting up ${channel}.`,
diag?.hint ?? 'See logs/setup-steps/ for details, then retry setup.', 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). // 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}.`); 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 ownerHandle = res.vars.owner_handle;
const platformId = res.vars.platform_id; 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) { if (!ownerHandle || !platformId) {
await failWith( await failWith(
`${channel}-resolve`, `${channel}-resolve`,
@@ -183,9 +177,13 @@ export async function runChannelSkill(
'The skill did not produce owner_handle + platform_id.', '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: // 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 wire = overrides.wire ?? initFirstAgent;
const ok = await wire({ channel, userId: `${channel}:${ownerHandle}`, platformId, displayName, agentName, role: role! }); const ok = await wire({ channel, userId: `${channel}:${ownerHandle}`, platformId, displayName, agentName, role: role! });
if (!ok) { if (!ok) {
+9 -4
View File
@@ -5,14 +5,17 @@
* external image deps) already lives in `setup/lib/teams-manifest.ts`; this just * 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. * 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 * 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/. * 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: * Usage:
* pnpm exec tsx setup/channels/teams-manifest-build.ts \ * 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'; import { buildTeamsAppPackage } from '../lib/teams-manifest.js';
@@ -24,11 +27,12 @@ function flag(name: string): string | undefined {
const appId = flag('app-id'); const appId = flag('app-id');
const url = flag('url'); const url = flag('url');
const outDir = flag('out') ?? 'data/teams'; const outDir = flag('out') ?? 'data/teams';
const rsc = process.argv.includes('--rsc');
const shortName = process.env.NANOCLAW_AGENT_NAME?.trim() || 'NanoClaw'; const shortName = process.env.NANOCLAW_AGENT_NAME?.trim() || 'NanoClaw';
if (!appId || !url) { if (!appId || !url) {
console.error( 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); process.exit(2);
} }
@@ -39,6 +43,7 @@ const result = buildTeamsAppPackage({
longDescription: `${shortName} personal assistant powered by NanoClaw.`, longDescription: `${shortName} personal assistant powered by NanoClaw.`,
websiteUrl: url, websiteUrl: url,
outDir, outDir,
rsc,
}); });
console.log(`Teams app package: ${result.zipPath}`); 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'], 'slack-validate': ['setup/channels/slack.ts'],
'imessage-install': ['.claude/skills/add-imessage/SKILL.md', 'scripts/skill-apply.ts', 'setup/channels/imessage.ts'], 'imessage-install': ['.claude/skills/add-imessage/SKILL.md', 'scripts/skill-apply.ts', 'setup/channels/imessage.ts'],
'imessage': ['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-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.ts'], 'teams-manifest': ['setup/lib/teams-manifest.ts', 'setup/channels/teams-manifest-build.ts'],
'init-first-agent': [ 'init-first-agent': [
'scripts/init-first-agent.ts', 'scripts/init-first-agent.ts',
'setup/channels/telegram.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 { tmpdir } from 'node:os';
import { join } from 'node:path'; 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'; import { fullyApplied, type ApplyEvent } from '../../scripts/skill-apply.js';
// Shared test state for the clack + claude-handoff mocks (hoisted so the vi.mock // 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), handoffSpy: vi.fn(async (_ctx: { channel: string; step: string; stepDescription: string }) => true),
answers: [] as string[], answers: [] as string[],
lastValidate: { fn: undefined as undefined | ((v: string) => string | Error | void | undefined) }, lastValidate: { fn: undefined as undefined | ((v: string) => string | Error | void | undefined) },
lastSelectOptions: { values: undefined as undefined | string[] },
})); }));
// Keep isHelpEscape + validateWithHelpEscape real (clackResolveInput uses them); // Keep isHelpEscape + validateWithHelpEscape real (clackResolveInput uses them);
@@ -33,11 +32,7 @@ vi.mock('@clack/prompts', async (importActual) => {
ce.lastValidate.fn = o?.validate; ce.lastValidate.fn = o?.validate;
return ce.answers.shift() ?? ''; return ce.answers.shift() ?? '';
}; };
const fromQueueSelect = async (o: { options: Array<{ value: string }> }): Promise<string> => { return { ...actual, text: vi.fn(fromQueue), password: vi.fn(fromQueue) };
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) };
}); });
// A small SKILL.md exercising the three things the driver wires: an operator // 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' }]); 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-')); const root = mkdtempSync(join(tmpdir(), 'driver-bin-'));
mkdirSync(join(root, 'bin')); mkdirSync(join(root, 'bin'));
writeFileSync(join(root, 'bin/greet'), '#!/usr/bin/env bash\necho hi-from-bin\n'); writeFileSync(join(root, 'bin/greet'), '#!/usr/bin/env bash\necho hi-from-bin\n');
chmodSync(join(root, 'bin/greet'), 0o755); 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'); 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-')); const root = mkdtempSync(join(tmpdir(), 'driver-cap-'));
expect(String(await hostExec(root)('echo D0CHANNEL')).trim()).toBe('D0CHANNEL'); expect(String(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
}); });
it('hostExecStream runs a step and captures the terminal status block fields (for effect:step)', async () => { 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'); 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 } { function reuseScratch(): { root: string; skill: string } {
const root = mkdtempSync(join(tmpdir(), 'reuse-')); const root = mkdtempSync(join(tmpdir(), 'reuse-'));
const skill = mkdtempSync(join(tmpdir(), 'reuse-skill-')); 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 () => { it('clackResolveInput passes a normal answer straight through — no handoff', async () => {
ce.handoffSpy.mockClear(); ce.handoffSpy.mockClear();
ce.answers = ['just-a-token']; 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 // 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 // "Build and validate") — the ordinal suffix marks them as a sequence, not a
// stuttered duplicate. Solo captions stay unsuffixed. // stuttered duplicate. Solo captions stay unsuffixed.
+18 -91
View File
@@ -13,8 +13,8 @@
* the URL offer, the prose-derived validation message. * the URL offer, the prose-derived validation message.
*/ */
import { execSync, spawn } from 'node:child_process'; import { execSync, spawn } from 'node:child_process';
import { appendFileSync, readFileSync, writeFileSync } from 'node:fs'; import { readFileSync } from 'node:fs';
import { basename, join } from 'node:path'; import { join } from 'node:path';
import * as p from '@clack/prompts'; import * as p from '@clack/prompts';
@@ -30,7 +30,6 @@ import {
} from '../../scripts/skill-apply.js'; } from '../../scripts/skill-apply.js';
import { parseDirectives, promptVar } from '../../scripts/skill-directives.js'; import { parseDirectives, promptVar } from '../../scripts/skill-directives.js';
import { extractOfferUrl, gatePolicy } from '../../scripts/skill-policy.js'; import { extractOfferUrl, gatePolicy } from '../../scripts/skill-policy.js';
import * as setupLog from '../logs.js';
import { isHeadless } from '../platform.js'; import { isHeadless } from '../platform.js';
import { openUrl } from './browser.js'; import { openUrl } from './browser.js';
import { isHelpEscape, offerClaudeHandoff, validateWithHelpEscape } from './claude-handoff.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}`); 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 * 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 * 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 * The wizard's `resolveInput` implementation: collect an `nc:prompt` through
* clack (password for secrets, an arrow-key select for an either/or validate * clack (password for secrets, text otherwise; a cancel defers), running the
* regex, text otherwise; a cancel defers), running the interactive re-ask loop * interactive re-ask loop against the prompt's declared `validate:`/`flags:`
* against the prompt's declared `validate:`/`flags:` (the engine's * (the engine's validate-at-bind is the programmatic backstop, not the UX).
* validate-at-bind is the programmatic backstop, not the UX).
*/ */
export function clackResolveInput(ctx: PrompterContext = {}): (name: string, meta: InputMeta) => Promise<string | undefined> { 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 // 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); const guarded = validateWithHelpEscape(check);
// clearOnError wipes a rejected secret so the operator re-pastes cleanly // clearOnError wipes a rejected secret so the operator re-pastes cleanly
// (a half-pasted token isn't left masked in the field). // (a half-pasted token isn't left masked in the field).
// An either/or prompt renders as an arrow-key select — the options come const ans = meta.secret
// straight from the validate regex (literalChoices). No re-ask loop and no ? await p.password({ message: meta.question, validate: guarded, clearOnError: true })
// `?` help-escape there: every choice is valid and self-describing. : await p.text({ message: meta.question, validate: guarded });
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 });
if (p.isCancel(ans)) return undefined; // cancelled ⇒ defer if (p.isCancel(ans)) return undefined; // cancelled ⇒ defer
if (isHelpEscape(ans) && process.stdout.isTTY) { if (isHelpEscape(ans) && process.stdout.isTTY) {
// Operator asked for help: hand off to interactive Claude with this // 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 * `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 * `ncl …` in a wire directive resolves to `bin/ncl` even when it isn't
* symlinked onto the operator's PATH. * 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> { export function hostExec(projectRoot: string): (cmd: string) => 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`);
};
return (cmd) => return (cmd) =>
new Promise((resolve, reject) => { execSync(cmd, {
const child = spawn('bash', ['-c', cmd], { cwd: projectRoot,
cwd: projectRoot, shell: '/bin/bash',
env: { ...process.env, PATH: `${join(projectRoot, 'bin')}:${process.env.PATH ?? ''}` }, encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, PATH: `${join(projectRoot, 'bin')}:${process.env.PATH ?? ''}` },
});
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}` : ''}`));
});
}); });
} }
@@ -300,20 +248,7 @@ export function hostExecStream(projectRoot: string): (cmd: string) => Promise<St
new Promise((resolve) => { new Promise((resolve) => {
const child = spawn('bash', ['-c', cmd], { const child = spawn('bash', ['-c', cmd], {
cwd: projectRoot, cwd: projectRoot,
env: { env: { ...process.env, PATH: `${join(projectRoot, 'bin')}:${process.env.PATH ?? ''}` },
...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' } : {}),
},
stdio: ['inherit', 'pipe', 'pipe'], stdio: ['inherit', 'pipe', 'pipe'],
}); });
const blocks: Array<{ fields: Record<string, string> }> = []; const blocks: Array<{ fields: Record<string, string> }> = [];
@@ -416,7 +351,7 @@ function defaultOnEvent(
return; return;
} }
// operator: note → URL offer → natural-barrier confirm. // operator: note → URL offer → natural-barrier confirm.
p.note(e.text, 'Your turn'); p.note(e.text, 'Do this');
const url = extractOfferUrl(e.text); const url = extractOfferUrl(e.text);
if (url !== undefined && (await confirm(`Open ${url} in your browser?`))) await open(url); if (url !== undefined && (await confirm(`Open ${url} in your browser?`))) await open(url);
const gate = gates.get(e.line); const gate = gates.get(e.line);
@@ -447,7 +382,7 @@ export interface RunSkillOptions {
*/ */
resolveInput?: (name: string, meta: InputMeta) => Promise<string | undefined>; resolveInput?: (name: string, meta: InputMeta) => Promise<string | undefined>;
/** Defaults to `hostExec`. */ /** 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`. */ /** Defaults to `hostExecStream`. Streaming exec for `nc:run effect:step`. */
execStream?: (cmd: string) => Promise<StepOutcome>; execStream?: (cmd: string) => Promise<StepOutcome>;
/** Defaults to the fork-aware channels-branch resolver. */ /** Defaults to the fork-aware channels-branch resolver. */
@@ -510,19 +445,11 @@ export async function runSkill(skillDir: string, opts: RunSkillOptions = {}): Pr
} catch { } catch {
// missing SKILL.md — the engine will produce an empty result anyway // 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, { return applySkill(skillDir, projectRoot, {
inputs, inputs,
resolveInput: opts.resolveInput ?? clackResolveInput({ channel: opts.channel, step: opts.step }), resolveInput: opts.resolveInput ?? clackResolveInput({ channel: opts.channel, step: opts.step }),
onEvent: opts.onEvent ?? defaultOnEvent(md, confirm, open), onEvent: opts.onEvent ?? defaultOnEvent(md, confirm, open),
exec: opts.exec ?? hostExec(projectRoot, rawLog), exec: opts.exec ?? hostExec(projectRoot),
execStream: opts.execStream ?? hostExecStream(projectRoot), execStream: opts.execStream ?? hostExecStream(projectRoot),
resolveRemote: opts.resolveRemote ?? channelsRemote(projectRoot), resolveRemote: opts.resolveRemote ?? channelsRemote(projectRoot),
skipEffects: opts.skipEffects, 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 * - outline.png 32×32 transparent outline icon
* - color.png 192×192 full-color icon * - color.png 192×192 full-color icon
* *
* Icons are generated in-process using a minimal PNG encoder so we don't * The static parts live in setup/assets/teams/ manifest.template.json
* need ImageMagick or vendor binary icon blobs into the repo. The outline * (pinned to schema v1.16 to match the skill doc) plus the two icons so
* icon is a simple rounded square outline; the color icon is a brand-blue * the manifest is reviewable as plain JSON. This module only fills in the
* filled square with a small white "N" blocked in by pixel setting. Good * per-install fields (app id, name, domain, optional RSC block) and zips the
* enough for a working sideload teams admins who care can replace the * three files in-process via ./zip.ts, so no `zip` binary is needed on the
* icons later. * host.
*
* The manifest is pinned to schema v1.16 to match the skill doc.
*/ */
import { execSync } from 'child_process';
import fs from 'fs'; import fs from 'fs';
import path from 'path'; import path from 'path';
import zlib from 'zlib';
const MANIFEST_SCHEMA = import { buildZip } from './zip.js';
'https://developer.microsoft.com/en-us/json-schemas/teams/v1.16/MicrosoftTeams.schema.json';
const MANIFEST_VERSION = '1.16'; const ASSETS_DIR = new URL('../assets/teams/', import.meta.url);
export interface ManifestOptions { export interface ManifestOptions {
/** The Azure AD app ID (same value used for `bots[0].botId`). */ /** The Azure AD app ID (same value used for `bots[0].botId`). */
@@ -36,6 +32,11 @@ export interface ManifestOptions {
websiteUrl: string; websiteUrl: string;
/** Out-dir for the generated zip + loose files. */ /** Out-dir for the generated zip + loose files. */
outDir: string; 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 { export interface ManifestResult {
@@ -45,6 +46,19 @@ export interface ManifestResult {
colorPath: string; 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. */ /** Build the full app package zip and return the paths. */
export function buildTeamsAppPackage(opts: ManifestOptions): ManifestResult { export function buildTeamsAppPackage(opts: ManifestOptions): ManifestResult {
fs.mkdirSync(opts.outDir, { recursive: true }); 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 colorPath = path.join(opts.outDir, 'color.png');
const zipPath = path.join(opts.outDir, 'teams-app-package.zip'); const zipPath = path.join(opts.outDir, 'teams-app-package.zip');
fs.writeFileSync(manifestPath, renderManifest(opts)); const manifest = Buffer.from(renderManifest(opts));
fs.writeFileSync(outlinePath, encodeOutlineIcon()); const outline = fs.readFileSync(new URL('outline.png', ASSETS_DIR));
fs.writeFileSync(colorPath, encodeColorIcon()); const color = fs.readFileSync(new URL('color.png', ASSETS_DIR));
// Fresh zip every run — idempotent, no stale files. fs.writeFileSync(manifestPath, manifest);
try { fs.writeFileSync(outlinePath, outline);
fs.unlinkSync(zipPath); fs.writeFileSync(colorPath, color);
} catch { fs.writeFileSync(
// noop if missing zipPath,
} buildZip([
execSync(`zip -j -q "${zipPath}" "${manifestPath}" "${outlinePath}" "${colorPath}"`, { { name: 'manifest.json', data: manifest },
stdio: ['ignore', 'ignore', 'inherit'], { name: 'outline.png', data: outline },
}); { name: 'color.png', data: color },
]),
);
return { zipPath, manifestPath, outlinePath, colorPath }; return { zipPath, manifestPath, outlinePath, colorPath };
} }
function renderManifest(opts: ManifestOptions): string { function renderManifest(opts: ManifestOptions): string {
const manifest = { const manifest = JSON.parse(
$schema: MANIFEST_SCHEMA, fs.readFileSync(new URL('manifest.template.json', ASSETS_DIR), 'utf8'),
manifestVersion: MANIFEST_VERSION, ) as ManifestTemplate;
version: '1.0.0',
id: opts.appId, manifest.id = opts.appId;
packageName: 'com.nanoclaw.bot', manifest.bots[0].botId = opts.appId;
developer: { manifest.name.short = opts.shortName.slice(0, 30);
name: 'NanoClaw', manifest.name.full = `${opts.shortName} Assistant`;
websiteUrl: opts.websiteUrl, manifest.description.full = opts.longDescription;
privacyUrl: opts.websiteUrl, manifest.developer.websiteUrl = opts.websiteUrl;
termsOfUseUrl: opts.websiteUrl, manifest.developer.privacyUrl = opts.websiteUrl;
}, manifest.developer.termsOfUseUrl = opts.websiteUrl;
name: { manifest.validDomains = [new URL(opts.websiteUrl).host];
short: opts.shortName.slice(0, 30),
full: `${opts.shortName} Assistant`, if (opts.rsc) {
}, // Teams app-update flows want a higher version than the already-uploaded
description: { // package, so the RSC variant (typically a re-upload) bumps it.
short: 'Your personal assistant in Teams.', manifest.version = '1.1.0';
full: opts.longDescription, // RSC grants bind to webApplicationInfo.id, not bots[].botId — without
}, // this block the permissions are never attached to the app and the bot
icons: { outline: 'outline.png', color: 'color.png' }, // silently keeps requiring @-mention. `resource` must be non-empty but
accentColor: '#4A90D9', // its value is unused for RSC-only apps.
bots: [ manifest.webApplicationInfo = { id: opts.appId, resource: 'https://notapplicable' };
{ manifest.authorization = {
botId: opts.appId, permissions: {
scopes: ['personal', 'team', 'groupchat'], resourceSpecific: [
supportsFiles: false, { name: 'ChannelMessage.Read.Group', type: 'Application' },
isNotificationOnly: false, { name: 'ChatMessage.Read.Chat', type: 'Application' },
],
}, },
], };
permissions: ['identity', 'messageTeamMembers'], }
validDomains: [new URL(opts.websiteUrl).host],
};
return JSON.stringify(manifest, null, 2) + '\n'; 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 * 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. * operator to send the code from the chat they want to register.
* *
* Renders the human-facing code card itself (see printCodeCard) and emits * Emits machine-readable status blocks only. The parent driver
* machine-readable status blocks alongside for the programmatic callers * (`setup:auto`) renders the code / attempt / success UI with clack. Running
* (/manage-channels, /init-first-agent) that parse them. * this step directly will look sparse that's intentional.
* *
* Blocks emitted: * Blocks emitted:
* PAIR_TELEGRAM_CODE { CODE, REASON=initial|regenerated } * PAIR_TELEGRAM_CODE { CODE, REASON=initial|regenerated }
@@ -20,8 +20,6 @@
*/ */
import path from 'path'; import path from 'path';
import * as p from '@clack/prompts';
import { import {
createPairing, createPairing,
waitForPairing, 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 * The Option A driver's streaming exec (setup/lib/skill-driver.ts
* `hostExecStream`) CONSUMES the `=== NANOCLAW SETUP: … ===` status blocks (it * `hostExecStream`) CONSUMES the `=== NANOCLAW SETUP: … ===` status blocks (it
* does not show them) and tees every OTHER stdout line verbatim to the * does not show them) and tees every OTHER stdout line straight to the
* operator's terminal. Static clack output is just lines, so it survives that * operator's terminal. So the human-facing code card has to be printed as plain
* tee and reads like the rest of the wizard only INTERACTIVE/animated clack * lines here the bespoke setup/channels/telegram.ts used to render these from
* widgets need the real TTY the piped child doesn't have (SSF-002). * 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 { function printCodeCard(code: string, reason: 'initial' | 'regenerated'): void {
const spaced = code.split('').join(' '); const spaced = code.split('').join(' ');
p.note( console.log('');
`${spaced}\n\nSend these 4 digits to your bot from Telegram.`, console.log(
reason === 'initial' ? 'Your pairing code is ready' : 'That code was used up — here is a fresh one', 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 { 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> { 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', { emitStatus('PAIR_TELEGRAM', {
STATUS: 'success', STATUS: 'success',
CODE: record.code, CODE: record.code,
+31
View File
@@ -22,6 +22,37 @@ describe('determineVerifyStatus', () => {
).toBe('failed'); ).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', () => { it('fails when the service is not running', () => {
expect( expect(
determineVerifyStatus({ determineVerifyStatus({
+26 -2
View File
@@ -217,15 +217,27 @@ export async function run(_args: string[]): Promise<void> {
mountAllowlist = 'configured'; 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 // Determine overall status. The cli-agent step earlier in setup already
// proved the agent round-trip works; verify is a static health check. // proved the agent round-trip works; verify is a static health check.
const status = determineVerifyStatus({ const status = determineVerifyStatus({
service, service,
credentials, credentials,
registeredGroups, registeredGroups,
wiringPending,
}); });
log.info('Verification complete', { status, channelAuth }); log.info('Verification complete', { status, channelAuth, wiringPending });
emitStatus('VERIFY', { emitStatus('VERIFY', {
SERVICE: service, SERVICE: service,
@@ -235,6 +247,7 @@ export async function run(_args: string[]): Promise<void> {
CHANNEL_AUTH: JSON.stringify(channelAuth), CHANNEL_AUTH: JSON.stringify(channelAuth),
REGISTERED_GROUPS: registeredGroups, REGISTERED_GROUPS: registeredGroups,
MOUNT_ALLOWLIST: mountAllowlist, MOUNT_ALLOWLIST: mountAllowlist,
...(wiringPending ? { WIRING: 'pending_first_dm' } : {}),
STATUS: status, STATUS: status,
LOG: 'logs/setup.log', LOG: 'logs/setup.log',
}); });
@@ -242,14 +255,25 @@ export async function run(_args: string[]): Promise<void> {
if (status === 'failed') process.exit(1); 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: { export function determineVerifyStatus(input: {
service: 'not_found' | 'stopped' | 'running' | 'running_other_checkout'; service: 'not_found' | 'stopped' | 'running' | 'running_other_checkout';
credentials: string; credentials: string;
registeredGroups: number; registeredGroups: number;
/** Zero groups but every configured channel defers wiring to the first DM. */
wiringPending?: boolean;
}): 'success' | 'failed' { }): 'success' | 'failed' {
return input.service === 'running' && return input.service === 'running' &&
input.credentials !== 'missing' && input.credentials !== 'missing' &&
input.registeredGroups > 0 (input.registeredGroups > 0 || input.wiringPending === true)
? 'success' ? 'success'
: 'failed'; : 'failed';
} }