mirror of
https://github.com/qwibitai/nanoclaw.git
synced 2026-07-09 18:57:08 +08:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cf3ca4dc36 | |||
| ee1d2773c2 | |||
| 405643f8a7 | |||
| 1a3c3eaf9b | |||
| 77a19a0c97 |
@@ -83,7 +83,8 @@ Create the Slack app (Socket Mode):
|
||||
3. App Home → enable the Messages Tab, and check "Allow users to send Slash commands and messages from the messages tab."
|
||||
4. Basic Information → App-Level Tokens → "Generate Token and Scopes" → add the connections:write scope → copy the token (starts with xapp-).
|
||||
5. Socket Mode → toggle "Enable Socket Mode" on.
|
||||
6. Install to Workspace, then copy the Bot User OAuth Token (starts with xoxb-).
|
||||
6. Event Subscriptions → toggle "Enable Events" on, then under "Subscribe to bot events" add: message.channels, message.groups, message.im, app_mention. Save Changes. (No Request URL is needed in Socket Mode.)
|
||||
7. Install to Workspace, then copy the Bot User OAuth Token (starts with xoxb-).
|
||||
```
|
||||
|
||||
For webhook delivery, tell the user:
|
||||
@@ -122,8 +123,9 @@ SLACK_SIGNING_SECRET={{signing_secret}}
|
||||
|
||||
With webhook delivery, the bridge serves port 3000 at `/webhook/slack`
|
||||
automatically; to receive replies, that port must be reachable from the internet
|
||||
and registered with Slack (Socket Mode skips all of this — events arrive over
|
||||
the socket as soon as the service restarts). Tell the user:
|
||||
and registered with Slack as the Request URL (Socket Mode needs no public URL —
|
||||
with the bot events subscribed above, events arrive over the socket as soon as
|
||||
the service restarts). Tell the user:
|
||||
|
||||
```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):
|
||||
|
||||
@@ -18,38 +18,19 @@ rm -f src/channels/teams.ts src/channels/teams-registration.test.ts
|
||||
|
||||
## 2. Remove credentials
|
||||
|
||||
Remove `TEAMS_APP_ID`, `TEAMS_APP_PASSWORD`, `TEAMS_APP_TENANT_ID`, and `TEAMS_APP_TYPE` from `.env`.
|
||||
|
||||
## 3. Sign out the Teams CLI, then remove the packages
|
||||
|
||||
`teams login` caches a Microsoft 365 session on disk that outlives the package —
|
||||
sign out first (skip if the CLI was never installed):
|
||||
Remove `TEAMS_APP_ID`, `TEAMS_APP_PASSWORD`, `TEAMS_APP_TENANT_ID`, and `TEAMS_APP_TYPE` from `.env`, then re-sync to the container:
|
||||
|
||||
```bash
|
||||
mkdir -p data/env && cp .env data/env/env
|
||||
```
|
||||
|
||||
## 3. Remove the package
|
||||
|
||||
```bash
|
||||
teams logout
|
||||
npm uninstall -g @microsoft/teams.cli
|
||||
pnpm uninstall @chat-adapter/teams
|
||||
```
|
||||
|
||||
## 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
|
||||
## 4. Rebuild and restart
|
||||
|
||||
```bash
|
||||
pnpm run build
|
||||
|
||||
+165
-402
@@ -14,14 +14,11 @@ reads the prose and applies them, and a parser can apply them deterministically
|
||||
from the same document. Every directive is idempotent, so the whole skill is
|
||||
safe to re-run; anything a parser can't apply falls back to the prose beside it.
|
||||
|
||||
Teams has no "paste a token" shortcut — a bot has to exist in Microsoft's cloud
|
||||
before it can receive a message. The Microsoft Teams CLI collapses that into
|
||||
one sign-in and one create command: it registers the Entra app, generates the
|
||||
client secret, registers a Teams-managed bot (through the Teams Developer
|
||||
Portal — **no Azure subscription needed**), uploads the app package, and hands
|
||||
back an install link. The old ~7-step Azure portal walk survives only as a
|
||||
fallback in [Alternatives](#alternatives) for tenants where the Developer
|
||||
Portal is blocked.
|
||||
Teams is the most involved channel NanoClaw supports — there's no "paste a
|
||||
token" shortcut. You'll walk through about seven Azure portal steps (app
|
||||
registration, client secret, Azure Bot resource, messaging endpoint, Teams
|
||||
channel, app package, sideload). Take them one at a time; the prompts below
|
||||
collect each value as you produce it.
|
||||
|
||||
## Apply
|
||||
|
||||
@@ -74,268 +71,139 @@ runs.
|
||||
## Credentials
|
||||
|
||||
The adapter is installed and registered, but it can't receive a message until a
|
||||
bot exists, points at this machine, and is installed into Teams. The Teams CLI
|
||||
does all of that below.
|
||||
bot exists in Azure, points at this machine, and is sideloaded into Teams. None
|
||||
of those steps can be clicked through by a parser, so they're operator
|
||||
instructions — relay each one, then collect the value it produces.
|
||||
|
||||
### Check for existing credentials
|
||||
Before you start, tell the user:
|
||||
|
||||
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
|
||||
```nc:operator
|
||||
Confirm you have everything Teams setup needs:
|
||||
1. A Microsoft 365 account that can create Entra app registrations and upload custom apps (sideloading) — free personal Teams does NOT qualify; you need a Microsoft 365 Business / EDU / developer tenant.
|
||||
2. A way to expose an HTTPS endpoint that forwards to this machine's webhook port 3000 (e.g. a Cloudflare Tunnel, or a reverse-proxied VPS). Start it now if it isn't running — e.g. `cloudflared tunnel --url http://localhost:3000` — the create step needs the URL up front. The next prompt asks for its public base URL: just the https:// origin, no trailing path.
|
||||
Note: the bot is created single-tenant (only your own Microsoft 365 tenant can install it) — the right default for a self-hosted assistant. If you need a bot other tenants can install, set it up manually via the Alternatives section of this skill instead.
|
||||
1. A Microsoft 365 tenant where you can sideload custom apps — free personal Teams does NOT support this; you need a Microsoft 365 Business / EDU / developer tenant with Teams admin or developer rights.
|
||||
2. A way to expose an HTTPS endpoint from this machine (ngrok, a Cloudflare Tunnel, or a reverse-proxied VPS). Azure Bot Service delivers activities to it.
|
||||
```
|
||||
|
||||
### Public URL
|
||||
|
||||
Microsoft delivers bot messages to an HTTPS endpoint you control; it has to
|
||||
reach this machine's webhook server (port 3000, configurable via
|
||||
`WEBHOOK_PORT`) at `/webhook/teams`.
|
||||
Azure Bot Service delivers messages to an HTTPS endpoint you control; it has to
|
||||
reach this machine's webhook server (port 3000) at `/api/webhooks/teams`. If you
|
||||
don't have a tunnel running yet, start one in another terminal first — e.g.
|
||||
`ngrok http 3000` gives you `https://abcd1234.ngrok.io`.
|
||||
|
||||
```nc:prompt public_url when:have_creds=no validate:^https:// normalize:rstrip-slash
|
||||
Paste your tunnel's public https:// URL — e.g. https://your-tunnel.trycloudflare.com
|
||||
```nc:prompt public_url validate:^https:// normalize:rstrip-slash
|
||||
Paste your public base URL (https://…, no trailing path) — e.g. https://abcd1234.ngrok.io.
|
||||
```
|
||||
|
||||
### App name
|
||||
### Register the Azure app
|
||||
|
||||
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).
|
||||
Tell the user:
|
||||
|
||||
```nc:prompt app_name when:have_creds=no validate:^[\sA-Za-z0-9._-]{1,30}$ normalize:trim
|
||||
What should the bot be called? One name covers the Entra app registration, the bot, and the Teams app (letters, digits, spaces, . _ -; max 30 characters) — e.g. NanoClaw.
|
||||
```nc:operator
|
||||
Create the Azure AD app registration:
|
||||
1. In https://portal.azure.com, search "App registrations" → "New registration".
|
||||
2. Name it (e.g. "NanoClaw").
|
||||
3. Supported account types: Single tenant (your org only — most common for self-host) OR Multi tenant (any Microsoft 365 tenant can add the bot).
|
||||
4. Click Register.
|
||||
5. On the Overview page, copy the Application (client) ID and, for a single-tenant app, the Directory (tenant) ID.
|
||||
```
|
||||
|
||||
### Install the Teams CLI
|
||||
|
||||
Installed globally with npm — not as a workspace dependency — deliberately:
|
||||
the CLI's credential store (keytar) is a native module whose install script
|
||||
must run to fetch its prebuilt binary, and pnpm's supply-chain policy blocks
|
||||
dependency build scripts — a workspace install leaves the sign-in unable to
|
||||
persist. The global install matches Microsoft's own instruction and keeps the
|
||||
workspace policy intact. Pinned; re-running is a no-op. (If npm reports
|
||||
EACCES here, your global prefix needs root — prefer a user-level Node like
|
||||
nvm, or `npm config set prefix ~/.npm-global`.) `--loglevel=error` because
|
||||
npm runs inside a pnpm script here and warns about every pnpm config var it
|
||||
inherits — pure noise; real errors still print.
|
||||
|
||||
```nc:run effect:external when:have_creds=no
|
||||
npm install -g @microsoft/teams.cli@3.0.2 --loglevel=error
|
||||
```nc:prompt app_id validate:^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$
|
||||
Paste the Application (client) ID — App registration Overview page.
|
||||
```
|
||||
```nc:prompt app_type validate:^(SingleTenant|MultiTenant)$
|
||||
Enter the app type — `SingleTenant` or `MultiTenant` (must match the account type you picked).
|
||||
```
|
||||
```nc:prompt app_tenant_id when:app_type=SingleTenant validate:^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$
|
||||
Paste the Directory (tenant) ID — App registration Overview page (Single Tenant only).
|
||||
```
|
||||
|
||||
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.
|
||||
### Create a client secret
|
||||
|
||||
### Sign in to Microsoft 365
|
||||
Tell the user:
|
||||
|
||||
Every `teams` command is a separate process, so the sign-in must survive into
|
||||
the next one via the CLI's on-disk token cache. A "libsecret not found —
|
||||
token cache will be stored unencrypted" warning here is safe to ignore: the
|
||||
CLI falls back to a plaintext cache file that persists fine, and setup signs
|
||||
the session out at the end anyway. The login output may
|
||||
also report "Azure CLI: not installed" — informational only; this flow
|
||||
creates a Teams-managed bot precisely so the Azure CLI is never needed (it
|
||||
only matters for `--azure` bots and the manual portal path). The
|
||||
step below verifies persistence by re-reading the session from a fresh
|
||||
process after login. In an interactive terminal the login opens a browser;
|
||||
on a headless box (SSH) it prints a device code — open
|
||||
microsoft.com/devicelogin on any machine and enter it. If this step fails,
|
||||
run `teams login` then `teams status` by hand: status must say logged in, or
|
||||
the cache is not persisting (see Troubleshooting).
|
||||
|
||||
```nc:run effect:step when:have_creds=no
|
||||
"$(npm prefix -g 2>/dev/null)/bin/teams" login && "$(npm prefix -g 2>/dev/null)/bin/teams" status --json 2>/dev/null | grep -q '"loggedIn": true' && printf '=== NANOCLAW SETUP: TEAMS-LOGIN ===\nSTATUS: success\n=== END ===\n'
|
||||
```nc:operator
|
||||
Create the client secret:
|
||||
1. In your app registration, open "Certificates & secrets".
|
||||
2. Click "New client secret" — Description "nanoclaw", Expires 180 days (recommended) or longer.
|
||||
3. Click Add.
|
||||
4. COPY THE VALUE NOW — Azure only shows it once (the Value column, not the Secret ID).
|
||||
```
|
||||
|
||||
### Create the bot
|
||||
|
||||
One command registers the Entra app, generates a client secret (Graph can take
|
||||
~30s to see the new app — the CLI retries), registers a Teams-managed bot, and
|
||||
uploads the app package to the Teams Developer Portal. It needs the sign-in
|
||||
from the previous step (`AUTH_REQUIRED` means run that first). The bot is
|
||||
always created single-tenant (`--sign-in-audience myOrg`) — the right default
|
||||
for a self-hosted assistant, applied without asking; for a bot other
|
||||
Microsoft 365 tenants can install, set it up manually per
|
||||
[Alternatives](#alternatives).
|
||||
|
||||
```nc:run effect:external when:have_creds=no capture:app_id=.credentials.CLIENT_ID,app_password=.credentials.CLIENT_SECRET,app_tenant_id=.credentials.TENANT_ID,teams_app_id=.teamsAppId,install_link=.installLink validate:^.+$
|
||||
"$(npm prefix -g 2>/dev/null)/bin/teams" app create --name "{{app_name}}" --endpoint "{{public_url}}/webhook/teams" --sign-in-audience myOrg --json
|
||||
```nc:prompt app_password secret validate:^.{20,}$
|
||||
Paste the client secret Value — Certificates & secrets (shown only once, at least 20 characters).
|
||||
```
|
||||
|
||||
### Store the credentials
|
||||
|
||||
The adapter reads these from `.env` (set-if-absent — a value you've already
|
||||
filled in is never overwritten). The pairing matters: `SingleTenant` requires
|
||||
`TEAMS_APP_TENANT_ID`, and a multi-tenant app must instead set
|
||||
`TEAMS_APP_TYPE=MultiTenant` with **no** tenant ID — a mismatch makes the
|
||||
adapter authenticate against the wrong authority and every message fails with
|
||||
a 401 from Bot Framework.
|
||||
The adapter reads these from `.env` (set-if-absent, so a value you've already
|
||||
filled in is never overwritten).
|
||||
`TEAMS_APP_TENANT_ID` is written only for a Single Tenant app; Multi Tenant
|
||||
doesn't need it.
|
||||
|
||||
```nc:env-set when:have_creds=no
|
||||
```nc:env-set
|
||||
TEAMS_APP_ID={{app_id}}
|
||||
TEAMS_APP_PASSWORD={{app_password}}
|
||||
TEAMS_APP_TYPE={{app_type}}
|
||||
```
|
||||
```nc:env-set when:app_type=SingleTenant
|
||||
TEAMS_APP_TENANT_ID={{app_tenant_id}}
|
||||
TEAMS_APP_TYPE=SingleTenant
|
||||
```
|
||||
### Create the Azure Bot resource
|
||||
|
||||
### Set the app icons
|
||||
|
||||
The CLI-created app ships with placeholder icons; this swaps in the NanoClaw
|
||||
mascot (the same PNGs the manual-path package bakes into its zip), so the
|
||||
install dialog below already shows it. Cosmetic — a failure is logged and
|
||||
skipped, never blocking setup. Re-runnable any time while signed in to the
|
||||
Teams CLI:
|
||||
|
||||
```nc:run effect:external when:have_creds=no
|
||||
"$(npm prefix -g 2>/dev/null)/bin/teams" app update {{teams_app_id}} --color-icon setup/assets/teams/color.png --outline-icon setup/assets/teams/outline.png --json || echo "Icon update failed — cosmetic only, continuing."
|
||||
```
|
||||
|
||||
### Who owns this bot
|
||||
|
||||
The account signed into the Teams CLI is the account that just created the
|
||||
bot — that human is the wiring target this flow suggests. Its identity comes
|
||||
from the CLI session, so this runs before the sign-out step below:
|
||||
|
||||
```nc:run effect:fetch when:have_creds=no capture:owner_upn=.username,owner_aad_id=.userObjectId validate:^.+$
|
||||
"$(npm prefix -g 2>/dev/null)/bin/teams" status --json 2>/dev/null
|
||||
```
|
||||
|
||||
### Confirm the wiring target
|
||||
|
||||
Nothing is wired without a confirmed target, and someone is always wired —
|
||||
there is no skip. The account signed into the Teams CLI is often NOT the
|
||||
person setting up NanoClaw, so a no leads to a clarifying choice: wire the
|
||||
logged-in Teams user after all, or a different Teams user by Microsoft Entra
|
||||
object ID. Identities are shown by sign-in name, never a raw ID:
|
||||
|
||||
```nc:operator when:have_creds=no
|
||||
Detected the account that created the bot: {{owner_upn}}. Wiring the assistant to it means its first message arrives in that account's Teams DMs.
|
||||
```
|
||||
|
||||
```nc:prompt wire_owner when:have_creds=no validate:^(yes|no)$ normalize:lower
|
||||
Wire the assistant to this account?
|
||||
```
|
||||
|
||||
```nc:operator when:wire_owner=no
|
||||
You're currently logged in to Teams as {{owner_upn}}.
|
||||
- To wire the assistant to this logged-in Teams user, choose "logged-in-account".
|
||||
- To wire a different Teams user, get their Microsoft Entra object ID — found at entra.microsoft.com > Users > (person) > Overview > Object ID, or Teams admin center > Manage users — and choose "other-account". Once wired, the assistant messages them first.
|
||||
```
|
||||
|
||||
```nc:prompt wire_target when:wire_owner=no validate:^(logged-in-account|other-account)$ normalize:lower
|
||||
Which Teams user should the assistant be wired to?
|
||||
```
|
||||
|
||||
```nc:prompt target_aad_id when:wire_target=other-account validate:^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$ normalize:trim
|
||||
Paste the Microsoft Entra object ID of the Teams user to wire (a GUID like 00000000-0000-0000-0000-000000000000).
|
||||
```
|
||||
|
||||
Either choice re-enters the exact same path as a yes above — rebind the
|
||||
wiring target and flip the branch, so the link chain below needs no second
|
||||
copy per branch:
|
||||
|
||||
```nc:run when:wire_target=other-account capture:owner_aad_id=.aad,wire_owner=.wire validate:^.+$
|
||||
printf '{"aad":"%s","wire":"yes"}' "{{target_aad_id}}"
|
||||
```
|
||||
|
||||
```nc:run when:wire_target=logged-in-account capture:wire_owner validate:^yes$
|
||||
echo yes
|
||||
```
|
||||
|
||||
### Install the app in Teams
|
||||
|
||||
The app package is already uploaded — no manifest zip, no manual sideload.
|
||||
Tell the user:
|
||||
|
||||
```nc:operator when:have_creds=no
|
||||
Install the bot into Teams:
|
||||
1. Open {{install_link}} — Teams opens with the app's install dialog. Click Add.
|
||||
2. If you need the link again later, run: teams app get {{teams_app_id}} --install-link
|
||||
3. If Teams refuses with a custom-app-upload error, a tenant admin must enable sideloading: Teams Admin Center > Teams apps > Setup policies > Global > "Upload custom apps" = On.
|
||||
Once the app shows up in your Teams sidebar (or app list), continue.
|
||||
```nc:operator
|
||||
Create the Azure Bot resource and point it at this machine:
|
||||
1. In https://portal.azure.com, search "Azure Bot" → Create.
|
||||
2. Bot handle: a unique name, e.g. nanoclaw-bot.
|
||||
3. Type of App: {{app_type}} — Creation type: Use existing app registration.
|
||||
4. App ID: {{app_id}}.
|
||||
5. After creating, open the bot → Configuration and set Messaging endpoint to {{public_url}}/api/webhooks/teams, then Apply.
|
||||
```
|
||||
|
||||
### Link the bot to your account
|
||||
### Enable the Teams channel
|
||||
|
||||
Nothing to do in Teams yet — these are background API calls, and the whole
|
||||
chain runs only for a confirmed target from
|
||||
[Confirm the wiring target](#confirm-the-wiring-target) (the detected owner
|
||||
on a yes, or the provided Entra object ID). Same move as
|
||||
Slack's `conversations.open` and Discord's `users/@me/channels`:
|
||||
create the bot↔owner 1:1 conversation proactively with the bot's own
|
||||
credentials, so the assistant messages the human first — nobody has to DM the
|
||||
bot to bootstrap it. This only works now that the app is installed (the step
|
||||
above); if Microsoft hasn't finished propagating the install yet, the create
|
||||
below can fail once — re-running the skill is safe.
|
||||
Tell the user (finish every Azure step above before continuing — the package
|
||||
built next bakes in the app registration, so the Azure app and bot must already
|
||||
exist):
|
||||
|
||||
First a Bot Framework token from the app credentials:
|
||||
|
||||
```nc:run effect:fetch when:wire_owner=yes capture:bot_token validate:^eyJ
|
||||
curl -sf -X POST "https://login.microsoftonline.com/{{app_tenant_id}}/oauth2/v2.0/token" --data-urlencode "grant_type=client_credentials" --data-urlencode "client_id={{app_id}}" --data-urlencode "client_secret={{app_password}}" --data-urlencode "scope=https://api.botframework.com/.default" | jq -er '.access_token'
|
||||
```nc:operator
|
||||
Enable the Microsoft Teams channel on the bot:
|
||||
1. Open your Azure Bot resource → Channels.
|
||||
2. Click Microsoft Teams → Accept terms → Apply.
|
||||
When the Azure app, bot resource, and Teams channel are all in place, continue.
|
||||
```
|
||||
|
||||
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):
|
||||
### Build the Teams app package
|
||||
|
||||
```nc:run effect:fetch when:wire_owner=yes capture:conversation_id validate:^.+$
|
||||
curl -sf -X POST "https://smba.trafficmanager.net/teams/v3/conversations" -H "Authorization: Bearer {{bot_token}}" -H "Content-Type: application/json" -d '{"bot":{"id":"28:{{app_id}}"},"members":[{"id":"{{owner_aad_id}}","name":"","role":"user"}],"tenantId":"{{app_tenant_id}}","channelData":{"tenant":{"id":"{{app_tenant_id}}"}},"isGroup":false}' | jq -er '.id'
|
||||
The manifest bakes in the Application (client) ID, so the Azure app registration
|
||||
above must be done first — building with a blank `app_id` produces a package that
|
||||
no bot can claim. Confirm it's set before generating the zip:
|
||||
|
||||
```nc:run effect:check
|
||||
[ -n "{{app_id}}" ]
|
||||
```
|
||||
|
||||
The adapter identifies inbound senders by their Bot Framework `29:` id, not
|
||||
the AAD id — the owner must be wired under that handle or their replies
|
||||
would not be recognized. The conversation was created with exactly one
|
||||
member (the owner), so its member list is the owner by construction; the
|
||||
filter only guards against channels that list the bot itself (`28:` ids).
|
||||
(Don't select by `.aadObjectId` here — the field is not reliably present in
|
||||
this response and its GUID casing varies.)
|
||||
Generate the zip you'll sideload into Teams (manifest + icons, written to
|
||||
`data/teams/teams-app-package.zip`). Re-running regenerates a fresh zip, so this
|
||||
is safe to repeat.
|
||||
|
||||
```nc:run effect:fetch when:wire_owner=yes capture:owner_handle=.id,owner_name=.name validate:^.+$
|
||||
curl -sf "https://smba.trafficmanager.net/teams/v3/conversations/{{conversation_id}}/members" -H "Authorization: Bearer {{bot_token}}" | jq -er '[.[] | select((.id // "") | startswith("28:") | not)][0] | {id, name: (.name // .givenName // "Teams user")}'
|
||||
```nc:run effect:external
|
||||
pnpm exec tsx setup/channels/teams-manifest-build.ts --app-id "{{app_id}}" --url "{{public_url}}"
|
||||
```
|
||||
|
||||
Compose the platform id exactly as the adapter encodes thread ids
|
||||
(`teams:{b64url conversation}:{b64url service url}`):
|
||||
### Sideload the app into Teams
|
||||
|
||||
```nc:run when:wire_owner=yes capture:platform_id validate:^teams:
|
||||
node -e 'const c=process.argv[1];const s="https://smba.trafficmanager.net/teams/";console.log("teams:"+Buffer.from(c).toString("base64url")+":"+Buffer.from(s).toString("base64url"))' "{{conversation_id}}"
|
||||
```
|
||||
Tell the user (do this before the restart below — the service should come up with
|
||||
the app already sideloaded):
|
||||
|
||||
### Sign out of the Teams CLI
|
||||
|
||||
The Microsoft 365 session was only needed to create the bot and identify its
|
||||
owner — the running adapter authenticates with the app credentials in
|
||||
`.env`, never with your account. On a headless box that session is a
|
||||
plaintext token file, which is worth removing unless you plan more `teams …`
|
||||
commands (rotate secret, endpoint update, RSC — each just needs a fresh
|
||||
`teams login`, a ~30-second device code):
|
||||
|
||||
```nc:prompt signout when:have_creds=no validate:^(yes|no)$ normalize:lower
|
||||
Sign out of the Teams CLI now? The bot doesn't need this login to run — signing out is recommended on shared or headless boxes, and `teams login` gets you back any time.
|
||||
```
|
||||
|
||||
```nc:run effect:external when:signout=yes
|
||||
"$(npm prefix -g 2>/dev/null)/bin/teams" logout
|
||||
```nc:operator
|
||||
Sideload the generated app package into Teams:
|
||||
1. Open Microsoft Teams → Apps → Manage your apps → Upload an app.
|
||||
2. Click "Upload a custom app" (or "Upload for me or my teams").
|
||||
3. Select data/teams/teams-app-package.zip and click Add.
|
||||
4. If "Upload a custom app" is missing, your tenant admin has disabled sideloading — enable it in Teams Admin Center → Teams apps → Setup policies → Global → Upload custom apps = On.
|
||||
Once the app is added in Teams, continue.
|
||||
```
|
||||
|
||||
## Restart
|
||||
@@ -349,29 +217,23 @@ bash setup/lib/restart.sh
|
||||
|
||||
## Finish wiring
|
||||
|
||||
On a fresh create, [Link the bot to your account](#link-the-bot-to-your-account) already resolved
|
||||
everything the wire needs — `owner_handle` (the owner's `29:` id) and
|
||||
`platform_id` (the bot↔owner DM). The setup wizard wires automatically from
|
||||
those and the welcome message lands in the owner's Teams DMs. Applying this
|
||||
skill outside the wizard? Run the same wire yourself:
|
||||
Unlike Discord or Slack, a Teams bot's platform ID isn't known until you DM the
|
||||
bot for the first time — the adapter derives it from the inbound activity. So
|
||||
this skill installs the adapter and stops here; you finish the wiring once the
|
||||
bot has seen its first message. Tell the user:
|
||||
|
||||
```bash
|
||||
pnpm exec tsx scripts/init-first-agent.ts --channel teams --user-id "teams:<owner_handle>" --platform-id "<platform_id>" --display-name "<the human's name>" --agent-name "<assistant name>" --role owner
|
||||
```nc:operator
|
||||
The Teams adapter is live and the service is running. One thing is left: your Teams bot's platform ID (which NanoClaw needs to wire it to an agent group) only becomes known after you DM the bot for the first time. To finish:
|
||||
1. Find your bot in Teams (search by name, or via the app you just sideloaded) and send it a message ("hi" is fine).
|
||||
2. Tail logs/nanoclaw.log for the inbound — the router auto-creates a row in messaging_groups in data/v2.db.
|
||||
3. Run scripts/init-first-agent.ts with --channel teams, the discovered platform_id, and your AAD user id — OR run /manage-channels to wire it interactively.
|
||||
```
|
||||
|
||||
**Fallback (re-runs, or the link step failed):** with credentials already in
|
||||
`.env` the resolve steps are skipped, so there is nothing new to wire — the
|
||||
first run's wiring still stands. If the install was never wired at all, the
|
||||
DM-first path always works: DM the bot once ("hi" is fine) — the router
|
||||
auto-creates the messaging group row in `data/v2.db` from that first inbound
|
||||
— then run `/init-first-agent` (or `/manage-channels`) with your coding
|
||||
agent.
|
||||
|
||||
## Next Steps
|
||||
|
||||
If you're in the middle of `/setup`, return to the setup flow now — it wires
|
||||
the owner automatically from the resolved values. Otherwise wire per
|
||||
[Finish wiring](#finish-wiring).
|
||||
If you're in the middle of `/setup`, return to the setup flow now. Otherwise,
|
||||
once you've DM'd the bot, wire this channel with `/init-first-agent` (or
|
||||
`/manage-channels`).
|
||||
|
||||
## Channel Info
|
||||
|
||||
@@ -385,78 +247,57 @@ the owner automatically from the resolved values. Otherwise wire per
|
||||
|
||||
## Alternatives
|
||||
|
||||
### Multi-tenant bot
|
||||
### Auto: Teams CLI
|
||||
|
||||
The Credentials flow above always creates a single-tenant bot (only your
|
||||
Microsoft 365 tenant can install it) — the right default for a self-hosted
|
||||
assistant, so the skill doesn't ask. For a bot any tenant can install, run
|
||||
the create by hand with `multipleOrgs` and store the matching env pairing —
|
||||
`MultiTenant` with **no** tenant ID (the same 401 pairing rule from the
|
||||
credentials step):
|
||||
The Credentials flow above walks the manual Azure Portal path. If you'd rather
|
||||
script it, the Microsoft Teams CLI creates the Entra app, client secret, and bot
|
||||
registration in one command. Requires Node.js 18+, a Microsoft 365 account with
|
||||
sideloading permissions, and a public HTTPS endpoint (ngrok, Cloudflare Tunnel,
|
||||
or similar).
|
||||
|
||||
```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):
|
||||
1. Install the CLI:
|
||||
|
||||
```bash
|
||||
TEAMS_APP_ID=<Application (client) ID>
|
||||
TEAMS_APP_PASSWORD=<client secret Value>
|
||||
TEAMS_APP_TYPE=SingleTenant
|
||||
TEAMS_APP_TENANT_ID=<Directory (tenant) ID>
|
||||
npm install -g @microsoft/teams.cli@preview
|
||||
```
|
||||
6. **Build the app package** (manifest + icons, written in-process to
|
||||
`data/teams/teams-app-package.zip` — no `zip` binary needed):
|
||||
|
||||
2. Sign in and verify:
|
||||
|
||||
```bash
|
||||
pnpm exec tsx setup/channels/teams-manifest-build.ts --app-id YOUR_APP_ID --url https://your-domain
|
||||
teams login
|
||||
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).
|
||||
|
||||
Or create the bot resource with the Azure CLI instead of the portal:
|
||||
3. Create the Entra app, client secret, and bot registration:
|
||||
|
||||
```bash
|
||||
teams app create \
|
||||
--name "NanoClaw" \
|
||||
--endpoint "https://your-domain/api/webhooks/teams"
|
||||
```
|
||||
|
||||
The CLI prints the credentials as `CLIENT_ID`, `CLIENT_SECRET`, and `TENANT_ID`. Map them to NanoClaw's env keys:
|
||||
|
||||
- `CLIENT_ID` → `TEAMS_APP_ID`
|
||||
- `CLIENT_SECRET` → `TEAMS_APP_PASSWORD`
|
||||
- `TENANT_ID` → `TEAMS_APP_TENANT_ID`
|
||||
|
||||
4. Pick **Install in Teams** from the post-create menu and confirm in the Teams dialog.
|
||||
|
||||
### Azure CLI for the bot resource
|
||||
|
||||
The Azure Bot resource and Teams channel can also be created with `az` instead of
|
||||
clicking through the portal:
|
||||
|
||||
```bash
|
||||
az group create --name nanoclaw-rg --location eastus
|
||||
az bot create --resource-group nanoclaw-rg --name nanoclaw-bot --app-type SingleTenant --appid YOUR_APP_ID --tenant-id YOUR_TENANT_ID --endpoint "https://your-domain/webhook/teams"
|
||||
az bot create \
|
||||
--resource-group nanoclaw-rg \
|
||||
--name nanoclaw-bot \
|
||||
--app-type SingleTenant \
|
||||
--appid YOUR_APP_ID \
|
||||
--tenant-id YOUR_TENANT_ID \
|
||||
--endpoint "https://your-domain/api/webhooks/teams"
|
||||
az bot msteams create --resource-group nanoclaw-rg --name nanoclaw-bot
|
||||
```
|
||||
|
||||
@@ -464,121 +305,43 @@ az bot msteams create --resource-group nanoclaw-rg --name nanoclaw-bot
|
||||
|
||||
### Receive all channel messages (without @-mention)
|
||||
|
||||
By default the bot only receives messages when @-mentioned. With a CLI-created
|
||||
bot, grant the resource-specific-consent (RSC) permissions directly — no
|
||||
manifest edit, no re-upload; the app version is bumped automatically:
|
||||
By default the bot only receives messages when @-mentioned. To receive every
|
||||
message in a channel, add an RSC (resource-specific consent) permission to your
|
||||
Teams app `manifest.json`:
|
||||
|
||||
```bash
|
||||
teams app rsc add <teams-app-id> ChannelMessage.Read.Group --type Application
|
||||
teams app rsc add <teams-app-id> ChatMessage.Read.Chat --type Application
|
||||
```json
|
||||
{
|
||||
"authorization": {
|
||||
"permissions": {
|
||||
"resourceSpecific": [
|
||||
{ "name": "ChannelMessage.Read.Group", "type": "Application" }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
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
|
||||
```
|
||||
Re-sideload the updated app package for the change to take effect.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "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.
|
||||
### "Upload a custom app" is missing in Teams
|
||||
|
||||
Your tenant admin has disabled sideloading. Enable it in Teams Admin Center →
|
||||
**Teams apps** → **Setup policies** → **Global** → **Upload custom apps** = On.
|
||||
Free personal Teams does not support sideloading at all — use a Microsoft 365
|
||||
Business / EDU / developer tenant.
|
||||
|
||||
The login step's sideloading probe is **advisory** — policy edits can take
|
||||
hours to propagate and the probe has been seen flapping between runs on the
|
||||
same account. The authoritative test is whether the install link's Add
|
||||
actually works; only act on the probe if the install itself refuses.
|
||||
|
||||
### `teams: command not found`
|
||||
|
||||
The CLI installed fine but npm's global bin directory isn't on your PATH — a
|
||||
common state with custom npm prefixes. Find it with `npm prefix -g` (the
|
||||
binary is at `<prefix>/bin/teams`), then either add that directory to PATH or
|
||||
symlink the binary somewhere already on it. The skill's own steps are immune —
|
||||
they invoke the absolute path.
|
||||
|
||||
### Create fails immediately with `AUTH_REQUIRED` after a successful sign-in
|
||||
|
||||
The sign-in didn't persist: each `teams` command is a separate process, and
|
||||
when the CLI's credential store can't load it silently falls back to an
|
||||
in-memory cache that dies with the login process. Symptom check:
|
||||
`teams status` says logged out right after a login succeeded. The known
|
||||
cause: the **CLI was installed as a pnpm workspace dependency** — pnpm's
|
||||
supply-chain policy skips dependency build scripts, so keytar (the CLI's
|
||||
native credential store) never gets its binary and the whole store fails to
|
||||
load. Use the global npm install this skill performs — and `pnpm uninstall
|
||||
@microsoft/teams.cli` if a workspace copy lingers, so `teams` resolves to
|
||||
the global one. (The "libsecret not found → stored unencrypted" warning is
|
||||
NOT this failure — that fallback persists fine and is safe to ignore.)
|
||||
|
||||
After fixing, sign in again and confirm `teams status` shows logged in, then
|
||||
re-run this skill.
|
||||
|
||||
### Bot never receives messages
|
||||
|
||||
1. The app is actually installed in Teams — if setup was interrupted before
|
||||
the install step, nothing got installed. Recover the install link:
|
||||
`teams app list` shows the Teams App ID, then
|
||||
`teams app get <teams-app-id> --install-link`.
|
||||
2. The tunnel is up and the messaging endpoint matches it — the endpoint must
|
||||
be `https://<your-domain>/webhook/teams`, and your tunnel (e.g.
|
||||
`cloudflared tunnel --url http://localhost:3000`) must be forwarding to
|
||||
this machine's port 3000. Check
|
||||
with `teams app doctor <teams-app-id>` (CLI-created bots) or Azure
|
||||
Bot → **Configuration** (manual path).
|
||||
3. The adapter started: `grep -i teams logs/nanoclaw.log | tail`.
|
||||
4. The credentials are in `.env` (`TEAMS_APP_ID`, `TEAMS_APP_PASSWORD`,
|
||||
`TEAMS_APP_TYPE`).
|
||||
|
||||
### Tunnel URL changed
|
||||
|
||||
Point the bot at the new endpoint:
|
||||
`teams app update <teams-app-id> --endpoint "https://new-domain/webhook/teams"`
|
||||
(manual path: Azure Bot → Configuration → Messaging endpoint).
|
||||
1. The tunnel is up and the messaging endpoint matches it: Azure Bot → **Configuration** → **Messaging endpoint** must be `https://<your-domain>/api/webhooks/teams`, and your tunnel (e.g. `ngrok http 3000`) must be forwarding to this machine's port 3000.
|
||||
2. The adapter started: `grep -i teams logs/nanoclaw.log | tail`.
|
||||
3. The credentials are in `.env` (`TEAMS_APP_ID`, `TEAMS_APP_PASSWORD`, `TEAMS_APP_TYPE`).
|
||||
|
||||
### `Unauthorized` / 401 from Azure Bot Service
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
### 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.
|
||||
|
||||
@@ -28,21 +28,24 @@ function operatorBody(md: string, n: number): string {
|
||||
}
|
||||
|
||||
describe('gatePolicy — §5.1 parity table (real skills)', () => {
|
||||
it('teams: one gate — the install-in-Teams operator pauses before the DM-open fetches', () => {
|
||||
// Operators in order (CLI-first flow): prerequisites, the detected-owner
|
||||
// note, the wire-declined note (when:wire_owner=no), install-in-Teams.
|
||||
// The finish-wiring handoff is prose only — the wizard wires inline from
|
||||
// the resolved vars.
|
||||
it('teams: exactly the two authored gates — and NO confirm on the operator-chain head', () => {
|
||||
// Operators in order: prerequisites, portal app, client secret, bot resource
|
||||
// (chain head), enable-channel (chain tail), sideload, final handoff.
|
||||
const d = decisions(loadSkill('teams'));
|
||||
expect(d).toHaveLength(4);
|
||||
expect(d).toHaveLength(7);
|
||||
expect(d.map((g) => g.needsConfirm)).toEqual([
|
||||
false, // prereqs → prompt public_url (prompt is the barrier)
|
||||
false, // detected-owner note → prompt wire_owner (prompt is the barrier)
|
||||
false, // wire-declined note → install operator (last operator of the chain carries the barrier)
|
||||
true, // install-in-Teams → the DM-open effect:fetch chain
|
||||
false, // → prompt public_url (prompt is the barrier)
|
||||
false, // → prompt app_id
|
||||
false, // → prompt app_password
|
||||
false, // bot-resource block → next compatible is another OPERATOR (rule 2):
|
||||
// the chain's LAST operator carries the barrier — never double-confirm
|
||||
true, // enable-channel → run effect:check (the manifest hazard gate)
|
||||
true, // sideload → run effect:restart
|
||||
false, // final handoff → end of document
|
||||
]);
|
||||
// Completed-work flavor (fetch/external, not effect:step).
|
||||
expect(d[3].flavor).toBe('completed');
|
||||
// Both confirms are completed-work flavor (check/restart, not effect:step).
|
||||
expect(d[4].flavor).toBe('completed');
|
||||
expect(d[5].flavor).toBe('completed');
|
||||
});
|
||||
|
||||
it('telegram: the pairing operator gains a readiness pause before the effect:step', () => {
|
||||
@@ -137,16 +140,10 @@ describe('gatePolicy — rules on synthetic fixtures', () => {
|
||||
// §5.2 URL-offer inventory — every operator body in the tree, plus the
|
||||
// normative negative fixture (slack's placeholder URL).
|
||||
describe('extractOfferUrl — §5.2 inventory', () => {
|
||||
it('teams: raw bodies stay offer-free — the install link is a {{var}} until substitution', () => {
|
||||
it('teams: both portal blocks offer a clean https://portal.azure.com (trailing comma stripped)', () => {
|
||||
const md = loadSkill('teams');
|
||||
// The install block's URL is {{install_link}} in the AUTHORED body — no
|
||||
// candidate matches here; the offer materializes at runtime from the
|
||||
// rendered body (proven in run-channel-skill.test.ts's fresh-create case).
|
||||
expect(operatorBody(md, 3)).toContain('{{install_link}}');
|
||||
expect(extractOfferUrl(operatorBody(md, 0))).toBeUndefined(); // prereqs
|
||||
expect(extractOfferUrl(operatorBody(md, 1))).toBeUndefined(); // detected-owner note
|
||||
expect(extractOfferUrl(operatorBody(md, 2))).toBeUndefined(); // wire-declined note (entra link is schemeless on purpose)
|
||||
expect(extractOfferUrl(operatorBody(md, 3))).toBeUndefined(); // install-in-Teams
|
||||
expect(extractOfferUrl(operatorBody(md, 1))).toBe('https://portal.azure.com'); // app registration
|
||||
expect(extractOfferUrl(operatorBody(md, 3))).toBe('https://portal.azure.com'); // bot resource (new offer)
|
||||
});
|
||||
|
||||
it('slack — the <your-public-host> placeholder is EXCLUDED (normative negative fixture)', () => {
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 13 KiB |
@@ -1,36 +0,0 @@
|
||||
{
|
||||
"$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.
|
Before Width: | Height: | Size: 392 B |
+5
-21
@@ -555,7 +555,9 @@ async function main(): Promise<void> {
|
||||
}
|
||||
let result: void | typeof BACK_TO_CHANNEL_SELECTION;
|
||||
// Every channel now runs through the SKILL.md-driven flow — the whole
|
||||
// connect+wire procedure lives in each add-<channel>/SKILL.md.
|
||||
// connect+wire procedure lives in each add-<channel>/SKILL.md. Teams
|
||||
// defers the wire (its platform_id only exists after the first inbound),
|
||||
// so it installs + hands off rather than wiring inline.
|
||||
if (channelChoice === 'telegram') {
|
||||
result = await runChannelSkill('telegram', displayName!, { offerBack: true });
|
||||
} else if (channelChoice === 'discord') {
|
||||
@@ -565,10 +567,7 @@ async function main(): Promise<void> {
|
||||
} else if (channelChoice === 'signal') {
|
||||
result = await runChannelSkill('signal', displayName!, { offerBack: true });
|
||||
} else if (channelChoice === 'teams') {
|
||||
// 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 });
|
||||
result = await runChannelSkill('teams', displayName!, { deferWire: true, offerBack: true });
|
||||
} else if (channelChoice === 'slack') {
|
||||
result = await runChannelSkill('slack', displayName!, { offerBack: true });
|
||||
} else if (channelChoice === 'imessage') {
|
||||
@@ -589,12 +588,6 @@ async function main(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// Deferred wire (Teams): verify passes with zero groups because the
|
||||
// platform id only exists after the first DM. Tracked here so the ENDING
|
||||
// changes too — the last box must be the one remaining action, not a
|
||||
// premature "your assistant is saying hi" (no welcome DM exists yet).
|
||||
let wiringPending = false;
|
||||
|
||||
if (!skip.has('verify')) {
|
||||
const res = await runQuietStep('verify', {
|
||||
running: 'Making sure everything works together…',
|
||||
@@ -649,7 +642,6 @@ async function main(): Promise<void> {
|
||||
p.outro(k.yellow('Almost there. A few things still need your attention.'));
|
||||
return;
|
||||
}
|
||||
wiringPending = res.terminal?.fields.WIRING === 'pending_first_dm';
|
||||
}
|
||||
|
||||
const rows: [string, string][] = [
|
||||
@@ -675,15 +667,7 @@ async function main(): Promise<void> {
|
||||
phEmit('setup_completed', { duration_ms: Date.now() - RUN_START });
|
||||
|
||||
const dmTarget = channelDmLabel(channelChoice);
|
||||
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) {
|
||||
if (dmTarget) {
|
||||
// Bright framed banner (not dim) — the whole point of the feedback was
|
||||
// that the welcome-message signal was too easy to miss. Use p.note so it
|
||||
// renders with a visible box, cyan-bold the directive line, and put it
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
import { describe, it, expect, afterEach, vi } from 'vitest';
|
||||
import { execSync } from 'node:child_process';
|
||||
import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'node:fs';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { runChannelSkill } from './run-channel-skill.js';
|
||||
import { runSkill } from '../lib/skill-driver.js';
|
||||
import { fullyApplied } from '../../scripts/skill-apply.js';
|
||||
import { parseDirectives } from '../../scripts/skill-directives.js';
|
||||
import { BACK_TO_CHANNEL_SELECTION, backGate } from '../lib/back-nav.js';
|
||||
|
||||
// Drive the first-prompt back gate (back-nav's brightSelect) from a queue
|
||||
@@ -75,31 +71,30 @@ describe('runChannelSkill adapter (Option A)', () => {
|
||||
expect(cmds.some((c) => c.startsWith('ncl '))).toBe(false);
|
||||
});
|
||||
|
||||
// Teams wires inline only when a fresh create resolved the owner DM
|
||||
// (wireIfResolved). This fixture answers the have_creds probe with "yes"
|
||||
// (credentials already in .env), so every creation + resolve step — the
|
||||
// teams-login step, teams app create, the env writes, the DM-open chain,
|
||||
// the install-link operator — is when:-skipped, the wire inputs stay
|
||||
// unresolved, and the run drops through to restart without wiring.
|
||||
it('wireIfResolved (Teams): existing credentials skip the whole CLI create flow, never reach the shared wire', async () => {
|
||||
// Teams' platform_id only exists after the first inbound, so its SKILL.md
|
||||
// installs + hands off and runChannelSkill is called with deferWire — it must
|
||||
// run the skill but never reach the shared wire. This is the driver-policy
|
||||
// parity fixture: it runs the DEFAULT onEvent handler (never an injected
|
||||
// onEvent, which would replace the policy — §5.0) and injects the
|
||||
// confirm/openUrl seams to prove both natural barriers fire and the portal
|
||||
// URL offer survives from the operator prose alone.
|
||||
it('deferWire (Teams): default policy fires the gate barriers + portal URL offer, never reaches the shared wire', async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'rcs-teams-'));
|
||||
mkdirSync(join(root, 'src/channels'), { recursive: true });
|
||||
writeFileSync(join(root, 'src/channels/index.ts'), '// barrel\n');
|
||||
writeFileSync(join(root, '.env'), 'TEAMS_APP_ID=existing\nTEAMS_APP_PASSWORD=existing-password\n');
|
||||
writeFileSync(join(root, '.env'), '');
|
||||
writeFileSync(join(root, 'package.json'), '{"name":"scratch"}');
|
||||
|
||||
const log: string[] = [];
|
||||
const opened: string[] = [];
|
||||
const wired: unknown[] = [];
|
||||
|
||||
await runChannelSkill('teams', 'Acme Corp', {
|
||||
projectRoot: root,
|
||||
exec: (c) => {
|
||||
log.push(`exec:${c}`);
|
||||
if (c.includes('TEAMS_APP_ID=.')) return 'yes'; // the have_creds probe
|
||||
},
|
||||
exec: (c) => void log.push(`exec:${c}`),
|
||||
resolveRemote: () => 'origin',
|
||||
reuse: false,
|
||||
wireIfResolved: true,
|
||||
deferWire: true,
|
||||
// The injectable interaction seams — the default handler consults them for
|
||||
// the URL offer and the natural-barrier confirms, so no real clack confirm
|
||||
// (which would hang in CI) and no real browser open is reached.
|
||||
@@ -107,358 +102,33 @@ describe('runChannelSkill adapter (Option A)', () => {
|
||||
log.push(`confirm:${m}`);
|
||||
return true;
|
||||
},
|
||||
openUrl: async () => undefined,
|
||||
// NO inputs: the public_url prompt is when:have_creds=no-guarded, so the
|
||||
// drop-through path must never ask for it. If the guard regressed, the
|
||||
// prompt would defer (resolveInput undefined) and fail() would be called.
|
||||
resolveInput: async () => undefined,
|
||||
fail: async (step, msg) => {
|
||||
throw new Error(`fail() called on drop-through path: ${step} — ${msg}`);
|
||||
},
|
||||
wire: (a) => {
|
||||
wired.push(a);
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
// the adapter install ran, but no bot was created and no login step fired…
|
||||
expect(log.some((c) => c.includes('pnpm add @chat-adapter/teams'))).toBe(true);
|
||||
expect(log.some((c) => c.includes('app create'))).toBe(false);
|
||||
expect(log.some((c) => c.includes('app update'))).toBe(false); // icon step is creation-side too
|
||||
expect(log.some((c) => c.includes('login'))).toBe(false);
|
||||
// …no logout either — the drop-through path never signed in, and must not
|
||||
// sign out a session the operator may be using for something else…
|
||||
expect(log.some((c) => c.includes('logout'))).toBe(false);
|
||||
// …the Teams CLI install is also skipped (nothing to create)…
|
||||
expect(log.some((c) => c.includes('npm install -g @microsoft/teams.cli'))).toBe(false);
|
||||
// …the service still restarts (adapter + existing credentials load)…
|
||||
expect(log.some((c) => c.includes('restart.sh'))).toBe(true);
|
||||
// …the pre-existing .env values were left alone…
|
||||
expect(readFileSync(join(root, '.env'), 'utf8')).toContain('TEAMS_APP_ID=existing');
|
||||
// …and the shared wire was never reached (no owner_handle/platform_id needed)
|
||||
expect(wired).toHaveLength(0);
|
||||
});
|
||||
|
||||
// The probe's shell one-liner is dispatched by substring in the fixtures
|
||||
// above — its actual semantics (EITHER key present ⇒ yes; a partial pair
|
||||
// must NOT trigger a second `teams app create`) are asserted here by running
|
||||
// the REAL command from the REAL SKILL.md against real .env states. Parsed
|
||||
// from the document so the test can't drift from what ships.
|
||||
it('Teams have_creds probe: either credential key present answers yes', () => {
|
||||
const md = readFileSync(join(process.cwd(), '.claude/skills/add-teams/SKILL.md'), 'utf8');
|
||||
const probe = parseDirectives(md).find(
|
||||
(d) => d.kind === 'run' && d.attrs.capture === 'have_creds',
|
||||
);
|
||||
expect(probe).toBeDefined();
|
||||
const cmd = probe!.body.join('\n');
|
||||
|
||||
const cases: Array<[string | null, string]> = [
|
||||
['TEAMS_APP_ID=a\nTEAMS_APP_PASSWORD=b\n', 'yes'],
|
||||
['TEAMS_APP_ID=a\n', 'yes'], // partial pair: creating another app would corrupt it
|
||||
['TEAMS_APP_PASSWORD=b\n', 'yes'],
|
||||
['OTHER=x\n', 'no'],
|
||||
['TEAMS_APP_ID=\n', 'no'], // empty value counts as unset (mirrors env-set)
|
||||
[null, 'no'], // no .env at all
|
||||
];
|
||||
for (const [env, expected] of cases) {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'rcs-probe-'));
|
||||
if (env !== null) writeFileSync(join(dir, '.env'), env);
|
||||
const out = execSync(cmd, { cwd: dir, shell: '/bin/bash', encoding: 'utf8' }).trim();
|
||||
expect(out, `env=${JSON.stringify(env)}`).toBe(expected);
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// The fresh-create leg of the same document, driven at the runSkill level so
|
||||
// the effect:step gets an injected streaming exec (runChannelSkill exposes no
|
||||
// execStream seam — CI must never spawn a real `teams login`). Proves the
|
||||
// CLI-first chain end-to-end: login step → create's JSON multi-capture → the
|
||||
// env writes → the substituted install link surviving into the URL offer.
|
||||
it('Teams fresh create: login step + JSON capture drive the env writes and the install-link offer', async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'rcs-teams-create-'));
|
||||
mkdirSync(join(root, 'src/channels'), { recursive: true });
|
||||
writeFileSync(join(root, 'src/channels/index.ts'), '// barrel\n');
|
||||
writeFileSync(join(root, '.env'), '');
|
||||
writeFileSync(join(root, 'package.json'), '{"name":"scratch"}');
|
||||
|
||||
const INSTALL_LINK =
|
||||
'https://teams.microsoft.com/l/app/tapp-123?installAppPackage=true&appTenantId=tenant-1';
|
||||
const log: string[] = [];
|
||||
const opened: string[] = [];
|
||||
const steps: string[] = [];
|
||||
|
||||
// The DM-open chain's expected results. The exec mock returns each
|
||||
// command's FINAL stdout (post-jq), matching what the engine captures.
|
||||
const EXPECTED_PLATFORM_ID = `teams:${Buffer.from('a:1conv').toString('base64url')}:${Buffer.from('https://smba.trafficmanager.net/teams/').toString('base64url')}`;
|
||||
|
||||
const res = await runSkill('.claude/skills/add-teams', {
|
||||
projectRoot: root,
|
||||
exec: (c) => {
|
||||
log.push(`exec:${c}`);
|
||||
if (c.includes('TEAMS_APP_ID=.')) return 'no'; // the have_creds probe: nothing configured yet
|
||||
if (c.includes(' app create ')) {
|
||||
// the --json shape teams.cli@3.0.2 prints (credentials keys are UPPERCASE)
|
||||
return JSON.stringify({
|
||||
appName: 'NanoClaw',
|
||||
teamsAppId: 'tapp-123',
|
||||
botId: '12345678-1234-1234-1234-123456789abc',
|
||||
installLink: INSTALL_LINK,
|
||||
portalLink: 'https://dev.teams.microsoft.com/apps/tapp-123',
|
||||
credentials: {
|
||||
CLIENT_ID: '12345678-1234-1234-1234-123456789abc',
|
||||
CLIENT_SECRET: 'a-much-longer-app-secret',
|
||||
TENANT_ID: '87654321-4321-4321-4321-cba987654321',
|
||||
},
|
||||
});
|
||||
}
|
||||
// owner identity from the CLI session (status --json fence, plain exec)
|
||||
if (c.includes('status --json')) {
|
||||
return JSON.stringify({ loggedIn: true, username: 'dan@acme.example', tenantId: 'tenant-1', userObjectId: 'aad-owner-1' });
|
||||
}
|
||||
if (c.includes('login.microsoftonline.com')) return 'eyJfake.bot.token';
|
||||
// /members is a sub-path of /v3/conversations — match it FIRST
|
||||
if (c.includes('/members')) return JSON.stringify({ id: '29:owner-xyz', name: 'Dan Mill' });
|
||||
if (c.includes('/v3/conversations')) return 'a:1conv';
|
||||
if (c.includes('node -e')) return EXPECTED_PLATFORM_ID;
|
||||
},
|
||||
execStream: async (cmd) => {
|
||||
steps.push(cmd);
|
||||
return { ok: true, fields: { STATUS: 'success' } };
|
||||
},
|
||||
resolveRemote: () => 'origin',
|
||||
inputs: { public_url: 'https://acme.example', app_name: 'NanoClaw', wire_owner: 'yes', signout: 'yes' },
|
||||
confirm: async (m) => {
|
||||
log.push(`confirm:${m}`);
|
||||
return true;
|
||||
},
|
||||
openUrl: async (u) => void opened.push(u),
|
||||
});
|
||||
|
||||
// the CLI installed globally (npm runs keytar's install script; pnpm's
|
||||
// build-script policy would leave the credential store unbuildable)…
|
||||
expect(log.some((c) => c.includes('npm install -g @microsoft/teams.cli@3.0.2'))).toBe(true);
|
||||
// …the login ran as a streaming step, never a plain exec (the CLI is
|
||||
// invoked by absolute path — $(npm prefix -g)/bin/teams — so match loosely)…
|
||||
expect(steps.some((c) => c.includes('/bin/teams" login'))).toBe(true);
|
||||
expect(log.some((c) => c.startsWith('exec:') && c.includes(' login'))).toBe(false);
|
||||
// …create got the collected public URL on the real /webhook/teams route,
|
||||
// the prompted name, and the unconditional single-tenant default…
|
||||
expect(log.some((c) => c.includes('--endpoint "https://acme.example/webhook/teams"'))).toBe(true);
|
||||
expect(log.some((c) => c.includes('--name "NanoClaw"') && c.includes('--sign-in-audience myOrg'))).toBe(true);
|
||||
// …the mascot icons were applied to the created app (captured teams app id,
|
||||
// both committed assets) before the install-link operator…
|
||||
expect(
|
||||
log.some(
|
||||
(c) =>
|
||||
c.includes(' app update tapp-123') &&
|
||||
c.includes('--color-icon setup/assets/teams/color.png') &&
|
||||
c.includes('--outline-icon setup/assets/teams/outline.png'),
|
||||
),
|
||||
).toBe(true);
|
||||
// …the DM-open chain resolved the wire inputs: the owner's 29: id from the
|
||||
// conversation members (first non-bot member) and the adapter-encoded
|
||||
// platform id from the created conversation…
|
||||
expect(res.vars.owner_handle).toBe('29:owner-xyz');
|
||||
expect(res.vars.owner_name).toBe('Dan Mill');
|
||||
expect(res.vars.platform_id).toBe(EXPECTED_PLATFORM_ID);
|
||||
// …the M365 session was signed out on the operator's "yes" (the adapter
|
||||
// runs on the .env app credentials; staying signed in is now a choice)…
|
||||
expect(log.some((c) => c.includes('/bin/teams" logout'))).toBe(true);
|
||||
// …the captured credentials landed in .env with the safe SingleTenant pairing…
|
||||
const env = readFileSync(join(root, '.env'), 'utf8');
|
||||
expect(env).toContain('TEAMS_APP_ID=12345678-1234-1234-1234-123456789abc');
|
||||
expect(env).toContain('TEAMS_APP_PASSWORD=a-much-longer-app-secret');
|
||||
expect(env).toContain('TEAMS_APP_TENANT_ID=87654321-4321-4321-4321-cba987654321');
|
||||
expect(env).toContain('TEAMS_APP_TYPE=SingleTenant');
|
||||
// …the install-link operator offered the SUBSTITUTED link (policy §5.2 runs on
|
||||
// the rendered body — an unsubstituted {{var}} would have been excluded)…
|
||||
expect(opened).toContain(INSTALL_LINK);
|
||||
// …a natural-barrier confirm fired between the install operator and restart…
|
||||
const gateAt = log.findIndex((c) => c.startsWith('confirm:') && !c.startsWith('confirm:Open '));
|
||||
const restartAt = log.findIndex((c) => c.includes('restart.sh'));
|
||||
expect(gateAt).toBeGreaterThanOrEqual(0);
|
||||
expect(gateAt).toBeLessThan(restartAt);
|
||||
// …and the whole document applied with nothing deferred or bounced.
|
||||
expect(res.deferred).toEqual([]);
|
||||
expect(res.agentTasks).toEqual([]);
|
||||
expect(fullyApplied(res)).toBe(true);
|
||||
});
|
||||
|
||||
// A no at the wiring confirm then "other-account" collects a different
|
||||
// user's Entra object ID, rebinds the wiring target, and re-enters the yes
|
||||
// branch: the conversation is created with the PROVIDED id (not the CLI
|
||||
// account's), and the wire inputs resolve to that person — the assistant
|
||||
// messages the desired user first. There is no skip: someone is always wired.
|
||||
it('Teams fresh create, wiring another account by Entra object ID: the chain runs against the target user', async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'rcs-teams-target-'));
|
||||
mkdirSync(join(root, 'src/channels'), { recursive: true });
|
||||
writeFileSync(join(root, 'src/channels/index.ts'), '// barrel\n');
|
||||
writeFileSync(join(root, '.env'), '');
|
||||
writeFileSync(join(root, 'package.json'), '{"name":"scratch"}');
|
||||
|
||||
const TARGET_AAD = 'aaaabbbb-cccc-dddd-eeee-ffff00001111';
|
||||
const EXPECTED_PLATFORM_ID = `teams:${Buffer.from('a:2conv').toString('base64url')}:${Buffer.from('https://smba.trafficmanager.net/teams/').toString('base64url')}`;
|
||||
const log: string[] = [];
|
||||
const res = await runSkill('.claude/skills/add-teams', {
|
||||
projectRoot: root,
|
||||
exec: (c) => {
|
||||
log.push(`exec:${c}`);
|
||||
if (c.includes('TEAMS_APP_ID=.')) return 'no';
|
||||
if (c.includes(' app create ')) {
|
||||
return JSON.stringify({
|
||||
teamsAppId: 'tapp-123',
|
||||
installLink: 'https://teams.microsoft.com/l/app/tapp-123',
|
||||
credentials: { CLIENT_ID: 'app-1', CLIENT_SECRET: 'a-much-longer-app-secret', TENANT_ID: 'tenant-1' },
|
||||
});
|
||||
}
|
||||
if (c.includes('status --json')) {
|
||||
return JSON.stringify({ loggedIn: true, username: 'dan@acme.example', userObjectId: 'aad-owner-1' });
|
||||
}
|
||||
// the rebind fence: printf its own substituted JSON back
|
||||
if (c.includes('"wire":"yes"')) return `{"aad":"${TARGET_AAD}","wire":"yes"}`;
|
||||
if (c.includes('login.microsoftonline.com')) return 'eyJfake.bot.token';
|
||||
if (c.includes('/members')) return JSON.stringify({ id: '29:target-xyz', name: 'Desired Person' });
|
||||
if (c.includes('/v3/conversations')) return 'a:2conv';
|
||||
if (c.includes('node -e')) return EXPECTED_PLATFORM_ID;
|
||||
},
|
||||
execStream: async () => ({ ok: true, fields: { STATUS: 'success' } }),
|
||||
resolveRemote: () => 'origin',
|
||||
// a MultiTenant app, so the SingleTenant-guarded app_tenant_id prompt is skipped
|
||||
inputs: {
|
||||
public_url: 'https://acme.example',
|
||||
app_name: 'NanoClaw',
|
||||
wire_owner: 'no',
|
||||
wire_target: 'other-account',
|
||||
target_aad_id: TARGET_AAD,
|
||||
signout: 'yes',
|
||||
app_id: '12345678-1234-1234-1234-123456789abc',
|
||||
app_type: 'MultiTenant',
|
||||
app_password: 'a-much-longer-app-password', // 20+ chars — valid for the declared shape
|
||||
},
|
||||
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',
|
||||
});
|
||||
// install + manifest ran…
|
||||
expect(log.some((c) => c.includes('teams-manifest-build'))).toBe(true);
|
||||
// …the Azure portal offer came from the operator BODY text (policy §5.2)…
|
||||
expect(opened.some((u) => /portal\.azure\.com/.test(u))).toBe(true);
|
||||
// …a natural-barrier confirm (not a URL offer) fired BEFORE the manifest
|
||||
// build (the manifest-before-the-app hazard fix, now derived from document
|
||||
// structure instead of an authored gate attr)…
|
||||
const firstGate = log.findIndex((c) => c.startsWith('confirm:') && !c.startsWith('confirm:Open '));
|
||||
const manifestAt = log.findIndex((c) => c.includes('teams-manifest-build'));
|
||||
expect(firstGate).toBeGreaterThanOrEqual(0);
|
||||
expect(firstGate).toBeLessThan(manifestAt);
|
||||
// …but the shared wire was never reached (no owner_handle/platform_id needed)
|
||||
expect(wired).toHaveLength(0);
|
||||
});
|
||||
|
||||
// The engine reads `.claude/skills/add-<channel>/SKILL.md` relative to cwd (the
|
||||
|
||||
@@ -15,9 +15,12 @@
|
||||
* So the wire lives in exactly one place (init-first-agent) and is never
|
||||
* duplicated across channel skills.
|
||||
*/
|
||||
import { writeFileSync } from 'node:fs';
|
||||
|
||||
import * as p from '@clack/prompts';
|
||||
|
||||
import { firstFailureHint, fullyApplied } from '../../scripts/skill-apply.js';
|
||||
import * as setupLog from '../logs.js';
|
||||
import { BACK_TO_CHANNEL_SELECTION, backGate, type ChannelFlowResult } from '../lib/back-nav.js';
|
||||
import { askOperatorRole, type OperatorRole } from '../lib/role-prompt.js';
|
||||
import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js';
|
||||
@@ -73,18 +76,17 @@ export interface ChannelSkillOverrides extends Partial<RunSkillOptions> {
|
||||
/** The shared wire; defaults to init-first-agent. Injectable for tests. */
|
||||
wire?: (args: WireArgs) => Promise<boolean> | boolean;
|
||||
/**
|
||||
* Wire only when the skill resolved owner_handle + platform_id this run
|
||||
* (Teams: the guarded DM-open steps only run on a fresh create). Resolved →
|
||||
* ask agent name/role and wire like any channel; unresolved (a drop-through
|
||||
* re-run — the first run's wiring still stands) → skip the wire and let the
|
||||
* SKILL's prose own the handoff. The name/role prompts are deferred until
|
||||
* after the skill run so the drop-through path asks nothing.
|
||||
* Skip the resolve+wire half. For a channel whose platform_id only exists
|
||||
* after the first inbound (Teams): the SKILL.md installs the adapter and ends
|
||||
* with an `nc:operator` handoff telling the operator to DM the bot and finish
|
||||
* wiring later. No owner_handle/platform_id is expected, no agent name/role is
|
||||
* asked, and init-first-agent is not run.
|
||||
*/
|
||||
wireIfResolved?: boolean;
|
||||
deferWire?: boolean;
|
||||
/**
|
||||
* Offer the "← Back to channel selection" gate as the very first prompt,
|
||||
* before any side effect (agent-name/role prompts, the skill run, the
|
||||
* wire). On back, returns the
|
||||
* before any side effect (agent-name/role prompts, the skill run, the wire —
|
||||
* and the deferWire/Teams path too). On back, returns the
|
||||
* `BACK_TO_CHANNEL_SELECTION` sentinel and does nothing else. Opt-in so
|
||||
* headless callers (and the existing tests) never see the extra prompt.
|
||||
*/
|
||||
@@ -101,7 +103,7 @@ export async function runChannelSkill(
|
||||
overrides: ChannelSkillOverrides = {},
|
||||
): Promise<ChannelFlowResult> {
|
||||
// First-prompt back gate — the very first thing, before any side effect
|
||||
// (agent-name/role prompts, the skill run, the wire).
|
||||
// (agent-name/role prompts, the skill run, the wire; covers deferWire too).
|
||||
// Opt-in via offerBack so headless callers + existing tests are unaffected.
|
||||
if (overrides.offerBack) {
|
||||
const label = channel.charAt(0).toUpperCase() + channel.slice(1);
|
||||
@@ -111,12 +113,9 @@ export async function runChannelSkill(
|
||||
|
||||
const projectRoot = overrides.projectRoot ?? process.cwd();
|
||||
const failWith = overrides.fail ?? fail;
|
||||
// The agent name + role are wire inputs — in wireIfResolved mode, defer the
|
||||
// prompts past the skill run (only a fresh create resolves the wire inputs;
|
||||
// a drop-through re-run asks nothing).
|
||||
const askLater = overrides.wireIfResolved;
|
||||
let agentName = askLater ? '' : overrides.agentName ?? (await resolveAgentName());
|
||||
let role = askLater ? undefined : overrides.role ?? (await askOperatorRole(channel));
|
||||
// The agent name + role are wire inputs — skip the prompts when the wire is deferred.
|
||||
const agentName = overrides.deferWire ? '' : overrides.agentName ?? (await resolveAgentName());
|
||||
const role = overrides.deferWire ? undefined : overrides.role ?? (await askOperatorRole(channel));
|
||||
|
||||
// Channel-specific: install adapter, collect credentials, resolve the wire
|
||||
// inputs. The whole channel-specific procedure lives in the SKILL.md.
|
||||
@@ -141,12 +140,21 @@ export async function runChannelSkill(
|
||||
});
|
||||
if (!fullyApplied(res)) {
|
||||
if (res.deferred.length) p.log.warn(`Still needs: ${res.deferred.join(', ')}`);
|
||||
for (const t of res.agentTasks) p.log.warn(`Needs an agent (${t.kind}): ${t.reason}`);
|
||||
// On a real bounce, also surface the skill's reference floor (Alternatives /
|
||||
// Optional configuration / Troubleshooting) — the same prose a human reader
|
||||
// would scroll to when a step doesn't apply cleanly. Only on a bounce
|
||||
// (agentTasks), and only when the skill actually ships a reference section.
|
||||
if (res.agentTasks.length && res.referenceProse) p.log.info(res.referenceProse);
|
||||
// A bounced reason can carry a full stderr dump (a Node stacktrace). The
|
||||
// terminal gets ONE line per bounce — the first line, which hostExec
|
||||
// composes as `exit <code>: <first stderr line>` — and the full text goes
|
||||
// to a raw step log, written only when there's actually more than one line
|
||||
// to keep (SSF-004; the reference prose is deliberately not dumped either).
|
||||
let rawLog: string | undefined;
|
||||
if (res.agentTasks.some((t) => t.reason.includes('\n'))) {
|
||||
rawLog = setupLog.stepRawLog(`${channel}-install-bounce`);
|
||||
writeFileSync(rawLog, res.agentTasks.map((t) => `## ${t.kind} (line ${t.line})\n${t.reason}\n`).join('\n'));
|
||||
}
|
||||
for (const t of res.agentTasks) {
|
||||
const lines = t.reason.split('\n').map((l) => l.trim()).filter(Boolean);
|
||||
const more = lines.length > 1 ? ` (+${lines.length - 1} more lines in ${rawLog})` : '';
|
||||
p.log.warn(`Needs an agent (${t.kind}): ${lines[0] ?? t.reason}${more}`);
|
||||
}
|
||||
// Surface the bounced step's OWN prose as the failure hint + Claude-handoff
|
||||
// context (fail() dims the hint and forwards it to offerClaudeOnFailure),
|
||||
// instead of a generic "couldn't finish" message. Only a real bounce yields a
|
||||
@@ -156,20 +164,18 @@ export async function runChannelSkill(
|
||||
`${channel}-install`,
|
||||
diag?.headline ?? `Couldn't finish setting up ${channel}.`,
|
||||
diag?.hint ?? 'See logs/setup-steps/ for details, then retry setup.',
|
||||
rawLog,
|
||||
);
|
||||
}
|
||||
|
||||
// Identity confirmation captured by the skill (e.g. add-slack's auth.test).
|
||||
if (res.vars.connected_as) p.log.success(`Connected to ${channel} as ${res.vars.connected_as}.`);
|
||||
|
||||
// Deferred wire (Teams): the SKILL's operator handoff owns the rest. Done here.
|
||||
if (overrides.deferWire) return;
|
||||
|
||||
const ownerHandle = res.vars.owner_handle;
|
||||
const platformId = res.vars.platform_id;
|
||||
if (overrides.wireIfResolved && (!ownerHandle || !platformId)) {
|
||||
// Drop-through re-run: the guarded resolve steps were skipped, so there is
|
||||
// nothing new to wire — the first run's wiring still stands (verify's
|
||||
// pending path covers a truly unwired install).
|
||||
return;
|
||||
}
|
||||
if (!ownerHandle || !platformId) {
|
||||
await failWith(
|
||||
`${channel}-resolve`,
|
||||
@@ -177,13 +183,9 @@ export async function runChannelSkill(
|
||||
'The skill did not produce owner_handle + platform_id.',
|
||||
);
|
||||
}
|
||||
if (overrides.wireIfResolved) {
|
||||
agentName = overrides.agentName ?? (await resolveAgentName());
|
||||
role = overrides.role ?? (await askOperatorRole(channel));
|
||||
}
|
||||
|
||||
// Shared wire — the same procedure for every channel. role is defined here:
|
||||
// it's only undefined in an unresolved wireIfResolved run (returned above).
|
||||
// it's only undefined in deferWire mode, which returned above.
|
||||
const wire = overrides.wire ?? initFirstAgent;
|
||||
const ok = await wire({ channel, userId: `${channel}:${ownerHandle}`, platformId, displayName, agentName, role: role! });
|
||||
if (!ok) {
|
||||
|
||||
@@ -5,17 +5,14 @@
|
||||
* 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.
|
||||
*
|
||||
* The short name comes from NANOCLAW_AGENT_NAME (falling back to "NanoClaw"), the
|
||||
* Mirrors the bespoke `stepGenerateManifest` in the old setup/channels/teams.ts:
|
||||
* the short name comes from NANOCLAW_AGENT_NAME (falling back to "NanoClaw"), the
|
||||
* description is "<name> personal assistant powered by NanoClaw.", the website
|
||||
* URL is the operator's public base URL, and the output lands in data/teams/.
|
||||
*
|
||||
* `--rsc` adds the resource-specific-consent permissions (receive all channel /
|
||||
* group-chat messages without @-mention) and bumps the manifest version so the
|
||||
* re-upload supersedes the original package.
|
||||
*
|
||||
* Usage:
|
||||
* pnpm exec tsx setup/channels/teams-manifest-build.ts \
|
||||
* --app-id <azure-app-id> --url https://your-domain [--out data/teams] [--rsc]
|
||||
* --app-id <azure-app-id> --url https://your-domain [--out data/teams]
|
||||
*/
|
||||
import { buildTeamsAppPackage } from '../lib/teams-manifest.js';
|
||||
|
||||
@@ -27,12 +24,11 @@ function flag(name: string): string | undefined {
|
||||
const appId = flag('app-id');
|
||||
const url = flag('url');
|
||||
const outDir = flag('out') ?? 'data/teams';
|
||||
const rsc = process.argv.includes('--rsc');
|
||||
const shortName = process.env.NANOCLAW_AGENT_NAME?.trim() || 'NanoClaw';
|
||||
|
||||
if (!appId || !url) {
|
||||
console.error(
|
||||
'usage: teams-manifest-build.ts --app-id <azure-app-id> --url <https-url> [--out <dir>] [--rsc]',
|
||||
'usage: teams-manifest-build.ts --app-id <azure-app-id> --url <https-url> [--out <dir>]',
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
@@ -43,7 +39,6 @@ const result = buildTeamsAppPackage({
|
||||
longDescription: `${shortName} personal assistant powered by NanoClaw.`,
|
||||
websiteUrl: url,
|
||||
outDir,
|
||||
rsc,
|
||||
});
|
||||
|
||||
console.log(`Teams app package: ${result.zipPath}`);
|
||||
|
||||
@@ -73,8 +73,8 @@ export const STEP_FILES: Record<string, string[]> = {
|
||||
'slack-validate': ['setup/channels/slack.ts'],
|
||||
'imessage-install': ['.claude/skills/add-imessage/SKILL.md', 'scripts/skill-apply.ts', 'setup/channels/imessage.ts'],
|
||||
'imessage': ['setup/channels/imessage.ts'],
|
||||
'teams-install': ['.claude/skills/add-teams/SKILL.md', 'scripts/skill-apply.ts', 'setup/channels/run-channel-skill.ts'],
|
||||
'teams-manifest': ['setup/lib/teams-manifest.ts', 'setup/channels/teams-manifest-build.ts'],
|
||||
'teams-install': ['.claude/skills/add-teams/SKILL.md', 'scripts/skill-apply.ts', 'setup/channels/teams.ts'],
|
||||
'teams-manifest': ['setup/lib/teams-manifest.ts', 'setup/channels/teams.ts'],
|
||||
'init-first-agent': [
|
||||
'scripts/init-first-agent.ts',
|
||||
'setup/channels/telegram.ts',
|
||||
|
||||
@@ -3,7 +3,7 @@ import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, chmodSync } from '
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { runSkill, hostExec, hostExecStream, labelOrdinals, promptValidator, clackResolveInput, type RunSkillOptions } from './skill-driver.js';
|
||||
import { runSkill, hostExec, hostExecStream, labelOrdinals, literalChoices, promptValidator, clackResolveInput, type RunSkillOptions } from './skill-driver.js';
|
||||
import { fullyApplied, type ApplyEvent } from '../../scripts/skill-apply.js';
|
||||
|
||||
// Shared test state for the clack + claude-handoff mocks (hoisted so the vi.mock
|
||||
@@ -15,6 +15,7 @@ const ce = vi.hoisted(() => ({
|
||||
handoffSpy: vi.fn(async (_ctx: { channel: string; step: string; stepDescription: string }) => true),
|
||||
answers: [] as string[],
|
||||
lastValidate: { fn: undefined as undefined | ((v: string) => string | Error | void | undefined) },
|
||||
lastSelectOptions: { values: undefined as undefined | string[] },
|
||||
}));
|
||||
|
||||
// Keep isHelpEscape + validateWithHelpEscape real (clackResolveInput uses them);
|
||||
@@ -32,7 +33,11 @@ vi.mock('@clack/prompts', async (importActual) => {
|
||||
ce.lastValidate.fn = o?.validate;
|
||||
return ce.answers.shift() ?? '';
|
||||
};
|
||||
return { ...actual, text: vi.fn(fromQueue), password: vi.fn(fromQueue) };
|
||||
const fromQueueSelect = async (o: { options: Array<{ value: string }> }): Promise<string> => {
|
||||
ce.lastSelectOptions.values = o.options.map((x) => x.value);
|
||||
return ce.answers.shift() ?? o.options[0].value;
|
||||
};
|
||||
return { ...actual, text: vi.fn(fromQueue), password: vi.fn(fromQueue), select: vi.fn(fromQueueSelect) };
|
||||
});
|
||||
|
||||
// A small SKILL.md exercising the three things the driver wires: an operator
|
||||
@@ -113,18 +118,39 @@ describe('thin skill driver', () => {
|
||||
expect(starts).toEqual([{ kind: 'run', label: 'Wire' }]);
|
||||
});
|
||||
|
||||
it('hostExec puts the project bin/ on PATH so a bare command resolves to it', () => {
|
||||
it('hostExec puts the project bin/ on PATH so a bare command resolves to it', async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'driver-bin-'));
|
||||
mkdirSync(join(root, 'bin'));
|
||||
writeFileSync(join(root, 'bin/greet'), '#!/usr/bin/env bash\necho hi-from-bin\n');
|
||||
chmodSync(join(root, 'bin/greet'), 0o755);
|
||||
const out = hostExec(root)('greet'); // bare name, not ./bin/greet
|
||||
const out = await hostExec(root)('greet'); // bare name, not ./bin/greet
|
||||
expect(String(out).trim()).toBe('hi-from-bin');
|
||||
});
|
||||
|
||||
it('hostExec returns stdout so a capture run can bind it', () => {
|
||||
it('hostExec returns stdout so a capture run can bind it', async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'driver-cap-'));
|
||||
expect(String(hostExec(root)('echo D0CHANNEL')).trim()).toBe('D0CHANNEL');
|
||||
expect(String(await hostExec(root)('echo D0CHANNEL')).trim()).toBe('D0CHANNEL');
|
||||
});
|
||||
|
||||
it('hostExec recomposes a failure as `exit <code>: <first stderr line>` with the full stderr kept below', async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'driver-fail-'));
|
||||
const run = (): Promise<string> => hostExec(root)('echo "boom: first line" >&2; echo "stack line two" >&2; exit 7');
|
||||
await expect(run).rejects.toThrow(/^exit 7: boom: first line/); // one-line consumers read this
|
||||
await expect(run).rejects.toThrow(/stack line two/); // full stderr survives for the agentTask reason
|
||||
});
|
||||
|
||||
it('hostExec tees each command + stdout/stderr to the raw log, success and failure alike', async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'driver-tee-'));
|
||||
const rawLog = join(root, 'raw.log');
|
||||
const exec = hostExec(root, rawLog);
|
||||
await exec('echo out-line; echo warn-line >&2');
|
||||
await expect(exec('echo dying-gasp >&2; exit 3')).rejects.toThrow(/exit 3/);
|
||||
const log = readFileSync(rawLog, 'utf8');
|
||||
expect(log).toContain('$ echo out-line; echo warn-line >&2');
|
||||
expect(log).toContain('out-line');
|
||||
expect(log).toContain('warn-line'); // stderr captured, not echoed to the wizard
|
||||
expect(log).toContain('$ echo dying-gasp >&2; exit 3');
|
||||
expect(log).toContain('dying-gasp'); // the failing command's output survives too
|
||||
});
|
||||
|
||||
it('hostExecStream runs a step and captures the terminal status block fields (for effect:step)', async () => {
|
||||
@@ -136,6 +162,20 @@ describe('thin skill driver', () => {
|
||||
expect(out.fields.PLATFORM_ID).toBe('telegram:42');
|
||||
});
|
||||
|
||||
it('hostExecStream children run with LOG_LEVEL=warn — host logger info noise stays off the wizard', async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'driver-loglevel-'));
|
||||
const prev = process.env.LOG_LEVEL;
|
||||
delete process.env.LOG_LEVEL; // simulate an operator who didn't set one
|
||||
try {
|
||||
const out = await hostExecStream(root)(
|
||||
'echo "=== NANOCLAW SETUP: ENV ==="; echo "STATUS: success"; echo "LVL: $LOG_LEVEL"; echo "=== END ==="',
|
||||
);
|
||||
expect(out.fields.LVL).toBe('warn');
|
||||
} finally {
|
||||
if (prev !== undefined) process.env.LOG_LEVEL = prev;
|
||||
}
|
||||
});
|
||||
|
||||
function reuseScratch(): { root: string; skill: string } {
|
||||
const root = mkdtempSync(join(tmpdir(), 'reuse-'));
|
||||
const skill = mkdtempSync(join(tmpdir(), 'reuse-skill-'));
|
||||
@@ -379,6 +419,18 @@ describe('thin skill driver', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('clackResolveInput renders an either/or validate as an arrow-key select over the literal choices', async () => {
|
||||
ce.answers = ['webhook'];
|
||||
ce.lastSelectOptions.values = undefined;
|
||||
const ans = await clackResolveInput()('connection', {
|
||||
question: 'How should Slack deliver events?',
|
||||
secret: false,
|
||||
validate: '^(socket|webhook)$',
|
||||
});
|
||||
expect(ans).toBe('webhook');
|
||||
expect(ce.lastSelectOptions.values).toEqual(['socket', 'webhook']); // the options came from the regex
|
||||
});
|
||||
|
||||
it('clackResolveInput passes a normal answer straight through — no handoff', async () => {
|
||||
ce.handoffSpy.mockClear();
|
||||
ce.answers = ['just-a-token'];
|
||||
@@ -402,6 +454,27 @@ describe('thin skill driver', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// An either/or `nc:prompt` renders as a select — the choices come from the
|
||||
// validate regex itself (no grammar addition). Only a fully-anchored
|
||||
// pure-literal alternation qualifies; anything with real regex syntax stays a
|
||||
// text prompt (SSF-003).
|
||||
describe('literalChoices (either/or prompt → select)', () => {
|
||||
it('extracts the choices from a pure-literal alternation', () => {
|
||||
expect(literalChoices('^(socket|webhook)$')).toEqual(['socket', 'webhook']);
|
||||
expect(literalChoices('^(qr|pairing-code)$')).toEqual(['qr', 'pairing-code']);
|
||||
expect(literalChoices('^(SingleTenant|MultiTenant)$')).toEqual(['SingleTenant', 'MultiTenant']);
|
||||
});
|
||||
|
||||
it('leaves prefixes, format unions, and non-alternations as text prompts', () => {
|
||||
expect(literalChoices('^xoxb-')).toBeNull(); // unanchored prefix
|
||||
expect(literalChoices('^https?://')).toBeNull(); // regex metachars
|
||||
expect(literalChoices('^(\\+\\d{8,15}|[^\\s@]+@[^\\s@]+\\.[^\\s@]+)$')).toBeNull(); // imessage phone|email union
|
||||
expect(literalChoices('^[0-9a-zA-Z-]+$')).toBeNull(); // char class, no alternation
|
||||
expect(literalChoices('^(solo)$')).toBeNull(); // one option is not a choice
|
||||
expect(literalChoices(undefined)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// Two steps under one heading share a spinner caption (build + test both read
|
||||
// "Build and validate") — the ordinal suffix marks them as a sequence, not a
|
||||
// stuttered duplicate. Solo captions stay unsuffixed.
|
||||
|
||||
+91
-18
@@ -13,8 +13,8 @@
|
||||
* the URL offer, the prose-derived validation message.
|
||||
*/
|
||||
import { execSync, spawn } from 'node:child_process';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { appendFileSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { basename, join } from 'node:path';
|
||||
|
||||
import * as p from '@clack/prompts';
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
} from '../../scripts/skill-apply.js';
|
||||
import { parseDirectives, promptVar } from '../../scripts/skill-directives.js';
|
||||
import { extractOfferUrl, gatePolicy } from '../../scripts/skill-policy.js';
|
||||
import * as setupLog from '../logs.js';
|
||||
import { isHeadless } from '../platform.js';
|
||||
import { openUrl } from './browser.js';
|
||||
import { isHelpEscape, offerClaudeHandoff, validateWithHelpEscape } from './claude-handoff.js';
|
||||
@@ -56,6 +57,19 @@ export function promptValidator(
|
||||
return (v) => (re.test((v ?? '').trim()) ? undefined : `That doesn't match the expected format. ${question}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* The literal alternatives of a fully-anchored pure-literal alternation —
|
||||
* `^(socket|webhook)$` → ['socket', 'webhook'] — or null for anything else
|
||||
* (an unanchored prefix like `^xoxb-`, a format union with real regex syntax
|
||||
* like imessage's `^(\+\d{8,15}|…)$`). This is what lets an either/or
|
||||
* `nc:prompt` render as an arrow-key select with no grammar addition: the
|
||||
* validate regex already enumerates the choices. Exported for tests.
|
||||
*/
|
||||
export function literalChoices(validate: string | undefined): string[] | null {
|
||||
const m = validate?.match(/^\^\(([A-Za-z0-9_-]+(?:\|[A-Za-z0-9_-]+)+)\)\$$/);
|
||||
return m ? m[1].split('|') : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handoff context for the `?` help-escape (Step 8 / mechanism M3). A lone `?` at
|
||||
* any prompt hands the operator to interactive Claude with this context, then
|
||||
@@ -73,9 +87,10 @@ export interface PrompterContext {
|
||||
|
||||
/**
|
||||
* The wizard's `resolveInput` implementation: collect an `nc:prompt` through
|
||||
* clack (password for secrets, text otherwise; a cancel defers), running the
|
||||
* interactive re-ask loop against the prompt's declared `validate:`/`flags:`
|
||||
* (the engine's validate-at-bind is the programmatic backstop, not the UX).
|
||||
* clack (password for secrets, an arrow-key select for an either/or validate
|
||||
* regex, text otherwise; a cancel defers), running the interactive re-ask loop
|
||||
* against the prompt's declared `validate:`/`flags:` (the engine's
|
||||
* validate-at-bind is the programmatic backstop, not the UX).
|
||||
*/
|
||||
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
|
||||
@@ -90,9 +105,15 @@ export function clackResolveInput(ctx: PrompterContext = {}): (name: string, met
|
||||
const guarded = validateWithHelpEscape(check);
|
||||
// clearOnError wipes a rejected secret so the operator re-pastes cleanly
|
||||
// (a half-pasted token isn't left masked in the field).
|
||||
const ans = meta.secret
|
||||
? await p.password({ message: meta.question, validate: guarded, clearOnError: true })
|
||||
: await p.text({ message: meta.question, validate: guarded });
|
||||
// An either/or prompt renders as an arrow-key select — the options come
|
||||
// straight from the validate regex (literalChoices). No re-ask loop and no
|
||||
// `?` help-escape there: every choice is valid and self-describing.
|
||||
const choices = meta.secret ? null : literalChoices(meta.validate);
|
||||
const ans = choices
|
||||
? await p.select({ message: meta.question, options: choices.map((c) => ({ value: c, label: c })) })
|
||||
: meta.secret
|
||||
? await p.password({ message: meta.question, validate: guarded, clearOnError: true })
|
||||
: await p.text({ message: meta.question, validate: guarded });
|
||||
if (p.isCancel(ans)) return undefined; // cancelled ⇒ defer
|
||||
if (isHelpEscape(ans) && process.stdout.isTTY) {
|
||||
// Operator asked for help: hand off to interactive Claude with this
|
||||
@@ -224,14 +245,45 @@ async function reuseFromEnv(
|
||||
* `run capture:<var>` can bind it. Puts the project's `bin/` on PATH so a bare
|
||||
* `ncl …` in a wire directive resolves to `bin/ncl` even when it isn't
|
||||
* symlinked onto the operator's PATH.
|
||||
*
|
||||
* Async (spawn, not execSync) so the step spinner keeps animating: a sync exec
|
||||
* blocks the event loop for the whole command and freezes every ticker in the
|
||||
* process. A failure rejects with the FIRST line as the actionable summary —
|
||||
* `exit <code>: <first stderr line>` — and the full stderr kept below, so
|
||||
* one-line consumers (run-channel-skill's bounce warn) stay readable while the
|
||||
* agentTask reason an agent fixes from still carries everything.
|
||||
*
|
||||
* Non-step effects are captured-output steps — the spinner is the only UI, and
|
||||
* stderr is piped, never echoed (a chatty tool's warnings don't belong on the
|
||||
* wizard screen). When `rawLog` is given, every command's stdout+stderr is
|
||||
* appended there (level 3, like runner.ts's per-step raw logs) so the silenced
|
||||
* noise stays inspectable.
|
||||
*/
|
||||
export function hostExec(projectRoot: string): (cmd: string) => string {
|
||||
export function hostExec(projectRoot: string, rawLog?: string): (cmd: string) => Promise<string> {
|
||||
const tee = (cmd: string, stdout: string, stderr: string): void => {
|
||||
if (!rawLog) return;
|
||||
const body = [stdout, stderr].filter(Boolean).join('');
|
||||
appendFileSync(rawLog, `$ ${cmd}\n${body}${body && !body.endsWith('\n') ? '\n' : ''}\n`);
|
||||
};
|
||||
return (cmd) =>
|
||||
execSync(cmd, {
|
||||
cwd: projectRoot,
|
||||
shell: '/bin/bash',
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, PATH: `${join(projectRoot, 'bin')}:${process.env.PATH ?? ''}` },
|
||||
new Promise((resolve, reject) => {
|
||||
const child = spawn('bash', ['-c', cmd], {
|
||||
cwd: projectRoot,
|
||||
env: { ...process.env, PATH: `${join(projectRoot, 'bin')}:${process.env.PATH ?? ''}` },
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
let out = '';
|
||||
let err = '';
|
||||
child.stdout.on('data', (c: Buffer) => { out += c.toString('utf8'); });
|
||||
child.stderr.on('data', (c: Buffer) => { err += c.toString('utf8'); });
|
||||
child.on('error', reject);
|
||||
child.on('close', (code) => {
|
||||
tee(cmd, out, err);
|
||||
if (code === 0) return resolve(out);
|
||||
const stderr = err.trim();
|
||||
const head = stderr.split('\n').map((l) => l.trim()).find(Boolean) ?? 'command failed';
|
||||
reject(new Error(`exit ${code ?? '?'}: ${head}${stderr ? `\n${stderr}` : ''}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -248,7 +300,20 @@ export function hostExecStream(projectRoot: string): (cmd: string) => Promise<St
|
||||
new Promise((resolve) => {
|
||||
const child = spawn('bash', ['-c', cmd], {
|
||||
cwd: projectRoot,
|
||||
env: { ...process.env, PATH: `${join(projectRoot, 'bin')}:${process.env.PATH ?? ''}` },
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: `${join(projectRoot, 'bin')}:${process.env.PATH ?? ''}`,
|
||||
// A step renders curated operator UI (a code card, a QR) — the host
|
||||
// logger's info noise doesn't belong on the wizard screen, and it
|
||||
// always emits ANSI so it can't be filtered by stream. Warnings and
|
||||
// errors still pass. An operator-set LOG_LEVEL wins (debugging).
|
||||
LOG_LEVEL: process.env.LOG_LEVEL ?? 'warn',
|
||||
// The child's stdout is a pipe, so picocolors would strip its clack
|
||||
// rendering to bare box chars that clash with the wizard theme.
|
||||
// When the OPERATOR's terminal is a real TTY, the teed lines land
|
||||
// there — force color so the child's card matches the parent.
|
||||
...(process.stdout.isTTY ? { FORCE_COLOR: '1' } : {}),
|
||||
},
|
||||
stdio: ['inherit', 'pipe', 'pipe'],
|
||||
});
|
||||
const blocks: Array<{ fields: Record<string, string> }> = [];
|
||||
@@ -351,7 +416,7 @@ function defaultOnEvent(
|
||||
return;
|
||||
}
|
||||
// operator: note → URL offer → natural-barrier confirm.
|
||||
p.note(e.text, 'Do this');
|
||||
p.note(e.text, 'Your turn');
|
||||
const url = extractOfferUrl(e.text);
|
||||
if (url !== undefined && (await confirm(`Open ${url} in your browser?`))) await open(url);
|
||||
const gate = gates.get(e.line);
|
||||
@@ -382,7 +447,7 @@ export interface RunSkillOptions {
|
||||
*/
|
||||
resolveInput?: (name: string, meta: InputMeta) => Promise<string | undefined>;
|
||||
/** Defaults to `hostExec`. */
|
||||
exec?: (cmd: string) => string | void;
|
||||
exec?: (cmd: string) => string | void | Promise<string | void>;
|
||||
/** Defaults to `hostExecStream`. Streaming exec for `nc:run effect:step`. */
|
||||
execStream?: (cmd: string) => Promise<StepOutcome>;
|
||||
/** Defaults to the fork-aware channels-branch resolver. */
|
||||
@@ -445,11 +510,19 @@ export async function runSkill(skillDir: string, opts: RunSkillOptions = {}): Pr
|
||||
} catch {
|
||||
// missing SKILL.md — the engine will produce an empty result anyway
|
||||
}
|
||||
// One raw log per skill apply (level 3): every default-exec command appends
|
||||
// its `$ cmd` + output there. Allocated only when the default exec is used —
|
||||
// an injected exec (tests, agent relay) owns its own capture.
|
||||
let rawLog: string | undefined;
|
||||
if (!opts.exec) {
|
||||
rawLog = setupLog.stepRawLog(`skill-${basename(skillDir)}`);
|
||||
writeFileSync(rawLog, `# skill ${basename(skillDir)} — ${new Date().toISOString()}\n\n`);
|
||||
}
|
||||
return applySkill(skillDir, projectRoot, {
|
||||
inputs,
|
||||
resolveInput: opts.resolveInput ?? clackResolveInput({ channel: opts.channel, step: opts.step }),
|
||||
onEvent: opts.onEvent ?? defaultOnEvent(md, confirm, open),
|
||||
exec: opts.exec ?? hostExec(projectRoot),
|
||||
exec: opts.exec ?? hostExec(projectRoot, rawLog),
|
||||
execStream: opts.execStream ?? hostExecStream(projectRoot),
|
||||
resolveRemote: opts.resolveRemote ?? channelsRemote(projectRoot),
|
||||
skipEffects: opts.skipEffects,
|
||||
|
||||
@@ -1,150 +0,0 @@
|
||||
/**
|
||||
* 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' });
|
||||
});
|
||||
});
|
||||
+219
-73
@@ -7,19 +7,23 @@
|
||||
* - outline.png — 32×32 transparent outline icon
|
||||
* - color.png — 192×192 full-color icon
|
||||
*
|
||||
* The static parts live in setup/assets/teams/ — manifest.template.json
|
||||
* (pinned to schema v1.16 to match the skill doc) plus the two icons — so
|
||||
* the manifest is reviewable as plain JSON. This module only fills in the
|
||||
* per-install fields (app id, name, domain, optional RSC block) and zips the
|
||||
* three files in-process via ./zip.ts, so no `zip` binary is needed on the
|
||||
* host.
|
||||
* Icons are generated in-process using a minimal PNG encoder so we don't
|
||||
* need ImageMagick or vendor binary icon blobs into the repo. The outline
|
||||
* icon is a simple rounded square outline; the color icon is a brand-blue
|
||||
* filled square with a small white "N" blocked in by pixel setting. Good
|
||||
* enough for a working sideload — teams admins who care can replace the
|
||||
* icons later.
|
||||
*
|
||||
* The manifest is pinned to schema v1.16 to match the skill doc.
|
||||
*/
|
||||
import { execSync } from 'child_process';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import zlib from 'zlib';
|
||||
|
||||
import { buildZip } from './zip.js';
|
||||
|
||||
const ASSETS_DIR = new URL('../assets/teams/', import.meta.url);
|
||||
const MANIFEST_SCHEMA =
|
||||
'https://developer.microsoft.com/en-us/json-schemas/teams/v1.16/MicrosoftTeams.schema.json';
|
||||
const MANIFEST_VERSION = '1.16';
|
||||
|
||||
export interface ManifestOptions {
|
||||
/** The Azure AD app ID (same value used for `bots[0].botId`). */
|
||||
@@ -32,11 +36,6 @@ export interface ManifestOptions {
|
||||
websiteUrl: string;
|
||||
/** Out-dir for the generated zip + loose files. */
|
||||
outDir: string;
|
||||
/**
|
||||
* Include RSC permissions (ChannelMessage.Read.Group, ChatMessage.Read.Chat)
|
||||
* so the bot receives all channel/group-chat messages without @-mention.
|
||||
*/
|
||||
rsc?: boolean;
|
||||
}
|
||||
|
||||
export interface ManifestResult {
|
||||
@@ -46,19 +45,6 @@ export interface ManifestResult {
|
||||
colorPath: string;
|
||||
}
|
||||
|
||||
/** The manifest.template.json fields this module rewrites per install. */
|
||||
interface ManifestTemplate {
|
||||
version: string;
|
||||
id: string;
|
||||
developer: { name: string; websiteUrl: string; privacyUrl: string; termsOfUseUrl: string };
|
||||
name: { short: string; full: string };
|
||||
description: { short: string; full: string };
|
||||
bots: [{ botId: string }];
|
||||
validDomains: string[];
|
||||
webApplicationInfo?: { id: string; resource: string };
|
||||
authorization?: { permissions: { resourceSpecific: Array<{ name: string; type: string }> } };
|
||||
}
|
||||
|
||||
/** Build the full app package zip and return the paths. */
|
||||
export function buildTeamsAppPackage(opts: ManifestOptions): ManifestResult {
|
||||
fs.mkdirSync(opts.outDir, { recursive: true });
|
||||
@@ -68,58 +54,218 @@ export function buildTeamsAppPackage(opts: ManifestOptions): ManifestResult {
|
||||
const colorPath = path.join(opts.outDir, 'color.png');
|
||||
const zipPath = path.join(opts.outDir, 'teams-app-package.zip');
|
||||
|
||||
const manifest = Buffer.from(renderManifest(opts));
|
||||
const outline = fs.readFileSync(new URL('outline.png', ASSETS_DIR));
|
||||
const color = fs.readFileSync(new URL('color.png', ASSETS_DIR));
|
||||
fs.writeFileSync(manifestPath, renderManifest(opts));
|
||||
fs.writeFileSync(outlinePath, encodeOutlineIcon());
|
||||
fs.writeFileSync(colorPath, encodeColorIcon());
|
||||
|
||||
fs.writeFileSync(manifestPath, manifest);
|
||||
fs.writeFileSync(outlinePath, outline);
|
||||
fs.writeFileSync(colorPath, color);
|
||||
fs.writeFileSync(
|
||||
zipPath,
|
||||
buildZip([
|
||||
{ name: 'manifest.json', data: manifest },
|
||||
{ name: 'outline.png', data: outline },
|
||||
{ name: 'color.png', data: color },
|
||||
]),
|
||||
);
|
||||
// Fresh zip every run — idempotent, no stale files.
|
||||
try {
|
||||
fs.unlinkSync(zipPath);
|
||||
} catch {
|
||||
// noop if missing
|
||||
}
|
||||
execSync(`zip -j -q "${zipPath}" "${manifestPath}" "${outlinePath}" "${colorPath}"`, {
|
||||
stdio: ['ignore', 'ignore', 'inherit'],
|
||||
});
|
||||
|
||||
return { zipPath, manifestPath, outlinePath, colorPath };
|
||||
}
|
||||
|
||||
function renderManifest(opts: ManifestOptions): string {
|
||||
const manifest = JSON.parse(
|
||||
fs.readFileSync(new URL('manifest.template.json', ASSETS_DIR), 'utf8'),
|
||||
) as ManifestTemplate;
|
||||
|
||||
manifest.id = opts.appId;
|
||||
manifest.bots[0].botId = opts.appId;
|
||||
manifest.name.short = opts.shortName.slice(0, 30);
|
||||
manifest.name.full = `${opts.shortName} Assistant`;
|
||||
manifest.description.full = opts.longDescription;
|
||||
manifest.developer.websiteUrl = opts.websiteUrl;
|
||||
manifest.developer.privacyUrl = opts.websiteUrl;
|
||||
manifest.developer.termsOfUseUrl = opts.websiteUrl;
|
||||
manifest.validDomains = [new URL(opts.websiteUrl).host];
|
||||
|
||||
if (opts.rsc) {
|
||||
// Teams app-update flows want a higher version than the already-uploaded
|
||||
// package, so the RSC variant (typically a re-upload) bumps it.
|
||||
manifest.version = '1.1.0';
|
||||
// RSC grants bind to webApplicationInfo.id, not bots[].botId — without
|
||||
// this block the permissions are never attached to the app and the bot
|
||||
// silently keeps requiring @-mention. `resource` must be non-empty but
|
||||
// its value is unused for RSC-only apps.
|
||||
manifest.webApplicationInfo = { id: opts.appId, resource: 'https://notapplicable' };
|
||||
manifest.authorization = {
|
||||
permissions: {
|
||||
resourceSpecific: [
|
||||
{ name: 'ChannelMessage.Read.Group', type: 'Application' },
|
||||
{ name: 'ChatMessage.Read.Chat', type: 'Application' },
|
||||
],
|
||||
const manifest = {
|
||||
$schema: MANIFEST_SCHEMA,
|
||||
manifestVersion: MANIFEST_VERSION,
|
||||
version: '1.0.0',
|
||||
id: opts.appId,
|
||||
packageName: 'com.nanoclaw.bot',
|
||||
developer: {
|
||||
name: 'NanoClaw',
|
||||
websiteUrl: opts.websiteUrl,
|
||||
privacyUrl: opts.websiteUrl,
|
||||
termsOfUseUrl: opts.websiteUrl,
|
||||
},
|
||||
name: {
|
||||
short: opts.shortName.slice(0, 30),
|
||||
full: `${opts.shortName} Assistant`,
|
||||
},
|
||||
description: {
|
||||
short: 'Your personal assistant in Teams.',
|
||||
full: opts.longDescription,
|
||||
},
|
||||
icons: { outline: 'outline.png', color: 'color.png' },
|
||||
accentColor: '#4A90D9',
|
||||
bots: [
|
||||
{
|
||||
botId: opts.appId,
|
||||
scopes: ['personal', 'team', 'groupchat'],
|
||||
supportsFiles: false,
|
||||
isNotificationOnly: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
],
|
||||
permissions: ['identity', 'messageTeamMembers'],
|
||||
validDomains: [new URL(opts.websiteUrl).host],
|
||||
};
|
||||
return JSON.stringify(manifest, null, 2) + '\n';
|
||||
}
|
||||
|
||||
// ─── Minimal PNG encoder (solid color, no external deps) ──────────────────
|
||||
|
||||
const PNG_SIG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
|
||||
// Precompute the CRC-32 table per the PNG spec. Node doesn't expose CRC32
|
||||
// directly (zlib.crc32 isn't part of the public API), so we roll our own.
|
||||
const CRC_TABLE = (() => {
|
||||
const table = new Uint32Array(256);
|
||||
for (let n = 0; n < 256; n++) {
|
||||
let c = n;
|
||||
for (let k = 0; k < 8; k++) {
|
||||
c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
||||
}
|
||||
table[n] = c >>> 0;
|
||||
}
|
||||
return table;
|
||||
})();
|
||||
|
||||
function crc32(buf: Buffer): number {
|
||||
let c = 0xffffffff;
|
||||
for (let i = 0; i < buf.length; i++) {
|
||||
c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
|
||||
}
|
||||
return (c ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
|
||||
function chunk(type: string, data: Buffer): Buffer {
|
||||
const len = Buffer.alloc(4);
|
||||
len.writeUInt32BE(data.length, 0);
|
||||
const typeBuf = Buffer.from(type, 'ascii');
|
||||
const crcBuf = Buffer.alloc(4);
|
||||
crcBuf.writeUInt32BE(crc32(Buffer.concat([typeBuf, data])), 0);
|
||||
return Buffer.concat([len, typeBuf, data, crcBuf]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode a solid-color RGBA image as a PNG. `pixels` is a width*height*4
|
||||
* byte array (R, G, B, A per pixel, row-major, top-to-bottom).
|
||||
*/
|
||||
function encodePng(width: number, height: number, pixels: Uint8Array): Buffer {
|
||||
// IHDR
|
||||
const ihdr = Buffer.alloc(13);
|
||||
ihdr.writeUInt32BE(width, 0);
|
||||
ihdr.writeUInt32BE(height, 4);
|
||||
ihdr[8] = 8; // bit depth
|
||||
ihdr[9] = 6; // color type: RGBA
|
||||
ihdr[10] = 0; // compression
|
||||
ihdr[11] = 0; // filter
|
||||
ihdr[12] = 0; // interlace
|
||||
|
||||
// IDAT: scanlines with filter byte 0 (None) prepended per row.
|
||||
const rowBytes = width * 4;
|
||||
const raw = Buffer.alloc(height * (rowBytes + 1));
|
||||
for (let y = 0; y < height; y++) {
|
||||
raw[y * (rowBytes + 1)] = 0;
|
||||
for (let x = 0; x < rowBytes; x++) {
|
||||
raw[y * (rowBytes + 1) + 1 + x] = pixels[y * rowBytes + x];
|
||||
}
|
||||
}
|
||||
const idat = zlib.deflateSync(raw);
|
||||
|
||||
return Buffer.concat([
|
||||
PNG_SIG,
|
||||
chunk('IHDR', ihdr),
|
||||
chunk('IDAT', idat),
|
||||
chunk('IEND', Buffer.alloc(0)),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Outline icon: 32×32 transparent background with a simple white rounded-
|
||||
* square outline. Teams renders it against a colored background so the
|
||||
* outline needs to be visible on both light and dark.
|
||||
*/
|
||||
function encodeOutlineIcon(): Buffer {
|
||||
const size = 32;
|
||||
const pixels = new Uint8Array(size * size * 4);
|
||||
const inset = 4;
|
||||
const stroke = 2;
|
||||
for (let y = 0; y < size; y++) {
|
||||
for (let x = 0; x < size; x++) {
|
||||
const onBorder =
|
||||
((x >= inset && x < inset + stroke) || (x >= size - inset - stroke && x < size - inset)) &&
|
||||
y >= inset &&
|
||||
y < size - inset;
|
||||
const onTopBot =
|
||||
((y >= inset && y < inset + stroke) || (y >= size - inset - stroke && y < size - inset)) &&
|
||||
x >= inset &&
|
||||
x < size - inset;
|
||||
const i = (y * size + x) * 4;
|
||||
if (onBorder || onTopBot) {
|
||||
pixels[i] = 255;
|
||||
pixels[i + 1] = 255;
|
||||
pixels[i + 2] = 255;
|
||||
pixels[i + 3] = 255;
|
||||
} else {
|
||||
pixels[i] = 0;
|
||||
pixels[i + 1] = 0;
|
||||
pixels[i + 2] = 0;
|
||||
pixels[i + 3] = 0; // transparent
|
||||
}
|
||||
}
|
||||
}
|
||||
return encodePng(size, size, pixels);
|
||||
}
|
||||
|
||||
/**
|
||||
* Color icon: 192×192 brand-blue filled square with a white "N" shape drawn
|
||||
* with simple bars (left vertical, right vertical, diagonal from top-right
|
||||
* to bottom-left). Crude but recognizable at a glance.
|
||||
*/
|
||||
function encodeColorIcon(): Buffer {
|
||||
const size = 192;
|
||||
const pixels = new Uint8Array(size * size * 4);
|
||||
// Brand blue #4A90D9
|
||||
const BG_R = 0x4a;
|
||||
const BG_G = 0x90;
|
||||
const BG_B = 0xd9;
|
||||
const thickness = 24;
|
||||
const margin = 40;
|
||||
const leftBarX = margin;
|
||||
const rightBarX = size - margin - thickness;
|
||||
for (let y = 0; y < size; y++) {
|
||||
for (let x = 0; x < size; x++) {
|
||||
const i = (y * size + x) * 4;
|
||||
pixels[i] = BG_R;
|
||||
pixels[i + 1] = BG_G;
|
||||
pixels[i + 2] = BG_B;
|
||||
pixels[i + 3] = 255;
|
||||
}
|
||||
}
|
||||
// Vertical bars
|
||||
for (let y = margin; y < size - margin; y++) {
|
||||
for (let dx = 0; dx < thickness; dx++) {
|
||||
setWhite(pixels, size, leftBarX + dx, y);
|
||||
setWhite(pixels, size, rightBarX + dx, y);
|
||||
}
|
||||
}
|
||||
// Diagonal from top-right of left bar to bottom-left of right bar
|
||||
const diagSteps = size - margin * 2;
|
||||
for (let s = 0; s < diagSteps; s++) {
|
||||
const t = s / (diagSteps - 1);
|
||||
const cx = Math.round(leftBarX + thickness + t * (rightBarX - leftBarX - thickness));
|
||||
const cy = Math.round(margin + t * (size - margin * 2 - 1));
|
||||
for (let dx = -Math.floor(thickness / 2); dx < Math.ceil(thickness / 2); dx++) {
|
||||
for (let dy = -2; dy <= 2; dy++) {
|
||||
setWhite(pixels, size, cx + dx, cy + dy);
|
||||
}
|
||||
}
|
||||
}
|
||||
return encodePng(size, size, pixels);
|
||||
}
|
||||
|
||||
function setWhite(pixels: Uint8Array, size: number, x: number, y: number): void {
|
||||
if (x < 0 || x >= size || y < 0 || y >= size) return;
|
||||
const i = (y * size + x) * 4;
|
||||
pixels[i] = 255;
|
||||
pixels[i + 1] = 255;
|
||||
pixels[i + 2] = 255;
|
||||
pixels[i + 3] = 255;
|
||||
}
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
/**
|
||||
* 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 15–9 year-1980, 8–5 month, 4–0 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;
|
||||
}
|
||||
+16
-22
@@ -2,9 +2,9 @@
|
||||
* Step: pair-telegram — issue a one-time pairing code and wait for the
|
||||
* operator to send the code from the chat they want to register.
|
||||
*
|
||||
* Emits machine-readable status blocks only. The parent driver
|
||||
* (`setup:auto`) renders the code / attempt / success UI with clack. Running
|
||||
* this step directly will look sparse — that's intentional.
|
||||
* Renders the human-facing code card itself (see printCodeCard) and emits
|
||||
* machine-readable status blocks alongside for the programmatic callers
|
||||
* (/manage-channels, /init-first-agent) that parse them.
|
||||
*
|
||||
* Blocks emitted:
|
||||
* PAIR_TELEGRAM_CODE { CODE, REASON=initial|regenerated }
|
||||
@@ -20,6 +20,8 @@
|
||||
*/
|
||||
import path from 'path';
|
||||
|
||||
import * as p from '@clack/prompts';
|
||||
|
||||
import {
|
||||
createPairing,
|
||||
waitForPairing,
|
||||
@@ -56,34 +58,26 @@ function intentToString(intent: PairingIntent): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the pairing code and live feedback as PLAIN stdout lines.
|
||||
* Render the pairing code card with clack's STATIC primitives (note/log).
|
||||
*
|
||||
* The Option A driver's streaming exec (setup/lib/skill-driver.ts
|
||||
* `hostExecStream`) CONSUMES the `=== NANOCLAW SETUP: … ===` status blocks (it
|
||||
* does not show them) and tees every OTHER stdout line straight to the
|
||||
* operator's terminal. So the human-facing code card has to be printed as plain
|
||||
* lines here — the bespoke setup/channels/telegram.ts used to render these from
|
||||
* the blocks, and that rendering now lives in the step itself. The structured
|
||||
* blocks are still emitted alongside for the agent-driven callers
|
||||
* (/manage-channels, /init-first-agent) that parse them.
|
||||
* does not show them) and tees every OTHER stdout line verbatim to the
|
||||
* operator's terminal. Static clack output is just lines, so it survives that
|
||||
* tee and reads like the rest of the wizard — only INTERACTIVE/animated clack
|
||||
* widgets need the real TTY the piped child doesn't have (SSF-002).
|
||||
*/
|
||||
function printCodeCard(code: string, reason: 'initial' | 'regenerated'): void {
|
||||
const spaced = code.split('').join(' ');
|
||||
console.log('');
|
||||
console.log(
|
||||
reason === 'initial'
|
||||
? 'Your pairing code is ready.'
|
||||
: 'That code was used up — here is a fresh one.',
|
||||
p.note(
|
||||
`${spaced}\n\nSend these 4 digits to your bot from Telegram.`,
|
||||
reason === 'initial' ? 'Your pairing code is ready' : 'That code was used up — here is a fresh one',
|
||||
);
|
||||
console.log('');
|
||||
console.log(` ${spaced}`);
|
||||
console.log('');
|
||||
console.log('Send these 4 digits to your bot from Telegram.');
|
||||
console.log('Waiting for you to send the code…');
|
||||
p.log.message('Waiting for you to send the code…');
|
||||
}
|
||||
|
||||
function printAttempt(candidate: string): void {
|
||||
console.log(`Got "${candidate}", which doesn't match — waiting for the correct code…`);
|
||||
p.log.warn(`Got "${candidate}", which doesn't match — waiting for the correct code…`);
|
||||
}
|
||||
|
||||
export async function run(args: string[]): Promise<void> {
|
||||
@@ -115,7 +109,7 @@ export async function run(args: string[]): Promise<void> {
|
||||
},
|
||||
});
|
||||
|
||||
console.log('\nTelegram paired.');
|
||||
p.log.success('Telegram paired.');
|
||||
emitStatus('PAIR_TELEGRAM', {
|
||||
STATUS: 'success',
|
||||
CODE: record.code,
|
||||
|
||||
@@ -22,37 +22,6 @@ describe('determineVerifyStatus', () => {
|
||||
).toBe('failed');
|
||||
});
|
||||
|
||||
// Deferred wire (Teams): configured but zero groups is pending operator
|
||||
// action (first DM), not a broken install — success, not failed.
|
||||
it('accepts zero groups when wiring is pending a first DM', () => {
|
||||
expect(
|
||||
determineVerifyStatus({
|
||||
...healthyBase,
|
||||
registeredGroups: 0,
|
||||
wiringPending: true,
|
||||
}),
|
||||
).toBe('success');
|
||||
});
|
||||
|
||||
it('pending wiring never rescues a stopped service or missing credentials', () => {
|
||||
expect(
|
||||
determineVerifyStatus({
|
||||
...healthyBase,
|
||||
registeredGroups: 0,
|
||||
wiringPending: true,
|
||||
service: 'stopped',
|
||||
}),
|
||||
).toBe('failed');
|
||||
expect(
|
||||
determineVerifyStatus({
|
||||
...healthyBase,
|
||||
registeredGroups: 0,
|
||||
wiringPending: true,
|
||||
credentials: 'missing',
|
||||
}),
|
||||
).toBe('failed');
|
||||
});
|
||||
|
||||
it('fails when the service is not running', () => {
|
||||
expect(
|
||||
determineVerifyStatus({
|
||||
|
||||
+2
-26
@@ -217,27 +217,15 @@ export async function run(_args: string[]): Promise<void> {
|
||||
mountAllowlist = 'configured';
|
||||
}
|
||||
|
||||
// Deferred-wire channels can't have a group yet: their platform id only
|
||||
// exists after the first inbound DM (see add-teams' "Finish wiring"), so
|
||||
// configured-but-unwired is pending operator action, not a broken install.
|
||||
// Only claim pending when EVERY configured channel is defer-wire — a
|
||||
// wire-during-setup channel (slack, telegram, …) with zero groups is a
|
||||
// genuine failure this must not mask.
|
||||
const wiringPending =
|
||||
registeredGroups === 0 &&
|
||||
configuredChannels.length > 0 &&
|
||||
configuredChannels.every((c) => DEFER_WIRE_CHANNELS.has(c));
|
||||
|
||||
// Determine overall status. The cli-agent step earlier in setup already
|
||||
// proved the agent round-trip works; verify is a static health check.
|
||||
const status = determineVerifyStatus({
|
||||
service,
|
||||
credentials,
|
||||
registeredGroups,
|
||||
wiringPending,
|
||||
});
|
||||
|
||||
log.info('Verification complete', { status, channelAuth, wiringPending });
|
||||
log.info('Verification complete', { status, channelAuth });
|
||||
|
||||
emitStatus('VERIFY', {
|
||||
SERVICE: service,
|
||||
@@ -247,7 +235,6 @@ export async function run(_args: string[]): Promise<void> {
|
||||
CHANNEL_AUTH: JSON.stringify(channelAuth),
|
||||
REGISTERED_GROUPS: registeredGroups,
|
||||
MOUNT_ALLOWLIST: mountAllowlist,
|
||||
...(wiringPending ? { WIRING: 'pending_first_dm' } : {}),
|
||||
STATUS: status,
|
||||
LOG: 'logs/setup.log',
|
||||
});
|
||||
@@ -255,25 +242,14 @@ export async function run(_args: string[]): Promise<void> {
|
||||
if (status === 'failed') process.exit(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Channels whose wiring only completes after the first inbound message —
|
||||
* the platform id doesn't exist until the bot is DM'd, so setup ends with
|
||||
* the channel configured but no group wired. Kept in lockstep with the
|
||||
* wireIfResolved call site in setup/auto.ts (its unresolved drop-through
|
||||
* leaves the channel configured but unwired).
|
||||
*/
|
||||
export const DEFER_WIRE_CHANNELS = new Set(['teams']);
|
||||
|
||||
export function determineVerifyStatus(input: {
|
||||
service: 'not_found' | 'stopped' | 'running' | 'running_other_checkout';
|
||||
credentials: string;
|
||||
registeredGroups: number;
|
||||
/** Zero groups but every configured channel defers wiring to the first DM. */
|
||||
wiringPending?: boolean;
|
||||
}): 'success' | 'failed' {
|
||||
return input.service === 'running' &&
|
||||
input.credentials !== 'missing' &&
|
||||
(input.registeredGroups > 0 || input.wiringPending === true)
|
||||
input.registeredGroups > 0
|
||||
? 'success'
|
||||
: 'failed';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user