@patimweb/pi-teams 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -28,8 +28,8 @@ pi install /path/to/pi-teams
28
28
  clientId: <application (client) ID>
29
29
  setDefault: true
30
30
  ```
31
- 3. **Sign in:** run `/teams-login`. pi shows a code and a URL; open it, enter
32
- the code, sign in as yourself.
31
+ 3. **Sign in:** run `/teams-login`. Your browser opens at the Microsoft
32
+ sign-in page and closes itself when you are done — nothing to type.
33
33
  4. **Check it works:** `/teams-status`, then `/teams-inbox`.
34
34
 
35
35
  ---
@@ -76,41 +76,122 @@ Without this, sign-in fails with `AADSTS7000218`.
76
76
  | `Calendars.ReadWrite` | reading and creating calendar events |
77
77
  | `OnlineMeetings.ReadWrite` | Teams meeting links |
78
78
  | `Files.Read.All`, `Sites.Read.All` | channel files |
79
- | `offline_access` | staying signed in |
80
79
 
81
- Some of these require **admin consent**. Click *Grant admin consent* if you
82
- can, or ask an administrator. `teams_doctor` reports exactly which scopes were
83
- granted and which were not, so you never have to guess from a 403.
80
+ `openid`, `profile` and `offline_access` are not listed: MSAL always requests
81
+ them and rejects them in an explicit scope list, so the lasting sign-in they
82
+ buy is automatic.
84
83
 
85
- To request fewer permissions, set `scopes` on the account — see below.
84
+ ### 4. Add the redirect URI for the browser sign-in
86
85
 
87
- ### 4. Guest and customer tenants
86
+ **Authentication** → **Add a platform** → **Mobile and desktop applications** →
87
+ tick `http://localhost`.
88
+
89
+ MSAL picks a free loopback port at sign-in time, and Entra accepts any port on
90
+ localhost for a public client, so this one entry covers every run. If your
91
+ tenant insists on an exact URI, register e.g. `http://localhost:3000` and set
92
+ `loopbackPort: 3000` on the account.
93
+
94
+ ---
95
+
96
+ ## Who has to approve what
97
+
98
+ Two separate hurdles, and only one of them normally needs an administrator.
99
+
100
+ ### Creating the registration
101
+
102
+ By default any member of the tenant may register an application (Entra ID →
103
+ **User settings** → *Users can register applications*). Where that is switched
104
+ off, the least-privileged role that can do it is **Application Developer**;
105
+ Cloud Application Administrator and Application Administrator also work.
106
+ Changing *Allow public client flows* and adding the redirect URI need no extra
107
+ role once the registration is yours.
108
+
109
+ ### Consenting to the permissions
110
+
111
+ Only two of the default scopes require **admin consent**:
112
+
113
+ | Scope | Consent | What you lose without it |
114
+ |-------|---------|--------------------------|
115
+ | `ChannelMessage.Read.All` | **admin** | reading channel messages and threads |
116
+ | `ChannelMember.Read.All` | **admin** | channel member lists |
117
+ | `ChannelMessage.Send` | user | — |
118
+ | `Chat.ReadWrite`, `ChatMessage.Send` | user | — |
119
+ | `Team.ReadBasic.All`, `Channel.ReadBasic.All` | user | — |
120
+ | `User.Read`, `User.ReadBasic.All` | user | — |
121
+ | `Presence.ReadWrite`, `Presence.Read.All` | user | — |
122
+ | `Calendars.ReadWrite`, `OnlineMeetings.ReadWrite` | user | — |
123
+ | `Files.Read.All`, `Sites.Read.All` | user | — |
124
+
125
+ Posting in a channel needs no admin; *reading* the channel does. Microsoft
126
+ treats bulk reading of channel content as the more sensitive right.
127
+
128
+ **Without an administrator**, drop the two admin-consent scopes and everything
129
+ else still works — chats in full, posting and replying in channels, presence,
130
+ calendar, meetings and directory lookup:
131
+
132
+ ```json
133
+ "scopes": [
134
+ "User.Read", "User.ReadBasic.All",
135
+ "Team.ReadBasic.All", "Channel.ReadBasic.All", "ChannelMessage.Send",
136
+ "Chat.ReadWrite", "ChatMessage.Send",
137
+ "Presence.ReadWrite", "Presence.Read.All",
138
+ "Calendars.ReadWrite", "OnlineMeetings.ReadWrite",
139
+ "Files.Read.All", "Sites.Read.All"
140
+ ]
141
+ ```
142
+
143
+ One caveat: if the tenant has *Users can consent to applications* switched off,
144
+ even the user-consentable scopes need an administrator — usually through the
145
+ admin consent request workflow.
146
+
147
+ `teams_doctor` reports exactly which scopes were actually granted, so a missing
148
+ consent shows up as a named permission rather than a 403.
149
+
150
+ ### Guest and customer tenants
88
151
 
89
152
  For a tenant that is not your own:
90
153
 
91
154
  - the registration must be **multitenant**,
92
155
  - your account must be a guest in that tenant,
93
- - an administrator there consents to the app once.
156
+ - an administrator **of that tenant** consents to the app once.
94
157
 
95
- Then add it as a tenant beneath your account (`parentAccount`) and sign in
96
- again against it.
158
+ In practice this is the bigger hurdle, not your own tenant. Once it is done,
159
+ add the tenant beneath your account (`parentAccount` in `teams_setup`) and sign
160
+ in against it.
97
161
 
98
162
  ---
99
163
 
100
- ## Acting as a user vs. app-only
164
+ ## How the sign-in works
165
+
166
+ `teams_login` uses MSAL (`@azure/msal-node`) and picks the flow that fits the
167
+ machine:
168
+
169
+ | | `interactive` (default) | `device-code` | `client-credentials` |
170
+ |---|---|---|---|
171
+ | What you do | browser opens, you sign in | type a short code elsewhere | nothing |
172
+ | Identity | you | you | the application |
173
+ | Needs a browser | yes | no | no |
174
+ | Reading chats/channels | yes | yes | only via protected APIs |
175
+ | **Sending messages** | **yes** | **yes** | **no** — refused with an explanation |
176
+ | Presence, calendar | yes | yes | partially |
177
+
178
+ **Interactive** is chosen automatically wherever a browser can actually be
179
+ reached. MSAL opens the system browser, listens on a loopback port for the
180
+ redirect, and handles PKCE — nothing to copy or type.
181
+
182
+ **Device code** takes over automatically over SSH, in containers, and on any
183
+ machine without a display server, because a browser opened there would appear
184
+ where nobody is looking. Force it anywhere with `PI_TEAMS_NO_BROWSER=1`, per
185
+ account with `"authMode": "device-code"`, or per call with
186
+ `teams_login mode: device-code`.
101
187
 
102
- | | `device-code` (default) | `client-credentials` |
103
- |---|---|---|
104
- | Identity | you | the application |
105
- | Sign-in | once, interactive | none |
106
- | Reading chats/channels | yes | only via protected APIs |
107
- | **Sending messages** | **yes** | **no** — refused with an explanation |
108
- | Presence, calendar | yes | partially |
188
+ Either way the session is cached on disk and renewed silently, so signing in is
189
+ a once-per-account affair.
109
190
 
110
191
  Microsoft Graph only permits app-only writes to chats and channels for
111
192
  migration scenarios (`Teamwork.Migrate.All`), so pi-teams refuses those calls
112
193
  up front instead of letting them fail deep inside a Graph request. Use
113
- `client-credentials` for unattended read-only jobs, `device-code` for
194
+ `client-credentials` for unattended read-only jobs, and a delegated flow for
114
195
  everything that speaks in your name.
115
196
 
116
197
  ---
@@ -129,7 +210,7 @@ prefer.
129
210
  "displayName": "NeoImpulse",
130
211
  "tenantId": "neoimpulse.onmicrosoft.com",
131
212
  "clientId": "00000000-0000-0000-0000-000000000000",
132
- "authMode": "device-code",
213
+ "authMode": "interactive",
133
214
  "safetyLevel": "confirm",
134
215
  "permissions": {
135
216
  "read": {
@@ -250,7 +331,7 @@ teams_permissions:
250
331
  | Tool | Description |
251
332
  |------|-------------|
252
333
  | `teams_setup` | Write an account or tenant to the config file |
253
- | `teams_login` | Sign in as yourself (device code flow) |
334
+ | `teams_login` | Sign in as yourself (browser, or device code where there is none) |
254
335
  | `teams_logout` | Remove a saved session |
255
336
  | `teams_accounts` | List accounts, switch the default, change a safety level, delete |
256
337
  | `teams_status` | Which account is active, who pi acts as, what it may do |
@@ -298,7 +379,7 @@ teams_permissions:
298
379
  | Command | Description |
299
380
  |---------|-------------|
300
381
  | `/teams-status` | Show the connection and permissions |
301
- | `/teams-login` | Start the device code sign-in |
382
+ | `/teams-login` | Sign in to Teams as yourself |
302
383
  | `/teams-inbox` | What needs your attention right now |
303
384
  | `/teams-permissions` | What pi may and may not do |
304
385
  | `/teams-doctor` | Diagnose the setup |
@@ -354,7 +435,7 @@ teams_send_channel_message:
354
435
  | File | Contents | Mode |
355
436
  |------|----------|------|
356
437
  | `~/.pi/agent/pi-teams.json` | accounts, tenants, safety levels, scope rules | `0600` |
357
- | `~/.pi/agent/pi-teams-tokens.json` | access and refresh tokens per account+tenant | `0600` |
438
+ | `~/.pi/agent/pi-teams-tokens/` | one MSAL token cache per account+tenant | `0600` |
358
439
  | `~/.pi/agent/pi-teams-audit.jsonl` | one line per write: when, who, where, what | `0600` |
359
440
 
360
441
  Both the config and the token cache are written through a private temp file and
@@ -371,7 +452,7 @@ middle.
371
452
  - **`src/config/index.ts`** — accounts, tenants, the safety cascade, persistence.
372
453
  - **`src/config/scope.ts`** — allow/deny matching. Pure, and the most heavily
373
454
  tested module in the package.
374
- - **`src/auth/`** — device code flow, client credentials, token cache, JWT claims.
455
+ - **`src/auth/`** — MSAL applications, the file-backed token cache, JWT claims.
375
456
  - **`src/graph/client.ts`** — fetch wrapper: bearer token, paging, throttling.
376
457
  - **`src/graph/*.ts`** — one module per resource area, returning domain types.
377
458
  - **`src/graph/mappers.ts`** — Graph JSON → domain types. Pure.
@@ -398,11 +479,14 @@ a network connection.
398
479
  |---------|---------------|
399
480
  | `AADSTS7000218` | "Allow public client flows" is off in the app registration |
400
481
  | `AADSTS65001` | Consent missing — sign in again, or have an admin consent |
482
+ | `AADSTS50011` (redirect URI mismatch) | Add `http://localhost` under *Mobile and desktop applications*, or pin `loopbackPort` |
401
483
  | `AADSTS50020` | The account is not a member/guest of that tenant, or the app is single-tenant |
402
- | "Not signed in" | Run `teams_login`; the refresh token may have been revoked |
484
+ | Browser never opens | No browser is reachable here; `teams_doctor` says why. `teams_login mode: device-code` always works |
485
+ | Browser opens, page never returns | A firewall is blocking the loopback port. Pin `loopbackPort` and allow it, or use the device code flow |
486
+ | "Not signed in" | Run `teams_login`; the saved session may have been revoked |
403
487
  | "Blocked by configuration" | Your own scope rules. `teams_permissions action: test` shows which rule |
404
- | "cannot run on an app-only token" | Set `authMode: "device-code"` and run `teams_login` |
405
- | 403 on channel messages | `ChannelMessage.Read.All` / `ChannelMessage.Send` not consented |
488
+ | "cannot run on an app-only token" | Set `authMode: "interactive"` and run `teams_login` |
489
+ | 403 reading channel messages | `ChannelMessage.Read.All` needs **admin** consent — see "Who has to approve what" |
406
490
  | Throttled | Graph rate limit; the client retries with back-off, then reports it |
407
491
 
408
492
  Run `teams_doctor` first — it checks all of the above at once.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@patimweb/pi-teams",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "Microsoft Teams integration for pi coding agent — acts fully as the signed-in user across multiple accounts and tenants: chats, channels, meetings, presence, with configurable guardrails",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -29,7 +29,8 @@
29
29
  "typecheck": "tsc --noEmit"
30
30
  },
31
31
  "dependencies": {
32
- "typebox": "^1.2.8"
32
+ "@azure/msal-node": "^6.0.0",
33
+ "typebox": ">=1.0.0"
33
34
  },
34
35
  "peerDependencies": {
35
36
  "@earendil-works/pi-coding-agent": ">=0.70.0",
@@ -7,12 +7,19 @@ Something is wrong with my Teams connection.
7
7
 
8
8
  2. Explain each problem in plain language, and say who can fix it — me or a
9
9
  tenant administrator. In particular:
10
- - missing consent → name the exact Graph scope and what it is needed for
10
+ - missing consent → name the exact Graph scope and what it is needed for,
11
+ and say whether it needs an admin (`ChannelMessage.Read.All` and
12
+ `ChannelMember.Read.All` do; the rest I can consent to myself)
11
13
  - "Allow public client flows" disabled → the setting is in Entra ID →
12
14
  App registrations → Authentication
15
+ - a redirect URI mismatch → `http://localhost` must be registered under
16
+ "Mobile and desktop applications"
17
+ - the browser sign-in unavailable → say why, and that
18
+ `teams_login mode: device-code` works anywhere
13
19
  - expired or revoked sign-in → I need to run `teams_login` again
14
20
  - a guest tenant that rejects the sign-in → the app registration must be
15
- multi-tenant and I must be invited to that tenant
21
+ multi-tenant, I must be invited there, and an admin of that tenant must
22
+ have consented
16
23
 
17
24
  3. Run `teams_permissions` with `action: "show"` and tell me, in one short
18
25
  paragraph, what you are currently allowed to do and what you are not.
@@ -12,8 +12,10 @@ something the user is personally saying.
12
12
  ## Before anything else
13
13
 
14
14
  Run `teams_status` if you are unsure which account is active or whether pi is
15
- signed in. If it reports "not signed in", run `teams_login` — it prints a short
16
- code and a URL that the user must open themselves.
15
+ signed in. If it reports "not signed in", run `teams_login`: it opens the
16
+ user's browser and completes on its own. On a machine with no browser it falls
17
+ back to a device code — then show the code and the URL to the user verbatim,
18
+ because they cannot finish signing in without them.
17
19
 
18
20
  If a tool fails with an authentication, permission or configuration error, run
19
21
  `teams_doctor` and report what it says. Do not retry the failing call.
@@ -0,0 +1,215 @@
1
+ /**
2
+ * MSAL token cache, persisted to disk.
3
+ *
4
+ * MSAL keeps its cache in memory and hands us a serialized blob through the
5
+ * `ICachePlugin` hooks; without persisting it, every pi session would start
6
+ * with a fresh sign-in. One file per account+tenant under
7
+ * `~/.pi/agent/pi-teams-tokens/`, mode 0600 — the blob contains refresh
8
+ * tokens.
9
+ *
10
+ * Writes go through a private temp file and an atomic rename: a truncated
11
+ * cache would silently sign the user out.
12
+ */
13
+
14
+ import {
15
+ existsSync,
16
+ mkdirSync,
17
+ readFileSync,
18
+ writeFileSync,
19
+ chmodSync,
20
+ statSync,
21
+ renameSync,
22
+ unlinkSync,
23
+ readdirSync,
24
+ } from "node:fs";
25
+ import { join } from "node:path";
26
+ import type { ICachePlugin, TokenCacheContext } from "@azure/msal-node";
27
+ import { getAgentDir } from "../config/index.ts";
28
+
29
+ // ---------------------------------------------------------------------------
30
+ // Paths
31
+ // ---------------------------------------------------------------------------
32
+
33
+ export function getCacheDir(): string {
34
+ return join(getAgentDir(), "pi-teams-tokens");
35
+ }
36
+
37
+ /** Filesystem-safe cache key for an account+tenant pair. */
38
+ export function cacheKey(account: string, tenantId: string): string {
39
+ const clean = (value: string) => value.toLowerCase().replace(/[^a-z0-9._-]/g, "_");
40
+ return `${clean(account)}__${clean(tenantId)}`;
41
+ }
42
+
43
+ export function getCachePath(account: string, tenantId: string): string {
44
+ return join(getCacheDir(), `${cacheKey(account, tenantId)}.json`);
45
+ }
46
+
47
+ // ---------------------------------------------------------------------------
48
+ // I/O
49
+ // ---------------------------------------------------------------------------
50
+
51
+ function readBlob(path: string): string | undefined {
52
+ if (!existsSync(path)) return undefined;
53
+ try {
54
+ // Older files, or a cache copied between machines, may be too permissive
55
+ // for something holding refresh tokens.
56
+ try {
57
+ const mode = statSync(path).mode & 0o777;
58
+ if (mode !== 0o600) chmodSync(path, 0o600);
59
+ } catch {
60
+ /* ignore */
61
+ }
62
+ return readFileSync(path, "utf-8");
63
+ } catch {
64
+ return undefined;
65
+ }
66
+ }
67
+
68
+ function writeBlob(path: string, blob: string): void {
69
+ const dir = getCacheDir();
70
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
71
+
72
+ const tmpPath = `${path}.${process.pid}.tmp`;
73
+ try {
74
+ writeFileSync(tmpPath, blob, { encoding: "utf-8", mode: 0o600 });
75
+ chmodSync(tmpPath, 0o600);
76
+ renameSync(tmpPath, path);
77
+ } catch {
78
+ try {
79
+ unlinkSync(tmpPath);
80
+ } catch {
81
+ /* ignore */
82
+ }
83
+ // A cache that cannot be written is a degraded session, not a failed one:
84
+ // the tokens still work until the process exits.
85
+ }
86
+ }
87
+
88
+ // ---------------------------------------------------------------------------
89
+ // Plugin
90
+ // ---------------------------------------------------------------------------
91
+
92
+ /** Build the cache plugin for one account+tenant. */
93
+ export function createCachePlugin(account: string, tenantId: string): ICachePlugin {
94
+ const path = getCachePath(account, tenantId);
95
+
96
+ return {
97
+ async beforeCacheAccess(context: TokenCacheContext): Promise<void> {
98
+ const blob = readBlob(path);
99
+ if (blob) context.tokenCache.deserialize(blob);
100
+ },
101
+ async afterCacheAccess(context: TokenCacheContext): Promise<void> {
102
+ if (context.cacheHasChanged) writeBlob(path, context.tokenCache.serialize());
103
+ },
104
+ };
105
+ }
106
+
107
+ // ---------------------------------------------------------------------------
108
+ // Inspection
109
+ // ---------------------------------------------------------------------------
110
+
111
+ /** What the status surfaces need to know, readable without MSAL or a network call. */
112
+ export interface CacheSummary {
113
+ /** A cache file exists and holds an account */
114
+ present: boolean;
115
+ /** Signed-in identity, when the cache holds one */
116
+ username?: string;
117
+ name?: string;
118
+ /** Epoch milliseconds at which the newest access token expires */
119
+ expiresAt?: number;
120
+ /** Whether that access token is still valid */
121
+ fresh: boolean;
122
+ }
123
+
124
+ /* eslint-disable @typescript-eslint/no-explicit-any */
125
+
126
+ /**
127
+ * Read the identity and expiry out of a cache file.
128
+ *
129
+ * The blob is MSAL's own schema, so this reads it defensively: a shape change
130
+ * upstream must degrade to "not signed in" in the status line, never throw in
131
+ * the middle of rendering the TUI.
132
+ */
133
+ export function readCacheSummary(account: string, tenantId: string): CacheSummary {
134
+ const blob = readBlob(getCachePath(account, tenantId));
135
+ if (!blob) return { present: false, fresh: false };
136
+
137
+ try {
138
+ const parsed = JSON.parse(blob) as Record<string, any>;
139
+
140
+ const accounts = Object.values(parsed.Account ?? {}) as any[];
141
+ const first = accounts[0];
142
+
143
+ const tokens = Object.values(parsed.AccessToken ?? {}) as any[];
144
+ // expires_on is seconds-since-epoch, as a string, in MSAL's schema.
145
+ const expiries = tokens
146
+ .map((token) => Number(token?.expires_on))
147
+ .filter((value) => Number.isFinite(value) && value > 0);
148
+ const expiresAt = expiries.length > 0 ? Math.max(...expiries) * 1000 : undefined;
149
+
150
+ return {
151
+ present: accounts.length > 0 || tokens.length > 0,
152
+ username: first?.username,
153
+ name: first?.name,
154
+ expiresAt,
155
+ fresh: expiresAt !== undefined && expiresAt - 5 * 60 * 1000 > Date.now(),
156
+ };
157
+ } catch {
158
+ return { present: false, fresh: false };
159
+ }
160
+ }
161
+
162
+ /** Delete one cached session. Returns true when a file was removed. */
163
+ export function removeCache(account: string, tenantId: string): boolean {
164
+ const path = getCachePath(account, tenantId);
165
+ if (!existsSync(path)) return false;
166
+ try {
167
+ unlinkSync(path);
168
+ return true;
169
+ } catch {
170
+ return false;
171
+ }
172
+ }
173
+
174
+ /** Delete every cached session. Returns how many were removed. */
175
+ export function removeAllCaches(): number {
176
+ const dir = getCacheDir();
177
+ if (!existsSync(dir)) return 0;
178
+
179
+ let removed = 0;
180
+ for (const entry of readdirSync(dir)) {
181
+ if (!entry.endsWith(".json")) continue;
182
+ try {
183
+ unlinkSync(join(dir, entry));
184
+ removed += 1;
185
+ } catch {
186
+ /* skip what we cannot remove */
187
+ }
188
+ }
189
+ return removed;
190
+ }
191
+
192
+ /** Every cached session on disk, for `teams_status` and the doctor. */
193
+ export function listCachedSessions(): Array<{ key: string; summary: CacheSummary }> {
194
+ const dir = getCacheDir();
195
+ if (!existsSync(dir)) return [];
196
+
197
+ const sessions: Array<{ key: string; summary: CacheSummary }> = [];
198
+ for (const entry of readdirSync(dir)) {
199
+ if (!entry.endsWith(".json")) continue;
200
+ const key = entry.replace(/\.json$/, "");
201
+ const blob = readBlob(join(dir, entry));
202
+ if (!blob) continue;
203
+ try {
204
+ const parsed = JSON.parse(blob) as Record<string, any>;
205
+ const first = (Object.values(parsed.Account ?? {}) as any[])[0];
206
+ sessions.push({
207
+ key,
208
+ summary: { present: true, username: first?.username, name: first?.name, fresh: false },
209
+ });
210
+ } catch {
211
+ /* ignore an unreadable cache file */
212
+ }
213
+ }
214
+ return sessions;
215
+ }