|
4 | 4 | // requiring users to download the repository Justfile or helper scripts. |
5 | 5 |
|
6 | 6 | import { createHash } from "node:crypto"; |
| 7 | +import { delimiter } from "node:path"; |
7 | 8 | import { spawnSync } from "node:child_process"; |
8 | 9 | import { |
9 | 10 | existsSync, |
@@ -1142,10 +1143,13 @@ function refreshM365Servers(argv: string[], contentRoot: string): void { |
1142 | 1143 | const endpoint = catalog.discoverEndpoint; |
1143 | 1144 | if (!endpoint) fail("M365 catalog is missing discoverEndpoint"); |
1144 | 1145 |
|
1145 | | - const token = args.token ?? loadTokenFromCache(); |
| 1146 | + const token = |
| 1147 | + args.token ?? |
| 1148 | + loadTokenFromCache() ?? |
| 1149 | + acquireDiscoveryToken(catalog, args, contentRoot); |
1146 | 1150 | if (!token) { |
1147 | 1151 | fail( |
1148 | | - "No bearer token found. Provide --token <bearer>, or connect any work-iq-* server once to seed ~/.hyperagent/mcp-tokens/.", |
| 1152 | + "No bearer token found. Provide --token <bearer>, run hyperagent --mcp-m365-create-app, or connect any work-iq-* server once to seed ~/.hyperagent/mcp-tokens/.", |
1149 | 1153 | ); |
1150 | 1154 | } |
1151 | 1155 |
|
@@ -1258,22 +1262,174 @@ process.stdout.write(await response.text()); |
1258 | 1262 | } |
1259 | 1263 | } |
1260 | 1264 |
|
1261 | | -function parseRefreshArgs(argv: string[]): { |
| 1265 | +function acquireDiscoveryToken( |
| 1266 | + catalog: Catalog, |
| 1267 | + args: RefreshArgs, |
| 1268 | + contentRoot: string, |
| 1269 | +): string | undefined { |
| 1270 | + const state = readJson<SavedM365State>(M365_STATE_FILE); |
| 1271 | + const clientId = args.clientId ?? state?.clientId; |
| 1272 | + const tenantId = args.tenantId ?? state?.tenantId; |
| 1273 | + const scope = |
| 1274 | + args.scope ?? `${catalog.resourceId ?? AGENT365_RESOURCE_ID}/.default`; |
| 1275 | + if (!clientId) return undefined; |
| 1276 | + |
| 1277 | + logStep(`No cached token found; acquiring one with MSAL (${args.flow})`); |
| 1278 | + const result = spawnSync( |
| 1279 | + process.execPath, |
| 1280 | + [ |
| 1281 | + "--input-type=module", |
| 1282 | + "-e", |
| 1283 | + ` |
| 1284 | +import { createRequire } from "node:module"; |
| 1285 | +import { execFile } from "node:child_process"; |
| 1286 | +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; |
| 1287 | +import { homedir } from "node:os"; |
| 1288 | +import { join } from "node:path"; |
| 1289 | +
|
| 1290 | +const require = createRequire(import.meta.url); |
| 1291 | +const msal = require("@azure/msal-node"); |
| 1292 | +const cacheDir = join(homedir(), ".hyperagent", "mcp-tokens"); |
| 1293 | +const cacheFile = join(cacheDir, "m365-discovery.msal.json"); |
| 1294 | +const clientId = process.env.HYPERAGENT_M365_CLIENT_ID; |
| 1295 | +const tenantId = process.env.HYPERAGENT_M365_TENANT_ID; |
| 1296 | +const scope = process.env.HYPERAGENT_M365_SCOPE; |
| 1297 | +const flow = process.env.HYPERAGENT_M365_FLOW; |
| 1298 | +if (!clientId || !scope || !flow) process.exit(2); |
| 1299 | +
|
| 1300 | +function openBrowser(url) { |
| 1301 | + console.error("[mcp] If the browser doesn't open, visit:"); |
| 1302 | + console.error("[mcp] " + url); |
| 1303 | + const [bin, args] = process.platform === "darwin" |
| 1304 | + ? ["open", [url]] |
| 1305 | + : process.platform === "win32" |
| 1306 | + ? ["cmd.exe", ["/c", "start", "", url]] |
| 1307 | + : ["xdg-open", [url]]; |
| 1308 | + execFile(bin, args, { timeout: 10000 }, () => {}); |
| 1309 | +} |
| 1310 | +
|
| 1311 | +const cachePlugin = { |
| 1312 | + async beforeCacheAccess(ctx) { |
| 1313 | + if (existsSync(cacheFile)) ctx.tokenCache.deserialize(readFileSync(cacheFile, "utf8")); |
| 1314 | + }, |
| 1315 | + async afterCacheAccess(ctx) { |
| 1316 | + if (ctx.cacheHasChanged) { |
| 1317 | + mkdirSync(cacheDir, { recursive: true, mode: 0o700 }); |
| 1318 | + writeFileSync(cacheFile, ctx.tokenCache.serialize(), { mode: 0o600 }); |
| 1319 | + } |
| 1320 | + }, |
| 1321 | +}; |
| 1322 | +
|
| 1323 | +const pca = new msal.PublicClientApplication({ |
| 1324 | + auth: { |
| 1325 | + clientId, |
| 1326 | + authority: tenantId |
| 1327 | + ? \`https://login.microsoftonline.com/\${tenantId}\` |
| 1328 | + : "https://login.microsoftonline.com/organizations", |
| 1329 | + }, |
| 1330 | + cache: { cachePlugin }, |
| 1331 | + system: { loggerOptions: { logLevel: msal.LogLevel.Warning } }, |
| 1332 | +}); |
| 1333 | +
|
| 1334 | +const scopes = [scope, "offline_access"]; |
| 1335 | +const accounts = await pca.getAllAccounts(); |
| 1336 | +let token = null; |
| 1337 | +if (accounts.length > 0) { |
| 1338 | + try { |
| 1339 | + token = await pca.acquireTokenSilent({ account: accounts[0], scopes }); |
| 1340 | + } catch {} |
| 1341 | +} |
| 1342 | +if (!token && flow === "device-code") { |
| 1343 | + token = await pca.acquireTokenByDeviceCode({ |
| 1344 | + scopes, |
| 1345 | + deviceCodeCallback: (response) => { |
| 1346 | + console.error(""); |
| 1347 | + console.error("[mcp] 🔐 Device code authentication required"); |
| 1348 | + console.error("[mcp] " + response.message); |
| 1349 | + console.error(""); |
| 1350 | + }, |
| 1351 | + }); |
| 1352 | +} |
| 1353 | +if (!token && flow === "browser") { |
| 1354 | + console.error("[mcp] 🔐 Opening browser for authentication..."); |
| 1355 | + token = await pca.acquireTokenInteractive({ |
| 1356 | + scopes, |
| 1357 | + openBrowser: async (url) => openBrowser(url), |
| 1358 | + successTemplate: "<html><body><h1>Authentication Successful</h1><p>You can close this window and return to HyperAgent.</p><script>setTimeout(()=>window.close(),2000)</script></body></html>", |
| 1359 | + errorTemplate: "<html><body><h1>Authentication Failed</h1><p>Check the terminal for details.</p></body></html>", |
| 1360 | + }); |
| 1361 | +} |
| 1362 | +if (!token?.accessToken) throw new Error("MSAL did not return an access token"); |
| 1363 | +process.stdout.write(token.accessToken); |
| 1364 | +`, |
| 1365 | + ], |
| 1366 | + { |
| 1367 | + encoding: "utf8", |
| 1368 | + env: { |
| 1369 | + ...process.env, |
| 1370 | + NODE_PATH: nodePathForContentRoot(contentRoot), |
| 1371 | + HYPERAGENT_M365_CLIENT_ID: clientId, |
| 1372 | + HYPERAGENT_M365_TENANT_ID: tenantId ?? "", |
| 1373 | + HYPERAGENT_M365_SCOPE: scope, |
| 1374 | + HYPERAGENT_M365_FLOW: args.flow, |
| 1375 | + }, |
| 1376 | + maxBuffer: 10 * 1024 * 1024, |
| 1377 | + stdio: ["ignore", "pipe", "inherit"], |
| 1378 | + }, |
| 1379 | + ); |
| 1380 | + |
| 1381 | + if (result.error) |
| 1382 | + fail(`MSAL token acquisition failed: ${result.error.message}`); |
| 1383 | + if (result.status !== 0) fail("MSAL token acquisition failed"); |
| 1384 | + const token = result.stdout.trim(); |
| 1385 | + if (token) logSuccess("Acquired discovery token with MSAL"); |
| 1386 | + return token || undefined; |
| 1387 | +} |
| 1388 | + |
| 1389 | +function nodePathForContentRoot(contentRoot: string): string { |
| 1390 | + const entries = [ |
| 1391 | + join(contentRoot, "node_modules"), |
| 1392 | + join(contentRoot, "..", "..", "node_modules"), |
| 1393 | + process.env.NODE_PATH ?? "", |
| 1394 | + ].filter(Boolean); |
| 1395 | + return entries.join(delimiter); |
| 1396 | +} |
| 1397 | + |
| 1398 | +interface RefreshArgs { |
1262 | 1399 | token?: string; |
| 1400 | + clientId?: string; |
| 1401 | + tenantId?: string; |
| 1402 | + scope?: string; |
| 1403 | + flow: "browser" | "device-code"; |
1263 | 1404 | includeCustom: boolean; |
1264 | | -} { |
1265 | | - const parsed: { token?: string; includeCustom: boolean } = { |
| 1405 | +} |
| 1406 | + |
| 1407 | +function parseRefreshArgs(argv: string[]): RefreshArgs { |
| 1408 | + const parsed: RefreshArgs = { |
| 1409 | + flow: "browser", |
1266 | 1410 | includeCustom: false, |
1267 | 1411 | }; |
1268 | 1412 | for (let index = 0; index < argv.length; index++) { |
1269 | 1413 | const arg = argv[index]; |
1270 | 1414 | if (arg === "--token" && index + 1 < argv.length) { |
1271 | 1415 | parsed.token = argv[++index]; |
| 1416 | + } else if (arg === "--client-id" && index + 1 < argv.length) { |
| 1417 | + parsed.clientId = argv[++index]; |
| 1418 | + } else if (arg === "--tenant-id" && index + 1 < argv.length) { |
| 1419 | + parsed.tenantId = argv[++index]; |
| 1420 | + } else if (arg === "--scope" && index + 1 < argv.length) { |
| 1421 | + parsed.scope = argv[++index]; |
| 1422 | + } else if (arg === "--flow" && index + 1 < argv.length) { |
| 1423 | + const flow = argv[++index]; |
| 1424 | + if (flow !== "browser" && flow !== "device-code") { |
| 1425 | + fail(`--flow must be "browser" or "device-code" (got: ${flow})`); |
| 1426 | + } |
| 1427 | + parsed.flow = flow; |
1272 | 1428 | } else if (arg === "--include-custom") { |
1273 | 1429 | parsed.includeCustom = true; |
1274 | 1430 | } else if (arg === "--help" || arg === "-h") { |
1275 | 1431 | console.log( |
1276 | | - "Usage: hyperagent --mcp-m365-refresh-servers [--token <bearer>] [--include-custom]", |
| 1432 | + "Usage: hyperagent --mcp-m365-refresh-servers [--token <bearer>] [--client-id ID] [--tenant-id ID] [--scope SCOPE] [--flow browser|device-code] [--include-custom]", |
1277 | 1433 | ); |
1278 | 1434 | process.exit(0); |
1279 | 1435 | } else { |
|
0 commit comments