@mathismeadows/roamer-device-auth 1.5.6 → 1.5.8

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.
@@ -0,0 +1,24 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3
+ <plist version="1.0">
4
+ <dict>
5
+ <key>CFBundleDisplayName</key>
6
+ <string>Roamer MCP</string>
7
+ <key>CFBundleExecutable</key>
8
+ <string>roamer-mcp-notifier</string>
9
+ <key>CFBundleIdentifier</key>
10
+ <string>com.mathismeadows.roamer-mcp.notifier.v4</string>
11
+ <key>CFBundleIconFile</key>
12
+ <string>roamer-mcp-v4.icns</string>
13
+ <key>CFBundleName</key>
14
+ <string>Roamer MCP</string>
15
+ <key>CFBundlePackageType</key>
16
+ <string>APPL</string>
17
+ <key>CFBundleShortVersionString</key>
18
+ <string>1.0</string>
19
+ <key>CFBundleVersion</key>
20
+ <string>4</string>
21
+ <key>LSUIElement</key>
22
+ <true/>
23
+ </dict>
24
+ </plist>
@@ -0,0 +1,150 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3
+ <plist version="1.0">
4
+ <dict>
5
+ <key>files</key>
6
+ <dict>
7
+ <key>Resources/roamer-mcp-icon-v2.png</key>
8
+ <data>
9
+ 2dM6CEL1wlYNq4xt+ecVt+BHGGE=
10
+ </data>
11
+ <key>Resources/roamer-mcp-v4.icns</key>
12
+ <data>
13
+ cQdIamkBe9J4DyIwSJnoMbrgdL4=
14
+ </data>
15
+ <key>Resources/roamer-mcp.icns</key>
16
+ <data>
17
+ cQdIamkBe9J4DyIwSJnoMbrgdL4=
18
+ </data>
19
+ </dict>
20
+ <key>files2</key>
21
+ <dict>
22
+ <key>Resources/roamer-mcp-icon-v2.png</key>
23
+ <dict>
24
+ <key>hash2</key>
25
+ <data>
26
+ MWFb39dsRLKc+Q+xfVR6joPIp/MVngghyYXAYyTUEQg=
27
+ </data>
28
+ </dict>
29
+ <key>Resources/roamer-mcp-v4.icns</key>
30
+ <dict>
31
+ <key>hash2</key>
32
+ <data>
33
+ qx5IaaBZH5kHBh1t8Kb0XEpXM51aRZuMOvBIbQLqKsQ=
34
+ </data>
35
+ </dict>
36
+ <key>Resources/roamer-mcp.icns</key>
37
+ <dict>
38
+ <key>hash2</key>
39
+ <data>
40
+ qx5IaaBZH5kHBh1t8Kb0XEpXM51aRZuMOvBIbQLqKsQ=
41
+ </data>
42
+ </dict>
43
+ </dict>
44
+ <key>rules</key>
45
+ <dict>
46
+ <key>^Resources/</key>
47
+ <true/>
48
+ <key>^Resources/.*\.lproj/</key>
49
+ <dict>
50
+ <key>optional</key>
51
+ <true/>
52
+ <key>weight</key>
53
+ <real>1000</real>
54
+ </dict>
55
+ <key>^Resources/.*\.lproj/locversion.plist$</key>
56
+ <dict>
57
+ <key>omit</key>
58
+ <true/>
59
+ <key>weight</key>
60
+ <real>1100</real>
61
+ </dict>
62
+ <key>^Resources/Base\.lproj/</key>
63
+ <dict>
64
+ <key>weight</key>
65
+ <real>1010</real>
66
+ </dict>
67
+ <key>^version.plist$</key>
68
+ <true/>
69
+ </dict>
70
+ <key>rules2</key>
71
+ <dict>
72
+ <key>.*\.dSYM($|/)</key>
73
+ <dict>
74
+ <key>weight</key>
75
+ <real>11</real>
76
+ </dict>
77
+ <key>^(.*/)?\.DS_Store$</key>
78
+ <dict>
79
+ <key>omit</key>
80
+ <true/>
81
+ <key>weight</key>
82
+ <real>2000</real>
83
+ </dict>
84
+ <key>^(Frameworks|SharedFrameworks|PlugIns|Plug-ins|XPCServices|Helpers|MacOS|Library/(Automator|Spotlight|LoginItems))/</key>
85
+ <dict>
86
+ <key>nested</key>
87
+ <true/>
88
+ <key>weight</key>
89
+ <real>10</real>
90
+ </dict>
91
+ <key>^.*</key>
92
+ <true/>
93
+ <key>^Info\.plist$</key>
94
+ <dict>
95
+ <key>omit</key>
96
+ <true/>
97
+ <key>weight</key>
98
+ <real>20</real>
99
+ </dict>
100
+ <key>^PkgInfo$</key>
101
+ <dict>
102
+ <key>omit</key>
103
+ <true/>
104
+ <key>weight</key>
105
+ <real>20</real>
106
+ </dict>
107
+ <key>^Resources/</key>
108
+ <dict>
109
+ <key>weight</key>
110
+ <real>20</real>
111
+ </dict>
112
+ <key>^Resources/.*\.lproj/</key>
113
+ <dict>
114
+ <key>optional</key>
115
+ <true/>
116
+ <key>weight</key>
117
+ <real>1000</real>
118
+ </dict>
119
+ <key>^Resources/.*\.lproj/locversion.plist$</key>
120
+ <dict>
121
+ <key>omit</key>
122
+ <true/>
123
+ <key>weight</key>
124
+ <real>1100</real>
125
+ </dict>
126
+ <key>^Resources/Base\.lproj/</key>
127
+ <dict>
128
+ <key>weight</key>
129
+ <real>1010</real>
130
+ </dict>
131
+ <key>^[^/]+$</key>
132
+ <dict>
133
+ <key>nested</key>
134
+ <true/>
135
+ <key>weight</key>
136
+ <real>10</real>
137
+ </dict>
138
+ <key>^embedded\.provisionprofile$</key>
139
+ <dict>
140
+ <key>weight</key>
141
+ <real>20</real>
142
+ </dict>
143
+ <key>^version\.plist$</key>
144
+ <dict>
145
+ <key>weight</key>
146
+ <real>20</real>
147
+ </dict>
148
+ </dict>
149
+ </dict>
150
+ </plist>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mathismeadows/roamer-device-auth",
3
- "version": "1.5.6",
3
+ "version": "1.5.8",
4
4
  "private": false,
5
5
  "mcpName": "com.mathismeadows/roamer-mcp",
6
6
  "type": "module",
@@ -10,7 +10,8 @@
10
10
  },
11
11
  "main": "./roamer-device-auth.mjs",
12
12
  "files": [
13
- "roamer-device-auth.mjs"
13
+ "roamer-device-auth.mjs",
14
+ "macos-notifier.app/"
14
15
  ],
15
16
  "publishConfig": {
16
17
  "access": "public"
@@ -44,13 +44,13 @@ import {
44
44
  refreshAuthorization,
45
45
  } from "@modelcontextprotocol/sdk/client/auth.js";
46
46
  import { readFile, writeFile, mkdir, readdir, unlink } from "node:fs/promises";
47
- import { realpathSync } from "node:fs";
47
+ import { realpathSync, watch } from "node:fs";
48
48
  import { homedir } from "node:os";
49
- import { join } from "node:path";
49
+ import { dirname, join } from "node:path";
50
50
  import { setTimeout as sleep } from "node:timers/promises";
51
51
  import { execFile } from "node:child_process";
52
52
  import { promisify } from "node:util";
53
- import { pathToFileURL } from "node:url";
53
+ import { fileURLToPath, pathToFileURL } from "node:url";
54
54
  import { createServer } from "node:http";
55
55
  import qrcode from "qrcode-terminal";
56
56
 
@@ -60,6 +60,13 @@ const ROAMER_MCP_URL = process.env.ROAMER_MCP_URL ?? "https://roamer-mcp.mathism
60
60
  const ROAMER_MCP_ORIGIN = new URL(ROAMER_MCP_URL).origin;
61
61
 
62
62
  const CACHE_DIR = join(homedir(), ".mcp-auth-device");
63
+ const MACOS_NOTIFIER_PATH = join(
64
+ dirname(fileURLToPath(import.meta.url)),
65
+ "macos-notifier.app",
66
+ "Contents",
67
+ "MacOS",
68
+ "roamer-mcp-notifier",
69
+ );
63
70
 
64
71
  // AUTH-51: every on-disk cache is namespaced per client slug — a null slug (the connecting
65
72
  // host sent no usable clientInfo.name) means "never persist": the caller gets a real
@@ -329,18 +336,25 @@ async function showDeviceCodeDialog(verificationUri, userCode) {
329
336
  // distinction someone needs in order to notice a stale identity is still active.
330
337
  async function notifySessionReused(clientSlug, identityLabel) {
331
338
  const clientLabel = clientSlug ?? "an unidentified client";
332
- const message = identityLabel
339
+ const logMessage = identityLabel
333
340
  ? `Reused an existing Roamer MCP session for ${clientLabel} (${identityLabel}).`
334
341
  : `Reused an existing Roamer MCP session for ${clientLabel}.`;
335
- try {
336
- await execFileAsync("osascript", [
337
- "-e",
338
- `display notification "${message.replace(/"/g, '\\"')}" with title "Roamer MCP"`,
339
- ]);
340
- } catch {
341
- // Best-effort, macOS-only — the stderr log line below is the fallback for every other OS.
342
+ if (process.platform === "darwin") {
343
+ try {
344
+ await execFileAsync("open", [
345
+ "-n",
346
+ "-W",
347
+ join(dirname(MACOS_NOTIFIER_PATH), "..", ".."),
348
+ "--args",
349
+ "Roamer MCP",
350
+ identityLabel ? `Connected as ${identityLabel}.` : "Connected using a saved session.",
351
+ `Client: ${clientLabel}`,
352
+ ]);
353
+ } catch {
354
+ // Best-effort — stderr remains the fallback if native notifications are unavailable.
355
+ }
342
356
  }
343
- log(message);
357
+ log(logMessage);
344
358
  }
345
359
 
346
360
  // AUTH-54: identityKey defaults to null (the pre-AUTH-54 file shape) rather than being
@@ -969,6 +983,65 @@ function respondWithSignInError(line, err) {
969
983
  process.stdout.write(`${JSON.stringify(response)}\n`);
970
984
  }
971
985
 
986
+ // AUTH-63: pure comparison, exported for direct unit testing — true when the on-disk active
987
+ // identity for this slug no longer matches the identityKey this process resolved at startup.
988
+ // Normalizes both sides through `?? null` since a cleared/never-set active identity reads back
989
+ // as `undefined`, not `null`, and those must compare equal to "no identity" rather than as a
990
+ // spurious change.
991
+ function identityHasChanged(active, identityKey) {
992
+ return (active?.identityKey ?? null) !== (identityKey ?? null);
993
+ }
994
+
995
+ // AUTH-63: fast path (fs.watch) + slow-poll fallback, watching the containing directory rather
996
+ // than the bare active-identity file so a login/logout's delete-then-rewrite cycle survives — a
997
+ // bare-file watch's inode-tied handle dies on Linux/macOS across a delete/recreate. Node's own
998
+ // fs.watch docs say it "is not 100% consistent across platforms, and is unavailable in some
999
+ // situations," and the callback's filename argument isn't guaranteed everywhere — filename is
1000
+ // therefore used only as a cheap filter when present (skip re-checking on unrelated CACHE_DIR
1001
+ // writes, e.g. ordinary token refreshes), and always falls through to a real check when it's
1002
+ // null. The slow-poll interval is what actually guarantees this self-heals even on a
1003
+ // platform/filesystem combination where the fs.watch event never fires at all.
1004
+ const IDENTITY_WATCHDOG_POLL_INTERVAL_MS = 5 * 60 * 1000;
1005
+
1006
+ function startIdentityWatchdog(clientSlug, identityKey, shutdown) {
1007
+ if (!clientSlug) return () => {}; // nothing cacheable to watch for this run at all
1008
+ const activeFileName = `roamer_active__${clientSlug}.json`;
1009
+
1010
+ const checkForIdentityChange = async () => {
1011
+ try {
1012
+ const active = await readActiveIdentity(clientSlug);
1013
+ if (identityHasChanged(active, identityKey)) {
1014
+ await shutdown(
1015
+ `Active identity for client "${clientSlug}" changed elsewhere (now ${active?.label ?? "signed out"}) — exiting so the host respawns with the current credential.`,
1016
+ );
1017
+ }
1018
+ } catch (err) {
1019
+ // A transient read failure (e.g. caught mid-write) just retries on the next fs.watch
1020
+ // event or the next poll tick — never itself a reason to exit.
1021
+ log(`Identity watchdog check failed (ignoring): ${err.message}`);
1022
+ }
1023
+ };
1024
+
1025
+ let watcher = null;
1026
+ try {
1027
+ watcher = watch(CACHE_DIR, (eventType, filename) => {
1028
+ if (filename && filename !== activeFileName) return;
1029
+ checkForIdentityChange();
1030
+ });
1031
+ watcher.on("error", (err) => log(`Identity watchdog fs.watch error (relying on the slow-poll fallback): ${err.message}`));
1032
+ } catch (err) {
1033
+ log(`Identity watchdog fs.watch unavailable at startup (relying on the slow-poll fallback): ${err.message}`);
1034
+ }
1035
+
1036
+ const interval = setInterval(checkForIdentityChange, IDENTITY_WATCHDOG_POLL_INTERVAL_MS);
1037
+ interval.unref?.(); // a pending poll must never be the reason this process stays alive
1038
+
1039
+ return () => {
1040
+ watcher?.close();
1041
+ clearInterval(interval);
1042
+ };
1043
+ }
1044
+
972
1045
  async function main() {
973
1046
  // AUTH-28: stdin is read (and, until sign-in completes, queued) from the very first tick —
974
1047
  // a cold-cache device-code flow can take minutes, and the MCP host must never see this
@@ -984,6 +1057,10 @@ async function main() {
984
1057
  // connects — see identityResolved below), so roamer://whoami can answer even while the
985
1058
  // real upstream connection is still coming up.
986
1059
  let identityLabel = null;
1060
+ // AUTH-63: this process's own baseline identityKey, captured alongside identityLabel below —
1061
+ // the identity watchdog compares the on-disk active-identity pointer against this value to
1062
+ // notice a login/logout that happened elsewhere.
1063
+ let identityKey = null;
987
1064
  let identityResolved = false;
988
1065
  const pendingWhoamiLines = [];
989
1066
 
@@ -1037,11 +1114,19 @@ async function main() {
1037
1114
  }
1038
1115
  });
1039
1116
 
1040
- process.stdin.on("end", async () => {
1041
- log("stdin closed, shutting down.");
1117
+ // AUTH-63: shared by the stdin-close path below and the identity watchdog — a single,
1118
+ // idempotent clean-shutdown path rather than two places independently closing the transport
1119
+ // and calling process.exit.
1120
+ let shuttingDown = false;
1121
+ const shutdown = async (reason) => {
1122
+ if (shuttingDown) return;
1123
+ shuttingDown = true;
1124
+ log(reason);
1042
1125
  if (transport) await transport.close();
1043
1126
  process.exit(0);
1044
- });
1127
+ };
1128
+
1129
+ process.stdin.on("end", () => shutdown("stdin closed, shutting down."));
1045
1130
 
1046
1131
  log("Local STDIO proxy running. Press Ctrl+C to exit.");
1047
1132
 
@@ -1080,6 +1165,7 @@ async function main() {
1080
1165
  // adds a network round trip to every ordinary client startup in exchange for a readable
1081
1166
  // log line; falls back to the existing oid-based label on any failure.
1082
1167
  identityLabel = (await fetchLiveLabel(result.tokens?.access_token)) ?? result.label;
1168
+ identityKey = result.identityKey ?? null;
1083
1169
  } catch (err) {
1084
1170
  // Sign-in genuinely failed (denied/expired), not just slow — every message queued while
1085
1171
  // we waited, starting with `initialize`, gets a real JSON-RPC error so the host sees an
@@ -1103,6 +1189,11 @@ async function main() {
1103
1189
  identityResolved = true;
1104
1190
  for (const line of pendingWhoamiLines) respondWhoami(line, clientSlug, identityLabel);
1105
1191
 
1192
+ // AUTH-63: starts watching for a login/logout elsewhere on this same client slug — this
1193
+ // process's own cached identity never hot-swaps, so the only correct response to noticing
1194
+ // it's now stale is a clean self-exit (via `shutdown` above) for the host to respawn from.
1195
+ startIdentityWatchdog(clientSlug, identityKey, shutdown);
1196
+
1106
1197
  // AUTH-51: the moment that used to be completely silent — a process starting up and
1107
1198
  // immediately using a credential it never interactively obtained this run.
1108
1199
  if (reusedSilently) await notifySessionReused(clientSlug, identityLabel);
@@ -1309,4 +1400,7 @@ export {
1309
1400
  // AUTH-60
1310
1401
  isWhoamiResourceRead,
1311
1402
  respondWhoami,
1403
+ // AUTH-63
1404
+ identityHasChanged,
1405
+ startIdentityWatchdog,
1312
1406
  };