@patimweb/pi-teams 1.6.0 → 1.6.1

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.
@@ -81,8 +81,23 @@ AADSTS7000218: The request body must contain the following parameter:
81
81
 
82
82
  ## 3. API permissions
83
83
 
84
- **App registrations** → your app → **API permissions** → **Add a permission** →
85
- **Microsoft Graph** → **Delegated permissions**:
84
+ **Entra admin center** → **Entra ID** → **App registrations** → your app →
85
+ **API permissions**. Four clicks, and the third one is the one people get
86
+ wrong:
87
+
88
+ 1. **Add a permission** — the button above the permission list.
89
+ 2. **Microsoft Graph** — the first tile under *Commonly used Microsoft APIs*.
90
+ Not *Azure DevOps*, not *Azure Storage*: those are different APIs and their
91
+ permissions will not help.
92
+ 3. **Delegated permissions**, *not* **Application permissions**. pi acts as you.
93
+ An application permission would give the app its own identity and a
94
+ completely different consent model — and the two tiles sit directly under
95
+ each other, which is why this is the usual mistake.
96
+ 4. Type each name into **Start typing a permission to filter these results**,
97
+ tick the box, and press **Add permissions** once at the end. Nothing is
98
+ saved until that button is pressed, so a half-filled list can be closed.
99
+
100
+ What you should end up ticking:
86
101
 
87
102
  | Permission | Needed for |
88
103
  |---|---|
@@ -99,7 +114,25 @@ AADSTS7000218: The request body must contain the following parameter:
99
114
  | `Files.Read.All`, `Sites.Read.All` | channel files |
100
115
 
101
116
  `openid`, `profile` and `offline_access` are deliberately absent: MSAL always
102
- requests them and rejects them in an explicit scope list.
117
+ requests them and rejects them in an explicit scope list. (You will still see
118
+ them in the picker, under **OpenId permissions**. Leave them alone.)
119
+
120
+ ### The Admin consent required column
121
+
122
+ After adding them, the list has a column that decides whether step 4 is a
123
+ five-second job or a ticket to your administrator:
124
+
125
+ | Permission | Admin consent required |
126
+ |---|---|
127
+ | `ChannelMessage.Read.All` | **Yes** — reading channels and threads |
128
+ | `ChannelMember.Read.All` | **Yes** — channel member lists |
129
+ | `ChatMember.ReadWrite` | **Yes** — only if you added it, see below |
130
+ | everything else in the list above | No |
131
+
132
+ The column shows the *default* for an organization, and a tenant can relax it
133
+ per permission, per user or per app — read it as "expect to need an admin", not
134
+ as a property of the permission itself. The scopes showing **Yes** are the ones
135
+ step 4 is about.
103
136
 
104
137
  **What is not needed:** no client secret, no certificate, no redirect URI beyond
105
138
  step 2, no application (as opposed to delegated) permissions.
@@ -112,7 +145,7 @@ again:
112
145
 
113
146
  | Permission | Unlocks | Consent |
114
147
  |---|---|---|
115
- | `ChatMember.ReadWrite` | removing someone from a chat (`teams_chat_members`) | user |
148
+ | `ChatMember.ReadWrite` | removing someone from a chat (`teams_chat_members`) | **admin** — the portal shows it as *Admin consent required: Yes*, and the tool says which scope is missing until it is granted |
116
149
 
117
150
  Without it the tool answers with the exact scope it is missing instead of
118
151
  failing at Graph with a consent error. Editing and deleting chat messages,
@@ -183,6 +216,12 @@ Or edit `~/.pi/agent/pi-teams.json` directly — the same file, mode `0600`:
183
216
  with your own credentials and MFA, and the page closes itself. Nothing to
184
217
  copy or type. Over SSH or in a container, pi falls back to the device code
185
218
  flow and shows a short code to enter on any device.
219
+
220
+ In **WSL** the URL is handed to Windows PowerShell, so the browser on the
221
+ Windows side opens — no X server and no `DISPLAY` needed. A launcher that
222
+ cannot be started is reported as an error; if you are upgrading from a
223
+ version that hung here instead, see the last row of the troubleshooting
224
+ table.
186
225
  2. `teams_status` — who pi acts as, which token is cached, what it may do.
187
226
  3. `teams_doctor` — checks configuration, sign-in, granted scopes and
188
227
  connectivity in one go. It reports a missing consent as a **named
@@ -223,9 +262,12 @@ that tenant on its own.
223
262
  | `AADSTS50011` — *redirect URI mismatch* | an exact URI is registered that does not match the chosen port | register the port-less `http://localhost`, or pin the port in the same file |
224
263
  | `AADSTS7000218` — *client_assertion or client_secret* | **Allow public client flows** is off | step 2b |
225
264
  | `AADSTS65001` — *consent required* | permissions added but never consented | sign in again; for the two admin scopes, step 4 |
265
+ | `AADSTS65004` — *request pending* | an **admin consent request** is waiting for approval, or consent was declined once | step 4: an administrator presses **Grant admin consent**. A pending request is under **Enterprise applications** → your app → **Permissions**. Until it is approved the browser comes back with this code instead of a token |
266
+ | `AADSTS53003` — *blocked by Conditional Access* | a policy requires a managed or compliant device, which the **device code** flow cannot satisfy | this is why the browser sign-in has to work rather than quietly fall back: on such a tenant the device code route is a dead end, not a fallback. Use a machine that satisfies the policy, or add pi's loopback redirect to it |
226
267
  | `AADSTS50020` — *not a member of the tenant* | wrong tenant, no guest account, or the app is single-tenant | steps 1 and 7 |
227
268
  | `AADSTS700016` — *application not found in directory* | wrong tenant ID or client ID | recheck `<DIRECTORY_TENANT_ID>` and `<APPLICATION_CLIENT_ID>` |
228
269
  | signed out after about an hour | the tenant grants no `offline_access` | the app needs a refresh token to renew silently; without it, run `teams_login` when the session expires. `teams_doctor` names the missing scope |
270
+ | browser never opens from WSL | older versions called `xdg-open`, which a plain WSL distro does not have — and the failure was swallowed | update pi-teams: current versions hand the URL to Windows PowerShell. If `/mnt/c` is not mounted, set `PI_TEAMS_NO_BROWSER=1` and use the device code flow — see `AADSTS53003` if Conditional Access blocks that |
229
271
 
230
272
  Run `teams_doctor` first for anything in this table: it validates the
231
273
  configuration, the cached session, the granted scopes and the connection to
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@patimweb/pi-teams",
3
- "version": "1.6.0",
3
+ "version": "1.6.1",
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",
package/src/auth/msal.ts CHANGED
@@ -7,7 +7,7 @@
7
7
  * whether a browser is actually reachable, and open it.
8
8
  */
9
9
 
10
- import { spawn } from "node:child_process";
10
+ import { spawn, type ChildProcess } from "node:child_process";
11
11
  import {
12
12
  ConfidentialClientApplication,
13
13
  LogLevel,
@@ -15,7 +15,7 @@ import {
15
15
  type Configuration,
16
16
  } from "@azure/msal-node";
17
17
  import type { TeamsConnection } from "../config/index.ts";
18
- import { browserUnavailableReason, canOpenBrowser } from "../utils/environment.ts";
18
+ import { browserUnavailableReason, canOpenBrowser, isWsl } from "../utils/environment.ts";
19
19
  import { createCachePlugin } from "./cache-plugin.ts";
20
20
 
21
21
  export { browserUnavailableReason, canOpenBrowser };
@@ -101,32 +101,139 @@ export function resetApps(): void {
101
101
  // Browser
102
102
  // ---------------------------------------------------------------------------
103
103
 
104
- /** Open a URL in the user's default browser, detached from this process. */
105
- export function openBrowser(url: string): Promise<void> {
106
- return new Promise((resolve, reject) => {
107
- const [command, args] =
108
- process.platform === "darwin"
109
- ? ["open", [url]]
110
- : process.platform === "win32"
111
- ? // `start` is a shell builtin, and the empty string is the window
112
- // title cmd.exe would otherwise take the URL for.
113
- ["cmd.exe", ["/c", "start", "", url]]
114
- : ["xdg-open", [url]];
104
+ /**
105
+ * The Windows-side shell a WSL session hands its URL to.
106
+ *
107
+ * Windows PowerShell rather than `cmd.exe`: the authorize URL carries several
108
+ * `&`, and `cmd /c start` reads the first of them as a command separator and
109
+ * opens a truncated URL. Single quotes survive them.
110
+ */
111
+ const WSL_POWERSHELL = "/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe";
115
112
 
113
+ /** How long to wait for a launcher that neither exits nor errors. */
114
+ const LAUNCH_EXIT_GRACE_MS = 2000;
115
+
116
+ /**
117
+ * The command that puts `url` in front of the user in a browser.
118
+ *
119
+ * Split out of `openBrowser` so the shapes stay testable: the Windows and WSL
120
+ * forms are easy to get subtly wrong, and all of them fail quietly when they
121
+ * are.
122
+ */
123
+ export function browserLaunch(
124
+ url: string,
125
+ target: { platform?: NodeJS.Platform; wsl?: boolean } = {},
126
+ ): { command: string; args: string[] } {
127
+ const platform = target.platform ?? process.platform;
128
+
129
+ if (target.wsl ?? isWsl()) {
130
+ // Escaping a quote inside a PowerShell single-quoted string means doubling
131
+ // it. Nothing else in the URL needs attention.
132
+ const quoted = url.replace(/'/g, "''");
133
+ return {
134
+ command: WSL_POWERSHELL,
135
+ args: ["-NoProfile", "-NonInteractive", "-Command", `Start-Process '${quoted}'`],
136
+ };
137
+ }
138
+
139
+ if (platform === "darwin") return { command: "open", args: [url] };
140
+
141
+ if (platform === "win32") {
142
+ // `start` is a shell builtin, and the empty string is the window title
143
+ // cmd.exe would otherwise take the URL for.
144
+ return { command: "cmd.exe", args: ["/c", "start", "", url] };
145
+ }
146
+
147
+ return { command: "xdg-open", args: [url] };
148
+ }
149
+
150
+ /**
151
+ * Run a launcher and wait until it is clear whether it worked.
152
+ *
153
+ * Two things have to be caught, and both were lost when this resolved on spawn.
154
+ * A missing launcher — `xdg-open` on a fresh WSL distro — reports ENOENT on
155
+ * `error` and never exits. A launcher that is present but finds no browser
156
+ * exits non-zero, which is how `xdg-open` says so.
157
+ *
158
+ * Resolving on spawn made both look like success, so the sign-in sat waiting
159
+ * for a redirect from a browser that had never opened, until the five minute
160
+ * timeout replaced the real cause with "Browser sign-in timed out".
161
+ */
162
+ export function launchBrowser(
163
+ command: string,
164
+ args: string[],
165
+ graceMs = LAUNCH_EXIT_GRACE_MS,
166
+ ): Promise<void> {
167
+ return new Promise((resolve, reject) => {
168
+ let child: ChildProcess;
116
169
  try {
117
- const child = spawn(command as string, args as string[], {
118
- detached: true,
119
- stdio: "ignore",
120
- });
121
- child.on("error", reject);
122
- child.unref();
123
- resolve();
170
+ child = spawn(command, args, { detached: true, stdio: ["ignore", "ignore", "pipe"] });
124
171
  } catch (err) {
125
172
  reject(err instanceof Error ? err : new Error(String(err)));
173
+ return;
126
174
  }
175
+
176
+ let settled = false;
177
+ let timer: ReturnType<typeof setTimeout> | undefined;
178
+ let stderr = "";
179
+
180
+ child.stderr?.on("data", (chunk: Buffer) => {
181
+ stderr += String(chunk);
182
+ });
183
+
184
+ const settle = (err?: Error) => {
185
+ if (settled) return;
186
+ settled = true;
187
+ if (timer) clearTimeout(timer);
188
+ child.unref();
189
+ if (err) reject(err);
190
+ else resolve();
191
+ };
192
+
193
+ // A launcher that stays alive is waiting on the browser, not failing.
194
+ timer = setTimeout(() => settle(), graceMs);
195
+
196
+ child.once("error", (err: NodeJS.ErrnoException) => {
197
+ const detail = err.code === "ENOENT" ? `${command} was not found` : err.message;
198
+ settle(new Error(`could not open a browser: ${detail}`));
199
+ });
200
+
201
+ child.once("close", (code, signal) => {
202
+ if (code === 0) settle();
203
+ else {
204
+ // `close`, not `exit`: the exit event can fire before the stderr pipe has
205
+ // drained, and the reason xdg-open gives is on stderr.
206
+ const why = stderr.trim() ? `: ${stderr.trim()}` : "";
207
+ settle(
208
+ new Error(
209
+ `could not open a browser: ${command} exited with ` +
210
+ `${code === null ? `signal ${signal ?? "unknown"}` : `code ${code}`}${why}`,
211
+ ),
212
+ );
213
+ }
214
+ });
127
215
  });
128
216
  }
129
217
 
218
+ /** Open a URL in the user's default browser, detached from this process. */
219
+ export async function openBrowser(url: string): Promise<void> {
220
+ const { command, args } = browserLaunch(url);
221
+
222
+ try {
223
+ await launchBrowser(command, args);
224
+ } catch (err) {
225
+ const cause = err instanceof Error ? err.message : String(err);
226
+ if (isWsl()) {
227
+ throw new Error(
228
+ `${cause}. From WSL the URL is handed to Windows PowerShell at ` +
229
+ `${WSL_POWERSHELL}; if the Windows drive is not mounted there, set ` +
230
+ "PI_TEAMS_NO_BROWSER=1 to use the device code flow instead.",
231
+ );
232
+ }
233
+ throw err instanceof Error ? err : new Error(cause);
234
+ }
235
+ }
236
+
130
237
  // ---------------------------------------------------------------------------
131
238
  // Browser response pages
132
239
  // ---------------------------------------------------------------------------
@@ -12,10 +12,16 @@
12
12
  * The interactive sign-in needs two things: a browser to open, and a loopback
13
13
  * listener that browser can reach. Over SSH both assumptions break — `open`
14
14
  * would launch a browser on the *remote* machine, where nobody is looking — so
15
- * those sessions get the device code flow instead.
15
+ * those sessions get the device code flow instead. In WSL the browser is on
16
+ * the Windows side of the same machine, which is what `isWsl` accounts for.
16
17
  *
17
18
  * Set `PI_TEAMS_NO_BROWSER=1` to force that fallback anywhere.
18
19
  */
20
+ export function isWsl(): boolean {
21
+ if (process.platform !== "linux") return false;
22
+ return !!(process.env.WSL_DISTRO_NAME || process.env.WSL_INTEROP);
23
+ }
24
+
19
25
  export function canOpenBrowser(): boolean {
20
26
  if (process.env.PI_TEAMS_NO_BROWSER) return false;
21
27
 
@@ -24,6 +30,11 @@ export function canOpenBrowser(): boolean {
24
30
 
25
31
  if (process.platform === "darwin" || process.platform === "win32") return true;
26
32
 
33
+ // WSL is not a headless Linux: the browser lives on Windows and `openBrowser`
34
+ // hands the URL over to it. DISPLAY says nothing either way — WSLg sets it on
35
+ // Windows 11 and not on Windows 10, while the handoff works on both.
36
+ if (isWsl()) return true;
37
+
27
38
  // Linux and the BSDs need a display server for a browser to appear at all.
28
39
  return !!(process.env.DISPLAY || process.env.WAYLAND_DISPLAY);
29
40
  }