@michael-joseph-miller/ant-bot 0.2.3 → 0.3.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.
package/dist/server.js CHANGED
@@ -18,7 +18,7 @@ import {
18
18
  SettingsSchema,
19
19
  UpdateBotRequest,
20
20
  UpdateConnectorRequest
21
- } from "./chunk-F3D2K4WZ.js";
21
+ } from "./chunk-I3GHIX3G.js";
22
22
  import {
23
23
  findWebDist,
24
24
  nodeLocateDeps,
@@ -2097,6 +2097,371 @@ function buildMcpServerConfig(connector, secrets) {
2097
2097
  };
2098
2098
  }
2099
2099
 
2100
+ // daemon/src/connectors/auth.ts
2101
+ import crypto2 from "node:crypto";
2102
+
2103
+ // daemon/src/connectors/oauth.ts
2104
+ import crypto from "node:crypto";
2105
+ function parseResourceMetadataUrl(wwwAuthenticate) {
2106
+ if (!wwwAuthenticate) return null;
2107
+ const m = /resource_metadata\s*=\s*"([^"]+)"/i.exec(wwwAuthenticate);
2108
+ return m ? m[1] : null;
2109
+ }
2110
+ function parseProtectedResourceMetadata(body) {
2111
+ const b2 = body;
2112
+ const servers = b2?.authorization_servers;
2113
+ if (!Array.isArray(servers) || servers.length === 0) return null;
2114
+ return {
2115
+ authorizationServers: servers.map(String),
2116
+ scopesSupported: Array.isArray(b2?.scopes_supported) ? b2.scopes_supported.map(String) : [],
2117
+ resource: typeof b2?.resource === "string" ? b2.resource : void 0
2118
+ };
2119
+ }
2120
+ function parseAuthServerMetadata(body) {
2121
+ const b2 = body;
2122
+ const auth = b2?.authorization_endpoint;
2123
+ const token = b2?.token_endpoint;
2124
+ if (typeof auth !== "string" || typeof token !== "string") return null;
2125
+ return {
2126
+ authorizationEndpoint: auth,
2127
+ tokenEndpoint: token,
2128
+ registrationEndpoint: typeof b2?.registration_endpoint === "string" ? b2.registration_endpoint : void 0,
2129
+ scopesSupported: Array.isArray(b2?.scopes_supported) ? b2.scopes_supported.map(String) : []
2130
+ };
2131
+ }
2132
+ function authServerMetadataUrls(issuer) {
2133
+ const u = new URL(issuer);
2134
+ const path12 = u.pathname.replace(/\/$/, "");
2135
+ const base = `${u.protocol}//${u.host}`;
2136
+ return [
2137
+ `${base}/.well-known/oauth-authorization-server${path12}`,
2138
+ `${base}/.well-known/openid-configuration${path12}`,
2139
+ `${base}${path12}/.well-known/oauth-authorization-server`,
2140
+ `${base}${path12}/.well-known/openid-configuration`
2141
+ ];
2142
+ }
2143
+ function createPkce() {
2144
+ const verifier = crypto.randomBytes(32).toString("base64url");
2145
+ const challenge = crypto.createHash("sha256").update(verifier).digest("base64url");
2146
+ return { verifier, challenge };
2147
+ }
2148
+ function buildAuthorizeUrl(i2) {
2149
+ const u = new URL(i2.authorizationEndpoint);
2150
+ const p = u.searchParams;
2151
+ p.set("response_type", "code");
2152
+ p.set("client_id", i2.clientId);
2153
+ p.set("redirect_uri", i2.redirectUri);
2154
+ p.set("state", i2.state);
2155
+ p.set("code_challenge", i2.challenge);
2156
+ p.set("code_challenge_method", "S256");
2157
+ if (i2.scopes.length) p.set("scope", i2.scopes.join(" "));
2158
+ if (i2.resource) p.set("resource", i2.resource);
2159
+ for (const [k, v] of Object.entries(i2.extra ?? {})) p.set(k, v);
2160
+ return u.toString();
2161
+ }
2162
+ function parseTokenResponse(body, ctx, now2) {
2163
+ const b2 = body;
2164
+ if (typeof b2?.access_token !== "string") return null;
2165
+ const expiresIn = typeof b2.expires_in === "number" ? b2.expires_in : void 0;
2166
+ return {
2167
+ accessToken: b2.access_token,
2168
+ // A refresh response often omits refresh_token, meaning "keep using the one you have".
2169
+ refreshToken: typeof b2.refresh_token === "string" ? b2.refresh_token : ctx.previousRefresh,
2170
+ expiresAt: expiresIn ? now2 + expiresIn * 1e3 : void 0,
2171
+ scope: typeof b2.scope === "string" ? b2.scope : void 0,
2172
+ tokenEndpoint: ctx.tokenEndpoint,
2173
+ clientId: ctx.clientId,
2174
+ clientSecret: ctx.clientSecret,
2175
+ resource: ctx.resource
2176
+ };
2177
+ }
2178
+ function needsRefresh(tokens, now2, skewMs = 6e4) {
2179
+ if (!tokens.expiresAt) return false;
2180
+ return now2 + skewMs >= tokens.expiresAt;
2181
+ }
2182
+ var JSON_HEADERS = { accept: "application/json" };
2183
+ var FETCH_TIMEOUT_MS = 15e3;
2184
+ async function getJson(url) {
2185
+ try {
2186
+ const res = await fetch(url, { headers: JSON_HEADERS, signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
2187
+ return res.ok ? await res.json() : null;
2188
+ } catch {
2189
+ return null;
2190
+ }
2191
+ }
2192
+ var OAuthError = class extends Error {
2193
+ constructor(message) {
2194
+ super(message);
2195
+ this.name = "OAuthError";
2196
+ }
2197
+ };
2198
+ function resourceMetadataCandidates(mcpUrl, wwwAuthenticate) {
2199
+ const u = new URL(mcpUrl);
2200
+ const path12 = u.pathname.replace(/\/$/, "");
2201
+ const out = [];
2202
+ const hint = parseResourceMetadataUrl(wwwAuthenticate);
2203
+ if (hint) out.push(hint);
2204
+ out.push(`${u.origin}/.well-known/oauth-protected-resource${path12}`);
2205
+ out.push(`${u.origin}/.well-known/oauth-protected-resource`);
2206
+ return [...new Set(out)];
2207
+ }
2208
+ async function firstResourceMetadata(mcpUrl, wwwAuthenticate) {
2209
+ for (const url of resourceMetadataCandidates(mcpUrl, wwwAuthenticate)) {
2210
+ const meta = parseProtectedResourceMetadata(await getJson(url));
2211
+ if (meta) return meta;
2212
+ }
2213
+ return null;
2214
+ }
2215
+ async function discoverAuth(mcpUrl, headers = {}) {
2216
+ let challenge;
2217
+ try {
2218
+ const res = await fetch(mcpUrl, {
2219
+ method: "POST",
2220
+ headers: { "content-type": "application/json", accept: "application/json, text/event-stream", ...headers },
2221
+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "__antbot_auth_probe__", arguments: {} } }),
2222
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS)
2223
+ });
2224
+ challenge = res.headers.get("www-authenticate");
2225
+ } catch (err) {
2226
+ throw new OAuthError(`Could not reach ${mcpUrl}: ${err.message}`);
2227
+ }
2228
+ const found = await firstResourceMetadata(mcpUrl, challenge);
2229
+ if (!found) {
2230
+ throw new OAuthError(
2231
+ "This server did not advertise an authorization server, so ant-bot cannot sign in to it. If it takes a static token, add one as an Authorization header instead."
2232
+ );
2233
+ }
2234
+ const resourceMeta = found;
2235
+ for (const issuer of resourceMeta.authorizationServers) {
2236
+ for (const url of authServerMetadataUrls(issuer)) {
2237
+ const meta = parseAuthServerMetadata(await getJson(url));
2238
+ if (meta) return { resource: resourceMeta, authServer: meta };
2239
+ }
2240
+ }
2241
+ throw new OAuthError(`Could not read authorization server metadata for ${resourceMeta.authorizationServers.join(", ")}`);
2242
+ }
2243
+ async function registerClient(registrationEndpoint, redirectUri2) {
2244
+ try {
2245
+ const res = await fetch(registrationEndpoint, {
2246
+ method: "POST",
2247
+ headers: { "content-type": "application/json", ...JSON_HEADERS },
2248
+ body: JSON.stringify({
2249
+ client_name: "ant-bot",
2250
+ redirect_uris: [redirectUri2],
2251
+ grant_types: ["authorization_code", "refresh_token"],
2252
+ response_types: ["code"],
2253
+ token_endpoint_auth_method: "none"
2254
+ }),
2255
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS)
2256
+ });
2257
+ if (!res.ok) return null;
2258
+ const b2 = await res.json();
2259
+ return typeof b2.client_id === "string" ? { clientId: b2.client_id, clientSecret: typeof b2.client_secret === "string" ? b2.client_secret : void 0 } : null;
2260
+ } catch {
2261
+ return null;
2262
+ }
2263
+ }
2264
+ async function postForm(endpoint, form) {
2265
+ const res = await fetch(endpoint, {
2266
+ method: "POST",
2267
+ headers: { "content-type": "application/x-www-form-urlencoded", ...JSON_HEADERS },
2268
+ body: new URLSearchParams(form).toString(),
2269
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS)
2270
+ });
2271
+ const body = await res.json().catch(() => null);
2272
+ if (!res.ok) {
2273
+ const e = body;
2274
+ throw new OAuthError(String(e?.error_description ?? e?.error ?? `token endpoint returned HTTP ${res.status}`));
2275
+ }
2276
+ return body;
2277
+ }
2278
+ async function exchangeCode(input) {
2279
+ const body = await postForm(input.tokenEndpoint, {
2280
+ grant_type: "authorization_code",
2281
+ code: input.code,
2282
+ code_verifier: input.verifier,
2283
+ client_id: input.clientId,
2284
+ redirect_uri: input.redirectUri,
2285
+ ...input.clientSecret ? { client_secret: input.clientSecret } : {},
2286
+ ...input.resource ? { resource: input.resource } : {}
2287
+ });
2288
+ const tokens = parseTokenResponse(body, input, input.now ?? Date.now());
2289
+ if (!tokens) throw new OAuthError("The authorization server did not return an access token.");
2290
+ return tokens;
2291
+ }
2292
+ async function refreshTokens(tokens, now2 = Date.now()) {
2293
+ if (!tokens.refreshToken) throw new OAuthError("No refresh token \u2014 sign in again.");
2294
+ const body = await postForm(tokens.tokenEndpoint, {
2295
+ grant_type: "refresh_token",
2296
+ refresh_token: tokens.refreshToken,
2297
+ client_id: tokens.clientId,
2298
+ ...tokens.clientSecret ? { client_secret: tokens.clientSecret } : {},
2299
+ ...tokens.resource ? { resource: tokens.resource } : {}
2300
+ });
2301
+ const next = parseTokenResponse(body, { ...tokens, previousRefresh: tokens.refreshToken }, now2);
2302
+ if (!next) throw new OAuthError("The authorization server did not return a refreshed access token.");
2303
+ return next;
2304
+ }
2305
+
2306
+ // daemon/src/connectors/auth.ts
2307
+ var log7 = logger("connector-auth");
2308
+ var tokenSecretName = (connectorName) => `antbot:oauth:${connectorName}`;
2309
+ var clientSecretName = (connectorName) => `antbot:oauth-client:${connectorName}`;
2310
+ var redirectUri = (port) => `http://127.0.0.1:${port}/api/connectors/oauth/callback`;
2311
+ var LOGIN_TTL_MS = 10 * 60 * 1e3;
2312
+ var ConnectorAuthService = class {
2313
+ constructor(secrets, port) {
2314
+ this.secrets = secrets;
2315
+ this.port = port;
2316
+ }
2317
+ secrets;
2318
+ port;
2319
+ pending = /* @__PURE__ */ new Map();
2320
+ /** Has this connector been signed in? Names only — never reads a value to answer. */
2321
+ isAuthorized(connectorName) {
2322
+ return this.secrets.list().includes(tokenSecretName(connectorName));
2323
+ }
2324
+ async read(connectorName) {
2325
+ const key = tokenSecretName(connectorName);
2326
+ const found = (await this.secrets.resolve([key])).get(key);
2327
+ if (!found) return null;
2328
+ try {
2329
+ return JSON.parse(found);
2330
+ } catch {
2331
+ log7.warn(`stored tokens for "${connectorName}" are unreadable`);
2332
+ return null;
2333
+ }
2334
+ }
2335
+ async write(connectorName, tokens) {
2336
+ await this.secrets.set(tokenSecretName(connectorName), JSON.stringify(tokens));
2337
+ }
2338
+ async signOut(connectorName) {
2339
+ await this.secrets.remove(tokenSecretName(connectorName));
2340
+ }
2341
+ /** Forget the tokens *and* the registered client. Used when the credentials themselves are wrong. */
2342
+ async forgetClient(connectorName) {
2343
+ await this.secrets.remove(clientSecretName(connectorName));
2344
+ }
2345
+ async readClient(connectorName) {
2346
+ const key = clientSecretName(connectorName);
2347
+ const found = (await this.secrets.resolve([key])).get(key);
2348
+ if (!found) return null;
2349
+ try {
2350
+ return JSON.parse(found);
2351
+ } catch {
2352
+ return null;
2353
+ }
2354
+ }
2355
+ /**
2356
+ * Begin a sign-in. Returns the URL the human must open.
2357
+ *
2358
+ * `clientId` is required only when the authorization server does not support dynamic client
2359
+ * registration — Google being the notable case, where the human supplies one from their own
2360
+ * cloud console. Everything else registers ant-bot automatically.
2361
+ */
2362
+ async beginLogin(connector, opts = {}) {
2363
+ if (connector.config.transport === "stdio") {
2364
+ throw new OAuthError("Sign-in applies to http and sse connectors; a stdio server takes its credentials in env.");
2365
+ }
2366
+ const discovery = await discoverAuth(connector.config.url);
2367
+ const redirect = redirectUri(this.port);
2368
+ const remembered = await this.readClient(connector.name);
2369
+ let clientId = opts.clientId ?? remembered?.clientId;
2370
+ let clientSecret = opts.clientSecret ?? (opts.clientId ? void 0 : remembered?.clientSecret);
2371
+ if (!clientId && discovery.authServer.registrationEndpoint) {
2372
+ const registered = await registerClient(discovery.authServer.registrationEndpoint, redirect);
2373
+ clientId = registered?.clientId;
2374
+ clientSecret = registered?.clientSecret;
2375
+ }
2376
+ if (!clientId) {
2377
+ throw new OAuthError(
2378
+ `${new URL(discovery.authServer.authorizationEndpoint).host} does not support automatic app registration, so it needs a client ID you create yourself. Register one with that provider, add "${redirect}" as an authorised redirect URI, and pass the client ID with --client-id.`
2379
+ );
2380
+ }
2381
+ if (opts.clientId || opts.clientSecret || !remembered) {
2382
+ await this.secrets.set(clientSecretName(connector.name), JSON.stringify({ clientId, clientSecret }));
2383
+ }
2384
+ const pkce = createPkce();
2385
+ const state = crypto2.randomBytes(16).toString("base64url");
2386
+ this.pending.set(state, {
2387
+ connectorId: connector.id,
2388
+ connectorName: connector.name,
2389
+ verifier: pkce.verifier,
2390
+ clientId,
2391
+ clientSecret,
2392
+ tokenEndpoint: discovery.authServer.tokenEndpoint,
2393
+ resource: discovery.resource.resource,
2394
+ redirectUri: redirect,
2395
+ startedAt: Date.now()
2396
+ });
2397
+ this.sweep();
2398
+ const authorizeUrl = buildAuthorizeUrl({
2399
+ authorizationEndpoint: discovery.authServer.authorizationEndpoint,
2400
+ clientId,
2401
+ redirectUri: redirect,
2402
+ scopes: opts.scopes?.length ? opts.scopes : discovery.resource.scopesSupported,
2403
+ state,
2404
+ challenge: pkce.challenge,
2405
+ resource: discovery.resource.resource,
2406
+ // Without these Google issues no refresh token, and the connector dies in an hour.
2407
+ extra: { access_type: "offline", prompt: "consent" }
2408
+ });
2409
+ return { authorizeUrl, discovery };
2410
+ }
2411
+ /** Finish a sign-in from the redirect. Returns the connector that was authorised. */
2412
+ async completeLogin(state, code) {
2413
+ const p = this.pending.get(state);
2414
+ if (!p) throw new OAuthError("This sign-in link is no longer valid. Start the sign-in again.");
2415
+ this.pending.delete(state);
2416
+ let tokens;
2417
+ try {
2418
+ tokens = await exchangeCode({
2419
+ tokenEndpoint: p.tokenEndpoint,
2420
+ code,
2421
+ verifier: p.verifier,
2422
+ clientId: p.clientId,
2423
+ clientSecret: p.clientSecret,
2424
+ redirectUri: p.redirectUri,
2425
+ resource: p.resource
2426
+ });
2427
+ } catch (err) {
2428
+ const message = err.message;
2429
+ if (/client_secret/i.test(message)) {
2430
+ throw new OAuthError(
2431
+ `This provider requires a client secret as well as a client ID. Add the secret from the same OAuth client (in Google's console: the client's "Client secret") and sign in again.`
2432
+ );
2433
+ }
2434
+ throw err;
2435
+ }
2436
+ await this.write(p.connectorName, tokens);
2437
+ log7.info(`connector "${p.connectorName}" signed in`);
2438
+ return { connectorId: p.connectorId, connectorName: p.connectorName };
2439
+ }
2440
+ /**
2441
+ * The Authorization header for a mounted connector, refreshing first if the token is close to
2442
+ * expiry. Returns null when the connector was never signed in, which is not an error — most
2443
+ * connectors use a static credential or none.
2444
+ */
2445
+ async authHeader(connectorName) {
2446
+ let tokens = await this.read(connectorName);
2447
+ if (!tokens) return null;
2448
+ if (needsRefresh(tokens, Date.now())) {
2449
+ try {
2450
+ tokens = await refreshTokens(tokens);
2451
+ await this.write(connectorName, tokens);
2452
+ } catch (err) {
2453
+ log7.warn(`could not refresh tokens for "${connectorName}": ${err.message}`);
2454
+ return null;
2455
+ }
2456
+ }
2457
+ return { Authorization: `Bearer ${tokens.accessToken}` };
2458
+ }
2459
+ sweep() {
2460
+ const cutoff = Date.now() - LOGIN_TTL_MS;
2461
+ for (const [state, p] of this.pending) if (p.startedAt < cutoff) this.pending.delete(state);
2462
+ }
2463
+ };
2464
+
2100
2465
  // daemon/src/config/config.ts
2101
2466
  import fs6 from "node:fs";
2102
2467
  import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
@@ -2173,11 +2538,11 @@ function writeConfig(cfg) {
2173
2538
  // daemon/src/permissions/secrets.ts
2174
2539
  import fs7 from "node:fs";
2175
2540
  import path7 from "node:path";
2176
- import crypto from "node:crypto";
2541
+ import crypto3 from "node:crypto";
2177
2542
  import { execFile } from "node:child_process";
2178
2543
  import { promisify } from "node:util";
2179
2544
  var exec = promisify(execFile);
2180
- var log7 = logger("secrets");
2545
+ var log8 = logger("secrets");
2181
2546
  var SERVICE = "ant-bot";
2182
2547
  var SecretToolBackend = class {
2183
2548
  name = "libsecret (secret-tool)";
@@ -2244,7 +2609,7 @@ var EncryptedFileBackend = class {
2244
2609
  key() {
2245
2610
  if (!fs7.existsSync(this.keyFile)) {
2246
2611
  fs7.mkdirSync(path7.dirname(this.keyFile), { recursive: true });
2247
- fs7.writeFileSync(this.keyFile, crypto.randomBytes(32), { mode: 384 });
2612
+ fs7.writeFileSync(this.keyFile, crypto3.randomBytes(32), { mode: 384 });
2248
2613
  }
2249
2614
  return fs7.readFileSync(this.keyFile);
2250
2615
  }
@@ -2255,7 +2620,7 @@ var EncryptedFileBackend = class {
2255
2620
  const key = this.key();
2256
2621
  const out = {};
2257
2622
  for (const [k, v] of Object.entries(raw)) {
2258
- const d = crypto.createDecipheriv("aes-256-gcm", key, Buffer.from(v.iv, "base64"));
2623
+ const d = crypto3.createDecipheriv("aes-256-gcm", key, Buffer.from(v.iv, "base64"));
2259
2624
  d.setAuthTag(Buffer.from(v.tag, "base64"));
2260
2625
  out[k] = Buffer.concat([d.update(Buffer.from(v.data, "base64")), d.final()]).toString("utf8");
2261
2626
  }
@@ -2268,8 +2633,8 @@ var EncryptedFileBackend = class {
2268
2633
  const key = this.key();
2269
2634
  const out = {};
2270
2635
  for (const [k, v] of Object.entries(values)) {
2271
- const iv = crypto.randomBytes(12);
2272
- const c = crypto.createCipheriv("aes-256-gcm", key, iv);
2636
+ const iv = crypto3.randomBytes(12);
2637
+ const c = crypto3.createCipheriv("aes-256-gcm", key, iv);
2273
2638
  const data = Buffer.concat([c.update(v, "utf8"), c.final()]);
2274
2639
  out[k] = { iv: iv.toString("base64"), tag: c.getAuthTag().toString("base64"), data: data.toString("base64") };
2275
2640
  }
@@ -2304,7 +2669,7 @@ async function pickBackend(fallbackFile) {
2304
2669
  };
2305
2670
  if (process.platform === "darwin" && await has("security", ["-h"])) return new MacKeychainBackend();
2306
2671
  if (process.platform === "linux" && await has("secret-tool", ["--version"])) return new SecretToolBackend();
2307
- log7.warn("no system keychain available; using the encrypted-file fallback");
2672
+ log8.warn("no system keychain available; using the encrypted-file fallback");
2308
2673
  return new EncryptedFileBackend(fallbackFile);
2309
2674
  }
2310
2675
  var SecretsService = class {
@@ -2376,12 +2741,12 @@ var SecretsService = class {
2376
2741
  };
2377
2742
 
2378
2743
  // daemon/src/app.ts
2379
- var log8 = logger("app");
2744
+ var log9 = logger("app");
2380
2745
  async function optionalImport(name, load) {
2381
2746
  try {
2382
2747
  return await load();
2383
2748
  } catch (err) {
2384
- log8.warn(`${name} module could not be loaded`, err.message);
2749
+ log9.warn(`${name} module could not be loaded`, err.message);
2385
2750
  return null;
2386
2751
  }
2387
2752
  }
@@ -2459,7 +2824,7 @@ async function createApp(opts = {}) {
2459
2824
  const available = new Set(app.secrets?.list() ?? []);
2460
2825
  const { mount, skipped } = planConnectorMount(assigned, available);
2461
2826
  for (const s of skipped) {
2462
- log8.warn(`connector "${s.connector.name}" not mounted \u2014 missing secret(s): ${s.missing.join(", ")}`);
2827
+ log9.warn(`connector "${s.connector.name}" not mounted \u2014 missing secret(s): ${s.missing.join(", ")}`);
2463
2828
  }
2464
2829
  const servers = {};
2465
2830
  const mounted = [];
@@ -2467,10 +2832,15 @@ async function createApp(opts = {}) {
2467
2832
  const refs = extractSecretRefs(connector.config);
2468
2833
  try {
2469
2834
  const secrets = refs.length ? await app.secrets.resolve(refs) : /* @__PURE__ */ new Map();
2470
- servers[connector.name] = buildMcpServerConfig(connector, secrets);
2835
+ const built = buildMcpServerConfig(connector, secrets);
2836
+ const auth = await app.connectorAuth?.authHeader(connector.name);
2837
+ if (auth && built.headers && !("Authorization" in built.headers)) {
2838
+ built.headers = { ...built.headers, ...auth };
2839
+ }
2840
+ servers[connector.name] = built;
2471
2841
  mounted.push({ name: connector.name, description: connector.description });
2472
2842
  } catch (err) {
2473
- log8.warn(`connector "${connector.name}" not mounted`, err.message);
2843
+ log9.warn(`connector "${connector.name}" not mounted`, err.message);
2474
2844
  }
2475
2845
  }
2476
2846
  return { servers, mounted };
@@ -2481,9 +2851,10 @@ async function createApp(opts = {}) {
2481
2851
  await pickBackend(cfg.paths.secrets),
2482
2852
  `${cfg.paths.secrets}.index`
2483
2853
  );
2484
- log8.info(`secrets backend: ${app.secrets.backendName}`);
2854
+ log9.info(`secrets backend: ${app.secrets.backendName}`);
2855
+ app.connectorAuth = new ConnectorAuthService(app.secrets, cfg.port);
2485
2856
  } catch (err) {
2486
- log8.warn("secrets backend unavailable", err.message);
2857
+ log9.warn("secrets backend unavailable", err.message);
2487
2858
  }
2488
2859
  await wireSkills(app);
2489
2860
  await wireBrowser(app);
@@ -2509,12 +2880,12 @@ async function wireSkills(app) {
2509
2880
  const mod = await optionalImport("skills", () => import("./skills-JUHWFBD6.js"));
2510
2881
  const pluginMod = await optionalImport("skill plugin", () => import("./plugin-WYUCG6F7.js"));
2511
2882
  const Ctor = mod?.SkillStore ?? mod?.default;
2512
- if (!Ctor) return void log8.warn("skills subsystem unavailable: no SkillStore export");
2883
+ if (!Ctor) return void log9.warn("skills subsystem unavailable: no SkillStore export");
2513
2884
  const pluginRoot = app.cfg.paths.skills;
2514
2885
  if (pluginMod?.ensureSkillPlugin) {
2515
2886
  pluginMod.ensureSkillPlugin(pluginRoot);
2516
2887
  const moved = pluginMod.migrateLegacyLayout?.(pluginRoot) ?? [];
2517
- if (moved.length) log8.info(`migrated ${moved.length} skill(s) into the plugin layout: ${moved.join(", ")}`);
2888
+ if (moved.length) log9.info(`migrated ${moved.length} skill(s) into the plugin layout: ${moved.join(", ")}`);
2518
2889
  app.skillPluginPath = pluginRoot;
2519
2890
  }
2520
2891
  const filesDir = pluginMod?.skillFilesDir?.(pluginRoot) ?? pluginRoot;
@@ -2527,30 +2898,30 @@ async function wireSkills(app) {
2527
2898
  const installed = took("install");
2528
2899
  const updated = took("update");
2529
2900
  const kept = [...took("skip-modified"), ...took("skip-foreign")];
2530
- if (installed.length) log8.info(`installed ${installed.length} bundled skill(s): ${installed.join(", ")}`);
2531
- if (updated.length) log8.info(`updated ${updated.length} bundled skill(s): ${updated.join(", ")}`);
2532
- if (kept.length) log8.info(`left ${kept.length} locally-modified skill(s) alone: ${kept.join(", ")}`);
2901
+ if (installed.length) log9.info(`installed ${installed.length} bundled skill(s): ${installed.join(", ")}`);
2902
+ if (updated.length) log9.info(`updated ${updated.length} bundled skill(s): ${updated.join(", ")}`);
2903
+ if (kept.length) log9.info(`left ${kept.length} locally-modified skill(s) alone: ${kept.join(", ")}`);
2533
2904
  const written = [...installed, ...updated, ...took("adopt")];
2534
2905
  const renamed = app.skills?.refreshFromDisk?.(written) ?? [];
2535
- if (renamed.length) log8.info(`refreshed metadata for ${renamed.length} skill(s): ${renamed.join(", ")}`);
2906
+ if (renamed.length) log9.info(`refreshed metadata for ${renamed.length} skill(s): ${renamed.join(", ")}`);
2536
2907
  } catch (e) {
2537
- log8.warn("bundled skills not synced", e.message);
2908
+ log9.warn("bundled skills not synced", e.message);
2538
2909
  }
2539
2910
  }
2540
2911
  app.skills.syncFromDisk?.();
2541
2912
  const fixed = app.skills.reconcile?.();
2542
- if (fixed?.repaired.length) log8.info(`repaired ${fixed.repaired.length} skill path(s): ${fixed.repaired.join(", ")}`);
2543
- if (fixed?.removed.length) log8.info(`dropped ${fixed.removed.length} skill row(s) with no files on disk`);
2544
- log8.info(`skills ready (${app.store.listSkills().length} registered)`);
2913
+ if (fixed?.repaired.length) log9.info(`repaired ${fixed.repaired.length} skill path(s): ${fixed.repaired.join(", ")}`);
2914
+ if (fixed?.removed.length) log9.info(`dropped ${fixed.removed.length} skill row(s) with no files on disk`);
2915
+ log9.info(`skills ready (${app.store.listSkills().length} registered)`);
2545
2916
  } catch (err) {
2546
- log8.warn("skills subsystem unavailable", err.message);
2917
+ log9.warn("skills subsystem unavailable", err.message);
2547
2918
  }
2548
2919
  }
2549
2920
  async function wireBrowser(app) {
2550
2921
  try {
2551
2922
  const mod = await optionalImport("browser", () => import("./browser-NOWM4S6C.js"));
2552
2923
  const Ctor = mod?.BrowserService ?? mod?.default;
2553
- if (!Ctor) return void log8.warn("browser subsystem unavailable: no BrowserService export");
2924
+ if (!Ctor) return void log9.warn("browser subsystem unavailable: no BrowserService export");
2554
2925
  const svc = new Ctor({ profileDir: app.cfg.paths.browserProfile, bus: app.bus, headless: true });
2555
2926
  let toolsMod = null;
2556
2927
  toolsMod = await optionalImport("browser tools", () => import("./tools-YNE7ZRPR.js"));
@@ -2565,16 +2936,16 @@ async function wireBrowser(app) {
2565
2936
  return s;
2566
2937
  };
2567
2938
  app.browser = svc;
2568
- log8.info("browser computer service ready");
2939
+ log9.info("browser computer service ready");
2569
2940
  } catch (err) {
2570
- log8.warn("browser subsystem unavailable", err.message);
2941
+ log9.warn("browser subsystem unavailable", err.message);
2571
2942
  }
2572
2943
  }
2573
2944
  async function wireScheduler(app) {
2574
2945
  try {
2575
2946
  const mod = await optionalImport("scheduler", () => import("./scheduler-VZVHQWGD.js"));
2576
2947
  const Ctor = mod?.Scheduler ?? mod?.default;
2577
- if (!Ctor) return void log8.warn("scheduler subsystem unavailable: no Scheduler export");
2948
+ if (!Ctor) return void log9.warn("scheduler subsystem unavailable: no Scheduler export");
2578
2949
  app.scheduler = new Ctor({
2579
2950
  store: app.store,
2580
2951
  bus: app.bus,
@@ -2582,9 +2953,9 @@ async function wireScheduler(app) {
2582
2953
  getSettings: app.getSettings
2583
2954
  });
2584
2955
  app.scheduler.start?.();
2585
- log8.info(`scheduler started (${app.store.listRoutines().filter((r) => r.enabled).length} active routines)`);
2956
+ log9.info(`scheduler started (${app.store.listRoutines().filter((r) => r.enabled).length} active routines)`);
2586
2957
  } catch (err) {
2587
- log8.warn("scheduler subsystem unavailable", err.message);
2958
+ log9.warn("scheduler subsystem unavailable", err.message);
2588
2959
  }
2589
2960
  }
2590
2961
  function drainMailbox(app) {
@@ -2616,7 +2987,7 @@ function workspaceRelative(root, p) {
2616
2987
 
2617
2988
  // daemon/src/bots/groups.ts
2618
2989
  import { query as query3 } from "@anthropic-ai/claude-agent-sdk";
2619
- var log9 = logger("groups");
2990
+ var log10 = logger("groups");
2620
2991
  async function routeGroupMessage(args) {
2621
2992
  const { text, members, mentionBotIds, mentionEveryone, settings, cwd } = args;
2622
2993
  if (mentionEveryone) return members;
@@ -2655,7 +3026,7 @@ Which single teammate should own this? Reply with only the slug.`,
2655
3026
  const found = members.find((m) => m.slug === slug);
2656
3027
  if (found) return [found];
2657
3028
  } catch (err) {
2658
- log9.warn("group router failed; defaulting to first member", err);
3029
+ log10.warn("group router failed; defaulting to first member", err);
2659
3030
  }
2660
3031
  return members.slice(0, 1);
2661
3032
  }
@@ -2870,7 +3241,7 @@ import path10 from "node:path";
2870
3241
 
2871
3242
  // daemon/src/bots/mcpProbe.ts
2872
3243
  import { spawn } from "node:child_process";
2873
- var log10 = logger("mcp-probe");
3244
+ var log11 = logger("mcp-probe");
2874
3245
  var PROTOCOL_VERSION = "2025-06-18";
2875
3246
  var DEFAULT_TIMEOUT_MS = 1e4;
2876
3247
  var MAX_DESCRIPTION = 200;
@@ -3031,7 +3402,7 @@ async function probeConnector(config, opts = {}) {
3031
3402
  if (type === "sse") return failed("testing is not supported for sse connectors \u2014 assign it to a bot and run a turn");
3032
3403
  return failed(`unknown transport: ${String(type)}`);
3033
3404
  } catch (err) {
3034
- log10.warn("probe threw", err);
3405
+ log11.warn("probe threw", err);
3035
3406
  return failed(err.message);
3036
3407
  }
3037
3408
  }
@@ -3072,7 +3443,12 @@ function registerOpsRoutes(f, app) {
3072
3443
  });
3073
3444
  f.get("/api/connectors", async () => {
3074
3445
  const available = new Set(app.secrets?.list() ?? []);
3075
- return store.listConnectors().map((c) => ({ ...c, missingSecrets: computeMissingSecrets(c, available) }));
3446
+ return store.listConnectors().map((c) => ({
3447
+ ...c,
3448
+ missingSecrets: computeMissingSecrets(c, available),
3449
+ // Names only — knowing a connector is signed in never requires reading its token.
3450
+ signedIn: app.connectorAuth?.isAuthorized(c.name) ?? false
3451
+ }));
3076
3452
  });
3077
3453
  f.post("/api/connectors", async (req, reply) => {
3078
3454
  const parsed = CreateConnectorRequest.safeParse(req.body);
@@ -3094,6 +3470,59 @@ function registerOpsRoutes(f, app) {
3094
3470
  store.deleteConnector(req.params.id);
3095
3471
  return { ok: true };
3096
3472
  });
3473
+ f.post(
3474
+ "/api/connectors/:id/login",
3475
+ async (req, reply) => {
3476
+ const connector = store.getConnector(req.params.id);
3477
+ if (!connector) return reply.code(404).send({ error: "No such connector" });
3478
+ if (!app.connectorAuth) return reply.code(503).send({ error: "Secrets backend unavailable, so sign-in cannot be stored" });
3479
+ try {
3480
+ const { authorizeUrl } = await app.connectorAuth.beginLogin(connector, req.body ?? {});
3481
+ return { authorizeUrl };
3482
+ } catch (err) {
3483
+ return reply.code(400).send({ error: err.message });
3484
+ }
3485
+ }
3486
+ );
3487
+ f.delete("/api/connectors/:id/login", async (req, reply) => {
3488
+ const connector = store.getConnector(req.params.id);
3489
+ if (!connector) return reply.code(404).send({ error: "No such connector" });
3490
+ await app.connectorAuth?.signOut(connector.name);
3491
+ return { ok: true };
3492
+ });
3493
+ f.get(
3494
+ "/api/connectors/oauth/callback",
3495
+ async (req, reply) => {
3496
+ const page = (title, detail, ok) => `<!doctype html><meta charset=utf-8><title>${title}</title>
3497
+ <body style="font-family:system-ui;background:#0b0d10;color:#e6e8eb;padding:3rem;max-width:40rem">
3498
+ <h1 style="color:${ok ? "#4ade80" : "#f87171"}">${title}</h1><p>${detail}</p>
3499
+ <p style="color:#9aa4b2">You can close this tab and return to ant-bot.</p>`;
3500
+ const { code, state, error, error_description: desc } = req.query;
3501
+ if (error) {
3502
+ return reply.type("text/html").send(page("Sign-in failed", `${error}: ${desc ?? ""}`, false));
3503
+ }
3504
+ if (!code || !state) {
3505
+ return reply.type("text/html").send(page("Sign-in failed", "The provider did not return a code.", false));
3506
+ }
3507
+ if (!app.connectorAuth) {
3508
+ return reply.type("text/html").send(page("Sign-in failed", "The secrets backend is unavailable.", false));
3509
+ }
3510
+ try {
3511
+ const { connectorName } = await app.connectorAuth.completeLogin(state, code);
3512
+ bus.publish({
3513
+ type: "notify",
3514
+ botId: null,
3515
+ threadId: null,
3516
+ title: "Connector signed in",
3517
+ body: `${connectorName} is now authorised.`,
3518
+ level: "info"
3519
+ });
3520
+ return reply.type("text/html").send(page("Signed in", `<b>${connectorName}</b> is now authorised.`, true));
3521
+ } catch (err) {
3522
+ return reply.type("text/html").send(page("Sign-in failed", err.message, false));
3523
+ }
3524
+ }
3525
+ );
3097
3526
  f.post("/api/connectors/:id/test", async (req, reply) => {
3098
3527
  const connector = store.getConnector(req.params.id);
3099
3528
  if (!connector) return reply.code(404).send({ error: "No such connector" });
@@ -3346,7 +3775,7 @@ function registerOpsRoutes(f, app) {
3346
3775
  }
3347
3776
 
3348
3777
  // daemon/src/api/server.ts
3349
- var log11 = logger("server");
3778
+ var log12 = logger("server");
3350
3779
  var require_ = createRequire(import.meta.url);
3351
3780
  function resolveWebDist() {
3352
3781
  return findWebDist(
@@ -3463,9 +3892,9 @@ async function startServer(opts = {}) {
3463
3892
  if (req.url.startsWith("/api")) return reply.code(404).send({ error: "Not found" });
3464
3893
  return reply.sendFile("index.html");
3465
3894
  });
3466
- log11.info(`serving UI from ${dist}`);
3895
+ log12.info(`serving UI from ${dist}`);
3467
3896
  } else {
3468
- log11.warn("web UI not built \u2014 run `pnpm --filter @antbot/ui build`");
3897
+ log12.warn("web UI not built \u2014 run `pnpm --filter @antbot/ui build`");
3469
3898
  fastify.setNotFoundHandler((req, reply) => {
3470
3899
  if (req.url.startsWith("/api")) return reply.code(404).send({ error: "Not found" });
3471
3900
  return reply.type("text/html").send(
@@ -3480,7 +3909,7 @@ async function startServer(opts = {}) {
3480
3909
  }
3481
3910
  }
3482
3911
  fastify.setErrorHandler((err, _req, reply) => {
3483
- log11.error("request failed", err);
3912
+ log12.error("request failed", err);
3484
3913
  const e = err;
3485
3914
  const code = e.statusCode && e.statusCode >= 400 ? e.statusCode : 500;
3486
3915
  reply.code(code).send({ error: e.message ?? "Internal error" });
@@ -3490,13 +3919,13 @@ async function startServer(opts = {}) {
3490
3919
  await fastify.listen({ port, host });
3491
3920
  const url = `http://${host}:${port}`;
3492
3921
  const delivered = drainMailbox(app);
3493
- if (delivered) log11.info(`redelivered ${delivered} queued handoff message(s)`);
3922
+ if (delivered) log12.info(`redelivered ${delivered} queued handoff message(s)`);
3494
3923
  const stale = app.store.listBots().filter((b2) => b2.state === "running" || b2.state === "queued");
3495
3924
  for (const b2 of stale) app.store.updateBot(b2.id, { state: "idle" });
3496
3925
  app.db.prepare(`UPDATE messages SET streaming=0 WHERE streaming=1`).run();
3497
3926
  app.db.prepare(`UPDATE approvals SET status='expired', reason='Daemon restarted' WHERE status='pending'`).run();
3498
3927
  app.db.prepare(`UPDATE routine_runs SET status='interrupted', finished_at=? WHERE status='running'`).run(Date.now());
3499
- log11.info(`ant-bot listening on ${url}`);
3928
+ log12.info(`ant-bot listening on ${url}`);
3500
3929
  return {
3501
3930
  fastify,
3502
3931
  app,