@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.
@@ -1,17 +1,25 @@
1
1
  /**
2
- * teams_login — sign in as the user via the device code flow.
2
+ * teams_login — sign in as the user.
3
+ * teams_logout — forget a saved session.
3
4
  *
4
- * The tool streams the code and URL through `onUpdate` as soon as Microsoft
5
- * issues them, then keeps polling. That ordering matters: the user has a
6
- * couple of minutes to act, and they cannot act on a code that only appears
7
- * after the tool returns.
5
+ * The browser flow is the default: pi opens the Microsoft sign-in page, the
6
+ * user signs in with their own credentials and MFA, and MSAL catches the
7
+ * redirect on a loopback port. Where no browser can be reached — SSH, a
8
+ * container, a headless box — it falls back to the device code flow and says
9
+ * why.
8
10
  */
9
11
 
10
12
  import { Type } from "typebox";
11
- import { pollForToken, requestDeviceCode } from "../auth/device-code.ts";
12
- import { acquireAppToken } from "../auth/client-credentials.ts";
13
- import { clearMemoryCache, forgetToken, storeToken } from "../auth/index.ts";
14
- import { clearAllTokens, deleteToken } from "../auth/token-store.ts";
13
+ import {
14
+ browserUnavailableReason,
15
+ canOpenBrowser,
16
+ defaultSignInMode,
17
+ signIn,
18
+ signOut,
19
+ signOutAll,
20
+ type DeviceCodeInfo,
21
+ type SignInMode,
22
+ } from "../auth/index.ts";
15
23
  import { tokenScopes } from "../auth/jwt.ts";
16
24
  import { getMe } from "../graph/me.ts";
17
25
  import { formatGraphError } from "../utils/errors.ts";
@@ -31,30 +39,64 @@ import {
31
39
  interface LoginParams {
32
40
  account?: string;
33
41
  tenant?: string;
42
+ mode?: string;
43
+ port?: number;
44
+ }
45
+
46
+ type Update = (update: { content: Array<{ type: "text"; text: string }> }) => void;
47
+
48
+ /** What the user has to do, rendered the moment MSAL tells us. */
49
+ function deviceCodeInstructions(label: string, info: DeviceCodeInfo, why?: string): string {
50
+ return [
51
+ `## Sign in to Teams (${label})`,
52
+ "",
53
+ why ? `_Using the device code flow because ${why}._` : "",
54
+ "",
55
+ `1. Open **${info.verificationUri}**`,
56
+ `2. Enter the code: **${info.userCode}**`,
57
+ "3. Sign in with the account pi should act as.",
58
+ "",
59
+ `The code expires in ${Math.round(info.expiresIn / 60)} minutes. Waiting…`,
60
+ ]
61
+ .filter((line) => line !== "")
62
+ .join("\n");
34
63
  }
35
64
 
36
65
  export const teamsLoginTool = {
37
66
  name: "teams_login",
38
67
  description:
39
- "Sign in to Microsoft Teams as yourself using the device code flow. Shows a short code and a URL; " +
40
- "open the URL, enter the code, and sign in with your normal credentials. The session is saved " +
41
- "(including a refresh token) so this is only needed once per account/tenant. " +
42
- "Use the 'account' and 'tenant' parameters to sign in to a second company or a guest tenant.",
68
+ "Sign in to Microsoft Teams as yourself. By default this opens your browser at the Microsoft sign-in " +
69
+ "page and completes automatically — nothing to type. On a machine with no browser (SSH, a container) " +
70
+ "it falls back to the device code flow and shows a short code to enter on another device. " +
71
+ "The session is saved and renewed silently, so this is normally needed once per account. " +
72
+ "Use 'account' and 'tenant' to sign in to a second company or a guest tenant.",
43
73
  parameters: Type.Object({
44
74
  account: AccountParam,
45
75
  tenant: TenantParam,
76
+ mode: Type.Optional(
77
+ Type.String({
78
+ description:
79
+ "Force a flow: 'interactive' (browser) or 'device-code'. Omit to let pi choose for this machine.",
80
+ }),
81
+ ),
82
+ port: Type.Optional(
83
+ Type.Number({
84
+ description:
85
+ "Fixed loopback port for the browser redirect. Only needed when the app registration lists an exact redirect URI such as http://localhost:3000.",
86
+ }),
87
+ ),
46
88
  }),
47
89
  promptSnippet: "Sign in to Microsoft Teams as the user",
48
90
  promptGuidelines: [
49
- "Use teams_login when a Teams tool reports that pi is not signed in, or when the user wants to add a second account.",
50
- "Show the user code and the verification URL to the user verbatim — they cannot complete sign-in without them.",
91
+ "Use teams_login when a Teams tool reports that pi is not signed in, or when the user wants to add a second account or tenant.",
92
+ "If the device code flow is used, show the user code and URL to the user verbatim — they cannot finish signing in without them.",
51
93
  ],
52
94
 
53
95
  async execute(
54
96
  _toolCallId: string,
55
97
  params: LoginParams,
56
98
  signal: AbortSignal | undefined,
57
- onUpdate: ((update: { content: Array<{ type: "text"; text: string }> }) => void) | undefined,
99
+ onUpdate: Update | undefined,
58
100
  ctx: ToolContext,
59
101
  ): Promise<ToolResult> {
60
102
  return run(async () => {
@@ -63,8 +105,7 @@ export const teamsLoginTool = {
63
105
 
64
106
  // App-only needs no user interaction at all.
65
107
  if (conn.authMode === "client-credentials") {
66
- const token = await acquireAppToken(conn, signal);
67
- storeToken(conn, token);
108
+ const token = await signIn(conn, { signal });
68
109
  return textResult(
69
110
  [
70
111
  `✅ Acquired an app-only token for ${label}.`,
@@ -72,50 +113,60 @@ export const teamsLoginTool = {
72
113
  `Granted app roles: ${tokenScopes(token.accessToken).join(", ") || "(none visible in the token)"}`,
73
114
  "",
74
115
  "Note: app-only tokens cannot post messages as a person. Switch this account to " +
75
- 'authMode "device-code" if pi should act as you.',
116
+ 'authMode "interactive" if pi should act as you.',
76
117
  ].join("\n"),
77
118
  { account: conn.account, tenant: conn.tenant, authMode: "client-credentials" },
78
119
  );
79
120
  }
80
121
 
81
- const challenge = await requestDeviceCode(conn, signal);
122
+ const requested = params.mode?.toLowerCase();
123
+ if (requested && requested !== "interactive" && requested !== "device-code") {
124
+ return errorResult(`mode must be 'interactive' or 'device-code', not "${params.mode}".`);
125
+ }
82
126
 
83
- const instructions = [
84
- `## Sign in to Teams (${label})`,
85
- "",
86
- `1. Open **${challenge.verificationUri}**`,
87
- `2. Enter the code: **${challenge.userCode}**`,
88
- "3. Sign in with the account pi should act as.",
89
- "",
90
- `The code expires at ${new Date(challenge.expiresAt).toLocaleTimeString()}. Waiting…`,
91
- ].join("\n");
127
+ let mode: SignInMode = (requested as SignInMode | undefined) ?? defaultSignInMode();
128
+ let fallbackReason: string | undefined;
92
129
 
93
- onUpdate?.({ content: [{ type: "text", text: instructions }] });
130
+ // An explicit request for the browser on a machine that has none would
131
+ // hang until the timeout; say so and use the code flow instead.
132
+ if (mode === "interactive" && !canOpenBrowser()) {
133
+ fallbackReason = browserUnavailableReason();
134
+ mode = "device-code";
135
+ } else if (mode === "device-code" && !requested && !canOpenBrowser()) {
136
+ fallbackReason = browserUnavailableReason();
137
+ }
138
+
139
+ if (mode === "interactive") {
140
+ onUpdate?.({
141
+ content: [
142
+ {
143
+ type: "text",
144
+ text: [
145
+ `## Signing in to Teams (${label})`,
146
+ "",
147
+ "Opening your browser. Sign in there with the account pi should act as —",
148
+ "the page closes itself when you are done.",
149
+ ].join("\n"),
150
+ },
151
+ ],
152
+ });
153
+ }
94
154
 
95
- let lastNotice = 0;
96
- const token = await pollForToken(conn, challenge, {
155
+ const result = await signIn(conn, {
156
+ mode,
97
157
  signal,
98
- onPending: (secondsLeft) => {
99
- // One nudge every 30s — enough to show progress, not enough to spam.
100
- if (secondsLeft > 0 && Date.now() - lastNotice > 30_000) {
101
- lastNotice = Date.now();
102
- onUpdate?.({
103
- content: [
104
- {
105
- type: "text",
106
- text: `${instructions}\n\nStill waiting for sign-in… (${secondsLeft}s left)`,
107
- },
108
- ],
109
- });
110
- }
158
+ loopbackPort: params.port ?? conn.loopbackPort,
159
+ onDeviceCode: (info) => {
160
+ onUpdate?.({
161
+ content: [{ type: "text", text: deviceCodeInstructions(label, info, fallbackReason) }],
162
+ });
111
163
  },
112
164
  });
113
165
 
114
- storeToken(conn, token);
115
166
  clearUserCache();
116
167
 
117
- // Confirm the token actually works and report who pi now is.
118
- let identity = token.user?.upn ?? token.user?.displayName ?? "(unknown)";
168
+ // Confirm the token actually works, and report who pi now is.
169
+ let identity = result.account?.username ?? result.account?.name ?? "(unknown)";
119
170
  let jobTitle: string | undefined;
120
171
  try {
121
172
  const me = await getMe(conn, signal);
@@ -132,16 +183,17 @@ export const teamsLoginTool = {
132
183
  );
133
184
  }
134
185
 
135
- const granted = tokenScopes(token.accessToken);
136
- const requested = conn.scopes.filter((s) => !["openid", "profile", "offline_access"].includes(s));
137
- const missing = requested.filter(
138
- (scope) => !granted.some((g) => g.toLowerCase() === scope.toLowerCase()),
186
+ const granted = tokenScopes(result.accessToken);
187
+ const missing = conn.scopes.filter(
188
+ (scope) => !granted.some((entry) => entry.toLowerCase() === scope.toLowerCase()),
139
189
  );
140
190
 
141
191
  return textResult(
142
192
  [
143
193
  `✅ Signed in to ${label} as **${identity}**${jobTitle ? ` (${jobTitle})` : ""}.`,
144
194
  "",
195
+ `Flow: ${result.mode === "interactive" ? "browser sign-in" : "device code"}` +
196
+ `${fallbackReason ? ` (${fallbackReason})` : ""}`,
145
197
  `pi now acts as this user in Teams. Safety level: **${conn.safetyLevel}**.`,
146
198
  missing.length > 0
147
199
  ? `\n⚠️ Not granted: ${missing.join(", ")}\nSome tools will fail until an admin consents to these scopes.`
@@ -151,6 +203,7 @@ export const teamsLoginTool = {
151
203
  account: conn.account,
152
204
  tenant: conn.tenant,
153
205
  user: identity,
206
+ mode: result.mode,
154
207
  grantedScopes: granted,
155
208
  missingScopes: missing,
156
209
  },
@@ -162,12 +215,12 @@ export const teamsLoginTool = {
162
215
  export const teamsLogoutTool = {
163
216
  name: "teams_logout",
164
217
  description:
165
- "Sign out of a Teams account: removes the saved access and refresh tokens from " +
166
- "~/.pi/agent/pi-teams-tokens.json. The account stays configured, so teams_login can sign in again.",
218
+ "Sign out of a Teams account: removes the saved session from ~/.pi/agent/pi-teams-tokens/. " +
219
+ "The account stays configured, so teams_login can sign in again.",
167
220
  parameters: Type.Object({
168
221
  account: AccountParam,
169
222
  tenant: TenantParam,
170
- all: Type.Optional(Type.Boolean({ description: "Remove the cached tokens of every account" })),
223
+ all: Type.Optional(Type.Boolean({ description: "Remove the saved sessions of every account" })),
171
224
  }),
172
225
  promptSnippet: "Sign out of a Teams account",
173
226
 
@@ -182,14 +235,12 @@ export const teamsLogoutTool = {
182
235
  clearUserCache();
183
236
 
184
237
  if (params.all) {
185
- const count = clearAllTokens();
186
- clearMemoryCache();
187
- return textResult(`✅ Removed ${count} cached Teams session(s).`, { removed: count });
238
+ const count = signOutAll();
239
+ return textResult(`✅ Removed ${count} saved Teams session(s).`, { removed: count });
188
240
  }
189
241
 
190
242
  const conn = connectionFor(ctx, params.account, params.tenant);
191
- const removed = deleteToken(conn.account, conn.tenantId);
192
- forgetToken(conn);
243
+ const removed = await signOut(conn);
193
244
 
194
245
  return removed
195
246
  ? textResult(`✅ Signed out of ${connectionLabel(conn)}.`, { account: conn.account })
@@ -27,6 +27,7 @@ interface SetupParams {
27
27
  displayName?: string;
28
28
  clientSecret?: string;
29
29
  authMode?: string;
30
+ loopbackPort?: number;
30
31
  safetyLevel?: string;
31
32
  parentAccount?: string;
32
33
  setDefault?: boolean;
@@ -116,7 +117,14 @@ export const teamsSetupTool = {
116
117
  authMode: Type.Optional(
117
118
  Type.String({
118
119
  description:
119
- "'device-code' (act as the signed-in user, default), 'client-credentials' (app-only, cannot post as a person), or 'auto'",
120
+ "'interactive' (browser sign-in, acts as the user — default), 'device-code' (same, but for machines with no browser), " +
121
+ "'client-credentials' (app-only, cannot post as a person), or 'auto' to decide per machine",
122
+ }),
123
+ ),
124
+ loopbackPort: Type.Optional(
125
+ Type.Number({
126
+ description:
127
+ "Fixed port for the browser sign-in redirect. Only needed when the app registration lists an exact redirect URI such as http://localhost:3000.",
120
128
  }),
121
129
  ),
122
130
  safetyLevel: Type.Optional(
@@ -179,6 +187,7 @@ export const teamsSetupTool = {
179
187
  clientSecret: params.clientSecret,
180
188
  authMode: params.authMode as TenantConfig["authMode"],
181
189
  scopes: params.scopes,
190
+ loopbackPort: params.loopbackPort,
182
191
  safetyLevel,
183
192
  permissions,
184
193
  };
@@ -211,8 +220,9 @@ export const teamsSetupTool = {
211
220
  tenantId: params.tenantId,
212
221
  clientId: params.clientId,
213
222
  clientSecret: params.clientSecret,
214
- authMode: (params.authMode as AccountConfig["authMode"]) ?? "device-code",
223
+ authMode: (params.authMode as AccountConfig["authMode"]) ?? "interactive",
215
224
  scopes: params.scopes,
225
+ loopbackPort: params.loopbackPort,
216
226
  safetyLevel: safetyLevel ?? "confirm",
217
227
  permissions,
218
228
  };
@@ -4,7 +4,7 @@
4
4
 
5
5
  import { Type } from "typebox";
6
6
  import { buildConnectionCard, formatStatusText } from "../status.ts";
7
- import { getToken, isFresh } from "../auth/token-store.ts";
7
+ import { readCacheSummary } from "../auth/cache-plugin.ts";
8
8
  import { readRootConfig } from "../config/index.ts";
9
9
  import {
10
10
  AccountParam,
@@ -47,13 +47,13 @@ export const teamsStatusTool = {
47
47
  ...(account.tenants ?? []).map((t) => ({ name: `${account.name}/${t.name}`, tenantId: t.tenantId })),
48
48
  ];
49
49
  for (const entry of tenants) {
50
- const token = getToken(account.name, entry.tenantId);
51
- const state = isFresh(token)
52
- ? "signed in"
53
- : token?.refreshToken
54
- ? "session saved (will refresh)"
55
- : "not signed in";
56
- const who = token?.user?.upn ? ` — ${token.user.upn}` : "";
50
+ const cache = readCacheSummary(account.name, entry.tenantId);
51
+ const state = !cache.present
52
+ ? "not signed in"
53
+ : cache.fresh
54
+ ? "signed in"
55
+ : "session saved (renews on next use)";
56
+ const who = cache.username ? ` — ${cache.username}` : "";
57
57
  others.push(`- ${entry.name}: ${state}${who}`);
58
58
  }
59
59
  }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Environment detection.
3
+ *
4
+ * Deliberately dependency-free: both the config layer and the auth layer need
5
+ * to know whether a browser is reachable, and neither should have to import
6
+ * the other to find out.
7
+ */
8
+
9
+ /**
10
+ * Can we realistically put a browser in front of this user?
11
+ *
12
+ * The interactive sign-in needs two things: a browser to open, and a loopback
13
+ * listener that browser can reach. Over SSH both assumptions break — `open`
14
+ * would launch a browser on the *remote* machine, where nobody is looking — so
15
+ * those sessions get the device code flow instead.
16
+ *
17
+ * Set `PI_TEAMS_NO_BROWSER=1` to force that fallback anywhere.
18
+ */
19
+ export function canOpenBrowser(): boolean {
20
+ if (process.env.PI_TEAMS_NO_BROWSER) return false;
21
+
22
+ // A remote shell: the browser would appear on the wrong machine.
23
+ if (process.env.SSH_CONNECTION || process.env.SSH_TTY || process.env.SSH_CLIENT) return false;
24
+
25
+ if (process.platform === "darwin" || process.platform === "win32") return true;
26
+
27
+ // Linux and the BSDs need a display server for a browser to appear at all.
28
+ return !!(process.env.DISPLAY || process.env.WAYLAND_DISPLAY);
29
+ }
30
+
31
+ /** Why the interactive flow is unavailable, phrased for a user-facing message. */
32
+ export function browserUnavailableReason(): string {
33
+ if (process.env.PI_TEAMS_NO_BROWSER) return "PI_TEAMS_NO_BROWSER is set";
34
+ if (process.env.SSH_CONNECTION || process.env.SSH_TTY || process.env.SSH_CLIENT) {
35
+ return "this is an SSH session, so the browser would open on the wrong machine";
36
+ }
37
+ if (process.platform !== "darwin" && process.platform !== "win32") {
38
+ return "no display server is available (DISPLAY and WAYLAND_DISPLAY are unset)";
39
+ }
40
+ return "no browser is available";
41
+ }
@@ -1,72 +0,0 @@
1
- /**
2
- * Client credentials grant — app-only access, no user involved.
3
- *
4
- * Useful for unattended jobs (a nightly digest, a scheduled report) against
5
- * directory and presence data. It is deliberately *not* a way to post as
6
- * somebody: Microsoft Graph only accepts app-only writes to chats and channels
7
- * for migration scenarios (`Teamwork.Migrate.All`), so this extension refuses
8
- * message sends on an app-only token instead of letting them fail deep inside
9
- * a Graph call. See ../safety/index.ts.
10
- */
11
-
12
- import type { TeamsConnection } from "../config/index.ts";
13
- import { AuthError, tokenUrl } from "./device-code.ts";
14
- import type { CachedToken } from "./token-store.ts";
15
-
16
- interface TokenResponse {
17
- access_token: string;
18
- expires_in: number;
19
- scope?: string;
20
- token_type: string;
21
- error?: string;
22
- error_description?: string;
23
- }
24
-
25
- /**
26
- * Acquire an app-only token.
27
- *
28
- * @throws {AuthError} when the secret is missing, expired, or the app has no
29
- * admin-consented application permissions.
30
- */
31
- export async function acquireAppToken(
32
- conn: TeamsConnection,
33
- signal?: AbortSignal,
34
- ): Promise<CachedToken> {
35
- if (!conn.clientSecret) {
36
- throw new AuthError(
37
- "missing_client_secret",
38
- `Account "${conn.account}" uses client-credentials but no clientSecret is configured.`,
39
- );
40
- }
41
-
42
- const body = new URLSearchParams({
43
- grant_type: "client_credentials",
44
- client_id: conn.clientId,
45
- client_secret: conn.clientSecret,
46
- scope: conn.scopes.length === 1 ? conn.scopes[0]! : "https://graph.microsoft.com/.default",
47
- });
48
-
49
- const response = await fetch(tokenUrl(conn.authorityHost, conn.tenantId), {
50
- method: "POST",
51
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
52
- body: body.toString(),
53
- signal,
54
- });
55
-
56
- const json = (await response.json()) as TokenResponse;
57
-
58
- if (!response.ok || !json.access_token) {
59
- throw new AuthError(
60
- json.error ?? `http_${response.status}`,
61
- json.error_description ?? `Client credentials request failed with HTTP ${response.status}`,
62
- );
63
- }
64
-
65
- return {
66
- accessToken: json.access_token,
67
- expiresAt: Date.now() + Math.max(0, json.expires_in - 60) * 1000,
68
- scopes: json.scope ? json.scope.split(" ") : [],
69
- authMode: "client-credentials",
70
- updatedAt: new Date().toISOString(),
71
- };
72
- }