@mulmoclaude/core 0.21.0 → 0.22.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.
@@ -304,31 +304,41 @@ shipped builders produce, and asserts `handlePermission` comes back over the MCP
304
304
  ### Symptoms
305
305
 
306
306
  - The `google` tool (or a `google.calendar.*` remote command) fails with **"Google account not linked on this host"**.
307
- - Errors mentioning **client_secret**: "no client_secret_*.json found" or "multiple client_secret_*.json files found".
307
+ - **"Google sign-in service unreachable"** or **"Google sign-in service returned HTTP …"**.
308
+ - **"multiple client_secret_*.json files found"**.
308
309
  - **"Google Calendar API: HTTP 403"** with a hint about enabling the API.
309
310
  - **"could not obtain a Google access token"** (grant revoked).
310
311
 
311
312
  ### Cause
312
313
 
313
314
  The `google` tool runs against a Google account linked **locally on this machine** — a refresh
314
- token stored at `~/.config/mulmo/google-token.json` (mode 600), obtained through a desktop
315
- OAuth (loopback + PKCE) consent using the client credentials in `~/.secrets/client_secret_*.json`.
316
- This is independent of claude.ai Google connectors. Each failure names its missing piece.
315
+ token stored at `~/.config/mulmo/google-token.json` (mode 600), obtained through a browser
316
+ consent (loopback + PKCE). This is independent of claude.ai Google connectors.
317
+
318
+ Two ways the link can be minted, and the tool picks automatically:
319
+
320
+ - **Default**: the user just clicks link and consents. The mulmoserver broker applies the OAuth
321
+ client secret for the token exchange / renewal (Google requires one); it stores nothing —
322
+ the tokens live only on this machine. **The user needs no Google Cloud setup.**
323
+ - **Own client** (advanced / self-hosters): if `~/.secrets/client_secret_*.json` exists, the
324
+ whole flow stays on this machine and the broker is never contacted.
317
325
 
318
326
  ### Fix
319
327
 
320
- - **Not linked / grant revoked** — ask the user to link (or re-link) the account: Settings
321
- Plugins Google "Link Google account", or run `yarn google:auth` in the repo. Then retry the
322
- original call. Do NOT try to create or edit the token file yourself.
323
- - **No client secret** — the user must download the OAuth *desktop-app* client JSON from the
324
- Google Cloud Console (APIs & Services Credentials) and place it in `~/.secrets/` keeping the
325
- `client_secret_*.json` filename (chmod 600).
326
- - **Multiple client secrets** — ask the user to keep exactly one `client_secret_*.json` in
327
- `~/.secrets/`; the stored refresh token pairs with one OAuth client, so duplicates are refused
328
- rather than guessed at.
329
- - **HTTP 403 from a Google API** — the API is not enabled for the user's Cloud project. The error
330
- names the API; ask them to enable that one in the Cloud Console (APIs & Services → Library —
331
- "Google Calendar API", "Google Tasks API", or "Google Drive API"), then retry. No re-link needed.
328
+ - **Not linked / grant revoked** — ask the user to link (or re-link) the account from this app's
329
+ settings, then retry. Do NOT tell them to create a Google Cloud project, and do NOT try to
330
+ create or edit the token file yourself.
331
+ - **Sign-in service unreachable / HTTP error** — the broker is down or the network is blocked.
332
+ It is only needed to link and to renew an expired access token; ask the user to retry shortly.
333
+ (A user with their own `~/.secrets/client_secret_*.json` never depends on it.)
334
+ - **Multiple client secrets** — the user has several `client_secret_*.json` in `~/.secrets/`;
335
+ a stored refresh token pairs with exactly one OAuth client, so the choice is refused rather
336
+ than guessed at. Ask them to keep one — or remove all of them to use the default flow.
337
+ - **HTTP 403 from a Google API** — the API is not enabled for the Cloud project behind the
338
+ client in use. With their own client, ask them to enable that API in the Cloud Console
339
+ (APIs & Services → Library — the error names it: "Google Calendar API", "Google Tasks API",
340
+ or "Google Drive API"), then retry — no re-link needed. On the default (broker) flow this
341
+ should not happen; report it rather than sending the user to the Console.
332
342
  - **Drive shows nothing / "I can't find the user's file"** — not an error. The app holds the
333
343
  `drive.file` scope, so it can only ever see files IT created; the user's wider Drive is
334
344
  invisible by design. Say so plainly instead of implying an empty Drive.
@@ -0,0 +1,15 @@
1
+ import { Credentials } from 'google-auth-library';
2
+ /** `MULMO_GOOGLE_BROKER_URL` lets a fork / staging deploy point elsewhere
3
+ * without a code change; unset means the shipped broker. */
4
+ export declare const brokerBaseUrl: (override?: string | undefined) => string;
5
+ export interface BrokerStartResponse {
6
+ authUrl: string;
7
+ state: string;
8
+ }
9
+ export declare function brokerStart(port: number, codeChallenge: string, baseUrl?: string): Promise<BrokerStartResponse>;
10
+ export declare function brokerExchange(input: {
11
+ code: string;
12
+ state: string;
13
+ codeVerifier: string;
14
+ }, baseUrl?: string): Promise<Credentials>;
15
+ export declare function brokerRefresh(refreshToken: string, baseUrl?: string): Promise<Credentials>;
@@ -2,9 +2,10 @@ export interface InstalledClientSecret {
2
2
  client_id: string;
3
3
  client_secret: string;
4
4
  }
5
- /** "ambiguous" (2+ files) is distinct from "missing" — the fixes differ
6
- * (remove duplicates vs download credentials), so the UI must not conflate
7
- * them. */
5
+ /** `missing` is the ordinary case — the broker supplies the client, so no user
6
+ * action is needed. `ambiguous` (2+ desktop clients) still needs a human:
7
+ * a stored refresh token pairs with exactly one client_id, so picking for
8
+ * them could silently break the link. */
8
9
  export type ClientSecretPresence = "found" | "missing" | "ambiguous";
9
10
  export declare function clientSecretPresence(home?: string): Promise<ClientSecretPresence>;
10
11
  export declare function findClientSecretPath(home?: string): Promise<string>;
@@ -80,31 +80,44 @@ function truncate(text, max, ellipsis = "…") {
80
80
  }
81
81
  //#endregion
82
82
  //#region src/google/clientSecret.ts
83
+ var isNonEmptyString = (value) => typeof value === "string" && value !== "";
83
84
  var isInstalledClientSecret = (value) => {
84
85
  if (!isRecord(value) || !isRecord(value.installed)) return false;
85
- return typeof value.installed.client_id === "string" && typeof value.installed.client_secret === "string";
86
+ return isNonEmptyString(value.installed.client_id) && isNonEmptyString(value.installed.client_secret);
86
87
  };
87
88
  var isClientSecretFileName = (name) => name.startsWith("client_secret_") && name.endsWith(".json");
88
- async function listClientSecretFiles(home) {
89
- return (await (0, node_fs_promises.readdir)(googleSecretsDir(home)).catch(() => [])).filter(isClientSecretFileName).sort();
89
+ var readIfDesktopClient = async (filePath) => {
90
+ try {
91
+ return isInstalledClientSecret(JSON.parse(await (0, node_fs_promises.readFile)(filePath, "utf-8"))) ? filePath : null;
92
+ } catch {
93
+ return null;
94
+ }
95
+ };
96
+ /** Absolute paths of every desktop-app client JSON in `~/.secrets/`. */
97
+ async function listDesktopClientSecretFiles(home) {
98
+ const dir = googleSecretsDir(home);
99
+ const candidates = (await (0, node_fs_promises.readdir)(dir).catch(() => [])).filter(isClientSecretFileName).sort();
100
+ return (await Promise.all(candidates.map((name) => readIfDesktopClient((0, node_path.join)(dir, name))))).filter((path) => path !== null);
90
101
  }
91
102
  async function clientSecretPresence(home) {
92
- const matches = await listClientSecretFiles(home);
103
+ const matches = await listDesktopClientSecretFiles(home);
93
104
  if (matches.length === 0) return "missing";
94
105
  return matches.length === 1 ? "found" : "ambiguous";
95
106
  }
96
107
  async function findClientSecretPath(home) {
97
108
  const dir = googleSecretsDir(home);
98
- const matches = await listClientSecretFiles(home);
109
+ const matches = await listDesktopClientSecretFiles(home);
99
110
  const [first, ...rest] = matches;
100
- if (!first) throw new Error(`no client_secret_*.json found in ${dir} — download the OAuth desktop-app credentials JSON from the Google Cloud Console and place it there (mode 600)`);
101
- if (rest.length > 0) throw new Error(`multiple client_secret_*.json files found in ${dir} (${matches.join(", ")}) — keep exactly one`);
102
- return (0, node_path.join)(dir, first);
111
+ if (!first) throw new Error(`no desktop-app client_secret_*.json found in ${dir} — this host links through the sign-in service instead`);
112
+ if (rest.length > 0) {
113
+ const names = matches.map((path) => path.slice(dir.length + 1)).join(", ");
114
+ throw new Error(`multiple desktop-app client_secret_*.json files found in ${dir} (${names}) — keep exactly one`);
115
+ }
116
+ return first;
103
117
  }
104
118
  async function loadClientSecret(home) {
105
119
  const filePath = await findClientSecretPath(home);
106
- const raw = await (0, node_fs_promises.readFile)(filePath, "utf-8");
107
- const parsed = JSON.parse(raw);
120
+ const parsed = JSON.parse(await (0, node_fs_promises.readFile)(filePath, "utf-8"));
108
121
  if (!isInstalledClientSecret(parsed)) throw new Error(`${filePath} is not a desktop-app OAuth client secret (missing "installed" with client_id / client_secret)`);
109
122
  return {
110
123
  client_id: parsed.installed.client_id,
@@ -164,6 +177,7 @@ function mergeGoogleTokens(existing, incoming) {
164
177
  ...incoming
165
178
  };
166
179
  if (!incoming.refresh_token && existing?.refresh_token) merged.refresh_token = existing.refresh_token;
180
+ if (!incoming.issuedVia && existing?.issuedVia) merged.issuedVia = existing.issuedVia;
167
181
  return merged;
168
182
  }
169
183
  var fileExists = async (filePath) => await (0, node_fs_promises.stat)(filePath).then(() => true, () => false);
@@ -224,6 +238,113 @@ function bridgeExternalSignal(external, controller) {
224
238
  return () => external.removeEventListener("abort", onAbort);
225
239
  }
226
240
  //#endregion
241
+ //#region src/google/broker.ts
242
+ var DEFAULT_BROKER_BASE_URL = "https://asia-northeast1-mulmoserver.cloudfunctions.net";
243
+ var BROKER_TIMEOUT_MS = 20 * ONE_SECOND_MS;
244
+ var withoutTrailingSlashes = (url) => {
245
+ let end = url.length;
246
+ while (end > 0 && url[end - 1] === "/") end -= 1;
247
+ return url.slice(0, end);
248
+ };
249
+ /** `MULMO_GOOGLE_BROKER_URL` lets a fork / staging deploy point elsewhere
250
+ * without a code change; unset means the shipped broker. */
251
+ var brokerBaseUrl = (override = process.env.MULMO_GOOGLE_BROKER_URL) => withoutTrailingSlashes(override ?? DEFAULT_BROKER_BASE_URL);
252
+ var LOOPBACK_HOSTNAMES = /* @__PURE__ */ new Set([
253
+ "127.0.0.1",
254
+ "localhost",
255
+ "::1",
256
+ "[::1]"
257
+ ]);
258
+ /** Codes, PKCE verifiers and refresh tokens travel to the broker, so cleartext
259
+ * is refused outright — an override pointed at `http://…` would put them on
260
+ * the wire. Loopback stays allowed: that is where tests stub the broker, and
261
+ * it never leaves the machine. */
262
+ var assertSecureBrokerUrl = (url) => {
263
+ let parsed;
264
+ try {
265
+ parsed = new URL(url);
266
+ } catch {
267
+ throw new Error(`invalid Google sign-in service URL: ${url}`);
268
+ }
269
+ if (parsed.protocol === "https:") return;
270
+ if (parsed.protocol === "http:" && LOOPBACK_HOSTNAMES.has(parsed.hostname)) return;
271
+ throw new Error(`the Google sign-in service must be reached over HTTPS (got ${parsed.protocol}//${parsed.host})`);
272
+ };
273
+ var brokerFetch = async (url, init = {}) => {
274
+ assertSecureBrokerUrl(url);
275
+ let response;
276
+ try {
277
+ response = await fetchWithTimeout(url, {
278
+ ...init,
279
+ timeoutMs: BROKER_TIMEOUT_MS,
280
+ headers: { "Content-Type": "application/json" }
281
+ });
282
+ } catch (err) {
283
+ throw new Error(`Google sign-in service unreachable — check the network connection and retry (${errorMessage(err)})`);
284
+ }
285
+ if (!response.ok) throw new Error(`Google sign-in service returned HTTP ${response.status}`);
286
+ try {
287
+ return await response.json();
288
+ } catch {
289
+ throw new Error("Google sign-in service returned a malformed response");
290
+ }
291
+ };
292
+ var GOOGLE_CONSENT_HOST = "accounts.google.com";
293
+ /** The returned URL is opened in the user's browser, so it must be Google's
294
+ * own consent page and nothing else — a mis-pointed or hostile broker (the
295
+ * base URL is overridable) could otherwise hand back a phishing page wearing
296
+ * a sign-in flow. Cheap to assert, and Google's endpoint host is fixed. */
297
+ var assertGoogleConsentUrl = (authUrl) => {
298
+ let parsed;
299
+ try {
300
+ parsed = new URL(authUrl);
301
+ } catch {
302
+ throw new Error("Google sign-in service returned an unusable authorization URL");
303
+ }
304
+ if (parsed.protocol !== "https:" || parsed.hostname !== GOOGLE_CONSENT_HOST) throw new Error(`Google sign-in service returned an authorization URL that is not Google's consent page (${parsed.protocol}//${parsed.host})`);
305
+ };
306
+ async function brokerStart(port, codeChallenge, baseUrl = brokerBaseUrl()) {
307
+ const payload = await brokerFetch(`${baseUrl}/googleOAuthStart?${new URLSearchParams({
308
+ port: String(port),
309
+ code_challenge: codeChallenge
310
+ }).toString()}`);
311
+ const record = isRecord(payload) ? payload : {};
312
+ if (typeof record.auth_url !== "string" || typeof record.state !== "string") throw new Error("Google sign-in service returned an unexpected response (missing auth_url / state)");
313
+ assertGoogleConsentUrl(record.auth_url);
314
+ return {
315
+ authUrl: record.auth_url,
316
+ state: record.state
317
+ };
318
+ }
319
+ var toCredentials = (payload, existingRefreshToken) => {
320
+ const record = isRecord(payload) ? payload : {};
321
+ if (typeof record.access_token !== "string") throw new Error("Google sign-in service returned no access token");
322
+ const refreshToken = typeof record.refresh_token === "string" ? record.refresh_token : existingRefreshToken;
323
+ return {
324
+ access_token: record.access_token,
325
+ ...refreshToken ? { refresh_token: refreshToken } : {},
326
+ ...typeof record.expiry_date === "number" ? { expiry_date: record.expiry_date } : {}
327
+ };
328
+ };
329
+ async function brokerExchange(input, baseUrl = brokerBaseUrl()) {
330
+ const credentials = toCredentials(await brokerFetch(`${baseUrl}/googleOAuthExchange`, {
331
+ method: "POST",
332
+ body: JSON.stringify({
333
+ code: input.code,
334
+ state: input.state,
335
+ code_verifier: input.codeVerifier
336
+ })
337
+ }));
338
+ if (!credentials.refresh_token) throw new Error("Google returned no refresh token — remove this app under Google Account → Security → Third-party access, then retry");
339
+ return credentials;
340
+ }
341
+ async function brokerRefresh(refreshToken, baseUrl = brokerBaseUrl()) {
342
+ return toCredentials(await brokerFetch(`${baseUrl}/googleOAuthRefresh`, {
343
+ method: "POST",
344
+ body: JSON.stringify({ refresh_token: refreshToken })
345
+ }), refreshToken);
346
+ }
347
+ //#endregion
227
348
  //#region src/google/auth.ts
228
349
  var GOOGLE_CALENDAR_SCOPE = "https://www.googleapis.com/auth/calendar.events";
229
350
  var GOOGLE_TASKS_SCOPE = "https://www.googleapis.com/auth/tasks";
@@ -239,6 +360,9 @@ var GOOGLE_SCOPES = [
239
360
  var CALLBACK_PATH = "/oauth2callback";
240
361
  var AUTH_TIMEOUT_MS = 5 * ONE_MINUTE_MS;
241
362
  var STATE_BYTES = 16;
363
+ /** Renew a minute early so a call can't start with a token that expires
364
+ * mid-flight. */
365
+ var EXPIRY_MARGIN_MS = ONE_MINUTE_MS;
242
366
  var createClient = (secret, redirectUri) => new google_auth_library.OAuth2Client({
243
367
  clientId: secret.client_id,
244
368
  clientSecret: secret.client_secret,
@@ -251,15 +375,29 @@ var persistRotatedTokens = (client, home) => {
251
375
  });
252
376
  });
253
377
  };
254
- async function getGoogleAccessToken(home) {
255
- const saved = await loadGoogleTokens(home);
256
- if (!saved?.refresh_token) throw new Error("Google account not linked on this host — ask the user to link their Google account in this app's settings, then retry");
378
+ var REVOKED_GRANT_MESSAGE = "could not obtain a Google access token — the grant may have been revoked; re-link the account";
379
+ var localAccessToken = async (saved, home) => {
380
+ const presence = await clientSecretPresence(home);
381
+ if (presence === "ambiguous") await loadClientSecret(home);
382
+ if (presence === "missing") throw new Error("the saved Google link was created with an OAuth client that is no longer configured on this host — ask the user to link their Google account again in this app's settings");
257
383
  const client = createClient(await loadClientSecret(home));
258
384
  client.setCredentials(saved);
259
385
  persistRotatedTokens(client, home);
260
386
  const { token } = await client.getAccessToken();
261
- if (!token) throw new Error("could not obtain a Google access token — the grant may have been revoked; re-link the account");
387
+ if (!token) throw new Error(REVOKED_GRANT_MESSAGE);
262
388
  return token;
389
+ };
390
+ var brokerAccessToken = async (saved, home) => {
391
+ if (typeof saved.expiry_date === "number" && saved.access_token && saved.expiry_date - EXPIRY_MARGIN_MS > Date.now()) return saved.access_token;
392
+ const refreshed = await brokerRefresh(saved.refresh_token ?? "");
393
+ if (!refreshed.access_token) throw new Error(REVOKED_GRANT_MESSAGE);
394
+ await saveGoogleTokens(refreshed, home);
395
+ return refreshed.access_token;
396
+ };
397
+ async function getGoogleAccessToken(home) {
398
+ const saved = await loadGoogleTokens(home);
399
+ if (!saved?.refresh_token) throw new Error("Google account not linked on this host — ask the user to link their Google account in this app's settings, then retry");
400
+ return saved.issuedVia === "broker" ? await brokerAccessToken(saved, home) : await localAccessToken(saved, home);
263
401
  }
264
402
  var REVOKE_URL = "https://oauth2.googleapis.com/revoke";
265
403
  /** Revoke the grant at Google (best-effort) and delete the local token file.
@@ -340,22 +478,49 @@ var buildConsentUrl = (client, codeChallenge, state) => client.generateAuthUrl({
340
478
  code_challenge: codeChallenge,
341
479
  state
342
480
  });
481
+ var generatePkce = async () => {
482
+ const { codeVerifier, codeChallenge } = await new google_auth_library.OAuth2Client().generateCodeVerifierAsync();
483
+ if (!codeChallenge) throw new Error("failed to derive a PKCE code challenge");
484
+ return {
485
+ codeVerifier,
486
+ codeChallenge
487
+ };
488
+ };
489
+ var authorizeWithLocalClient = async (secret, server, port, opts) => {
490
+ const client = createClient(secret, `http://127.0.0.1:${port}${CALLBACK_PATH}`);
491
+ const { codeVerifier, codeChallenge } = await client.generateCodeVerifierAsync();
492
+ if (!codeChallenge) throw new Error("failed to derive a PKCE code challenge");
493
+ const state = (0, node_crypto.randomBytes)(STATE_BYTES).toString("hex");
494
+ opts.onAuthUrl?.(buildConsentUrl(client, codeChallenge, state));
495
+ const code = await waitForAuthCode(server, state, opts.timeoutMs ?? AUTH_TIMEOUT_MS);
496
+ const { tokens } = await client.getToken({
497
+ code,
498
+ codeVerifier
499
+ });
500
+ if (!tokens.refresh_token) throw new Error("Google returned no refresh token — remove this app under Google Account → Security → Third-party access, then retry");
501
+ return tokens;
502
+ };
503
+ var authorizeWithBroker = async (server, port, opts) => {
504
+ const { codeVerifier, codeChallenge } = await generatePkce();
505
+ const { authUrl, state } = await brokerStart(port, codeChallenge);
506
+ opts.onAuthUrl?.(authUrl);
507
+ return await brokerExchange({
508
+ code: await waitForAuthCode(server, state, opts.timeoutMs ?? AUTH_TIMEOUT_MS),
509
+ state,
510
+ codeVerifier
511
+ });
512
+ };
343
513
  async function authorizeGoogle(opts = {}) {
344
- const secret = await loadClientSecret(opts.home);
514
+ const presence = await clientSecretPresence(opts.home);
515
+ if (presence === "ambiguous") await loadClientSecret(opts.home);
516
+ const useLocalClient = presence === "found";
345
517
  const { server, port } = await startLoopbackServer();
346
518
  try {
347
- const client = createClient(secret, `http://127.0.0.1:${port}${CALLBACK_PATH}`);
348
- const { codeVerifier, codeChallenge } = await client.generateCodeVerifierAsync();
349
- if (!codeChallenge) throw new Error("failed to derive a PKCE code challenge");
350
- const state = (0, node_crypto.randomBytes)(STATE_BYTES).toString("hex");
351
- opts.onAuthUrl?.(buildConsentUrl(client, codeChallenge, state));
352
- const code = await waitForAuthCode(server, state, opts.timeoutMs ?? AUTH_TIMEOUT_MS);
353
- const { tokens } = await client.getToken({
354
- code,
355
- codeVerifier
356
- });
357
- if (!tokens.refresh_token) throw new Error("Google returned no refresh token — remove this app under Google Account → Security → Third-party access, then retry");
358
- return await saveGoogleTokens(tokens, opts.home);
519
+ const issuedVia = useLocalClient ? "local" : "broker";
520
+ return await saveGoogleTokens({
521
+ ...useLocalClient ? await authorizeWithLocalClient(await loadClientSecret(opts.home), server, port, opts) : await authorizeWithBroker(server, port, opts),
522
+ issuedVia
523
+ }, opts.home);
359
524
  } finally {
360
525
  server.close();
361
526
  }
@@ -633,6 +798,10 @@ exports.GOOGLE_TASKS_SCOPE = GOOGLE_TASKS_SCOPE;
633
798
  exports.MAX_LIST_RESULTS = MAX_LIST_RESULTS;
634
799
  exports.assertSafeMimeType = assertSafeMimeType;
635
800
  exports.authorizeGoogle = authorizeGoogle;
801
+ exports.brokerBaseUrl = brokerBaseUrl;
802
+ exports.brokerExchange = brokerExchange;
803
+ exports.brokerRefresh = brokerRefresh;
804
+ exports.brokerStart = brokerStart;
636
805
  exports.buildMultipartBody = buildMultipartBody;
637
806
  exports.calendarApiError = calendarApiError;
638
807
  exports.clientSecretPresence = clientSecretPresence;