@modootoday/datalab-extension-mcp 1.8.8 → 1.8.10

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/README.en.md CHANGED
@@ -55,10 +55,10 @@ Avoid sharing a screenshot or the copied setup. If it may have leaked, press **[
55
55
  One command finds **every** AI app on your computer and connects them automatically. Nothing to do per app.
56
56
 
57
57
  1. **Open a terminal** — on Windows type `cmd` in the search box → "Command Prompt"; on Mac press `⌘+Space` and type `Terminal`; on Linux open your terminal app (often `Ctrl+Alt+T`).
58
- 2. **Paste this command and press Enter.** (`@1.8.8` is the pinned version, so the connector never changes behind your back.)
58
+ 2. **Paste this command and press Enter.** (`@1.8.10` is the pinned version, so the connector never changes behind your back.)
59
59
 
60
60
  ```
61
- npx -y @modootoday/datalab-extension-mcp@1.8.8 install
61
+ npx -y @modootoday/datalab-extension-mcp@1.8.10 install
62
62
  ```
63
63
 
64
64
  Lines of English scrolling by is normal. This only downloads and runs the connector — it touches nothing else.
@@ -252,7 +252,7 @@ Start it again after a full quit and it picks the tools up. If they still aren't
252
252
  - **When the connection disappears** — run the very command you used at the start once more; it safely re-registers. (Some AI apps clean up their settings file and drop the connection.)
253
253
 
254
254
  ```
255
- npx -y @modootoday/datalab-extension-mcp@1.8.8 install
255
+ npx -y @modootoday/datalab-extension-mcp@1.8.10 install
256
256
  ```
257
257
 
258
258
  - **`'npx' is not recognized...`** — Node.js isn't installed. The "No Node.js?" one-liner in Step 2 is the simplest fix.
@@ -282,7 +282,7 @@ Start it again after a full quit and it picks the tools up. If they still aren't
282
282
  1. Run this in a terminal (it removes the entries registered in your AI apps).
283
283
 
284
284
  ```
285
- npx -y @modootoday/datalab-extension-mcp@1.8.8 uninstall
285
+ npx -y @modootoday/datalab-extension-mcp@1.8.10 uninstall
286
286
  ```
287
287
 
288
288
  2. Turn **MCP 연결** off in the extension settings. The panel is the bridge, so with it off a leftover config line can look nothing up.
package/README.md CHANGED
@@ -49,10 +49,10 @@ Claude 같은 AI 앱을 데이터랩툴즈에 연결해 주는 작은 무료 프
49
49
  명령 한 줄이면 컴퓨터에 있는 AI 앱을 **모두 자동으로** 찾아 연결해 줘요. 앱마다 따로 할 일은 없어요.
50
50
 
51
51
  1. **터미널을 열어요** — Windows는 검색창에 `cmd` → "명령 프롬프트", Mac은 `⌘+스페이스`로 `터미널`, Linux는 터미널 앱(보통 `Ctrl+Alt+T`)이에요.
52
- 2. **아래 명령을 붙여넣고 Enter를 눌러요.** (`@1.8.8`은 커넥터가 나도 모르게 바뀌지 않도록 고정한 버전이에요.)
52
+ 2. **아래 명령을 붙여넣고 Enter를 눌러요.** (`@1.8.10`은 커넥터가 나도 모르게 바뀌지 않도록 고정한 버전이에요.)
53
53
 
54
54
  ```
55
- npx -y @modootoday/datalab-extension-mcp@1.8.8 install
55
+ npx -y @modootoday/datalab-extension-mcp@1.8.10 install
56
56
  ```
57
57
 
58
58
  영어 글자가 여러 줄 올라오는 건 정상이에요. 이 명령은 커넥터를 내려받아 실행할 뿐, 다른 건 건드리지 않아요.
@@ -220,7 +220,7 @@ AI가 "그런 기능은 없다" 고 답하면, **"도구 목록을 확인해 보
220
220
  - **연결이 사라졌을 때** — 처음 실행했던 그 명령을 한 번 더 실행하면 안전하게 다시 등록돼요. (일부 AI 앱이 설정 파일을 정리하며 연결을 지우기도 해요.)
221
221
 
222
222
  ```
223
- npx -y @modootoday/datalab-extension-mcp@1.8.8 install
223
+ npx -y @modootoday/datalab-extension-mcp@1.8.10 install
224
224
  ```
225
225
 
226
226
  - **`'npx'을(는) 내부 또는 외부 명령...`** — Node.js가 없는 거예요. 위 2단계의 "Node.js가 없다면" 한 줄 설치가 가장 간단해요.
@@ -250,7 +250,7 @@ AI가 "그런 기능은 없다" 고 답하면, **"도구 목록을 확인해 보
250
250
  1. 터미널에서 아래 명령을 실행해요(AI 앱에 등록된 설정을 지워 줘요).
251
251
 
252
252
  ```
253
- npx -y @modootoday/datalab-extension-mcp@1.8.8 uninstall
253
+ npx -y @modootoday/datalab-extension-mcp@1.8.10 uninstall
254
254
  ```
255
255
 
256
256
  2. 확장 설정에서 **MCP 연결을 꺼요.** 패널이 브릿지라, 꺼 두면 어딘가 설정 줄이 남아 있어도 AI 앱이 아무것도 조회할 수 없어요.
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  DEFAULT_HOST,
3
3
  DEFAULT_PORT
4
- } from "./chunk-U7AIWUSN.js";
4
+ } from "./chunk-DOPJ4OZ4.js";
5
5
  import {
6
6
  TOKEN_PROMPT_GUIDE,
7
7
  TOKEN_PROMPT_QUESTION,
@@ -40,9 +40,9 @@ async function runBrowsers(argv, deps) {
40
40
  const send = deps.fetchImpl ?? globalThis.fetch;
41
41
  const rest = withoutFlag(argv, "--token");
42
42
  const action = rest[0] ?? "list";
43
- if (action !== "add" && action !== "list" && action !== "revoke") {
43
+ if (action !== "add" && action !== "list" && action !== "revoke" && action !== "repair") {
44
44
  deps.out(
45
- `usage: browsers [add <label>|list|revoke <handle>] [--token <t>]`
45
+ `usage: browsers [add <label>|list|revoke <handle>|repair] [--token <t>]`
46
46
  );
47
47
  return 2;
48
48
  }
@@ -93,6 +93,13 @@ async function runBrowsers(argv, deps) {
93
93
  }
94
94
  if (action === "list") {
95
95
  const rows = parsed["browsers"] ?? [];
96
+ const registryStatus = parsed["registryStatus"];
97
+ if (registryStatus?.state === "degraded") {
98
+ deps.out(
99
+ `Registry warning: ${String(registryStatus.error ?? "degraded")}.`
100
+ );
101
+ deps.out("Run: datalab-extension-mcp browsers repair");
102
+ }
96
103
  if (rows.length === 0) {
97
104
  deps.out("No browsers registered.");
98
105
  return 0;
@@ -109,6 +116,13 @@ async function runBrowsers(argv, deps) {
109
116
  );
110
117
  return parsed["removed"] === true ? 0 : 1;
111
118
  }
119
+ if (action === "repair") {
120
+ const removed = Array.isArray(parsed["removed"]) ? parsed["removed"].length : 0;
121
+ deps.out(
122
+ removed > 0 ? `Repaired ${String(removed)} conflicting browser registration.` : "No conflicting browser registrations found."
123
+ );
124
+ return 0;
125
+ }
112
126
  deps.out(`Registered ${String(parsed["handle"])}.`);
113
127
  deps.out(`Paste this into the browser's panel \u2014 it is shown only now:`);
114
128
  deps.out(String(parsed["token"]));
@@ -1,4 +1,4 @@
1
- // ../mcp-server/dist/chunk-YXH45HHP.js
1
+ // ../mcp-server/dist/chunk-Q4ALBPO7.js
2
2
  import { createHash } from "crypto";
3
3
  import { createHash as createHash2, randomUUID } from "crypto";
4
4
  import { mkdir, readFile, readdir, rm, writeFile } from "fs/promises";
@@ -24,8 +24,8 @@ import { spawn } from "child_process";
24
24
  import { closeSync } from "fs";
25
25
  import { randomBytes } from "crypto";
26
26
  import { readFileSync } from "fs";
27
- import { rename, writeFile as writeFile2 } from "fs/promises";
28
- import { join as join3 } from "path";
27
+ import { mkdir as mkdir2, rename, writeFile as writeFile2 } from "fs/promises";
28
+ import { dirname, join as join3 } from "path";
29
29
  import { createRequire } from "module";
30
30
  import { join as join4 } from "path";
31
31
  var __defProp = Object.defineProperty;
@@ -14387,7 +14387,6 @@ function date4(params) {
14387
14387
  return /* @__PURE__ */ _coercedDate(ZodDate, params);
14388
14388
  }
14389
14389
  config(en_default());
14390
- var BRIDGE_PROTOCOL_VERSION = 1;
14391
14390
  var SUPPORTED_PROTOCOL_VERSIONS = [1];
14392
14391
  var MCP_TOOL_NAME_RE = /^[a-zA-Z0-9_-]{1,64}$/;
14393
14392
  var BridgeToolDescriptorSchema = external_exports.object({
@@ -14463,7 +14462,9 @@ var HelloNackReasonSchema = external_exports.enum([
14463
14462
  /** Origin header absent or not our extension. */
14464
14463
  "forbidden_origin",
14465
14464
  /** This browser already has an active event stream. */
14466
- "session_active"
14465
+ "session_active",
14466
+ /** Root authority cannot open a browser session. */
14467
+ "browser_registration_required"
14467
14468
  ]);
14468
14469
  var BrowserRoutingSchema = external_exports.object({
14469
14470
  targetBrowserId: external_exports.string(),
@@ -14653,6 +14654,14 @@ var REFUSED = {
14653
14654
  message: "Pairing token missing or invalid."
14654
14655
  };
14655
14656
  function resolveCredential(presented, known) {
14657
+ const root = checkToken(presented.token, known.rootToken);
14658
+ if (root.ok) {
14659
+ return {
14660
+ kind: "refused",
14661
+ reason: "browser_registration_required",
14662
+ message: "The root token is management-only. Register this browser with its own token."
14663
+ };
14664
+ }
14656
14665
  const { browserId } = presented;
14657
14666
  if (browserId !== void 0 && browserId.length > 0) {
14658
14667
  const expected = known.claimed.get(browserId);
@@ -14663,8 +14672,7 @@ function resolveCredential(presented, known) {
14663
14672
  return REFUSED;
14664
14673
  }
14665
14674
  }
14666
- const root = checkToken(presented.token, known.rootToken);
14667
- return root.ok ? { kind: "root" } : REFUSED;
14675
+ return REFUSED;
14668
14676
  }
14669
14677
  var READ_ONLY_TOOLS = [
14670
14678
  "datalab_browsers",
@@ -15616,12 +15624,7 @@ var PendingRegistry = class {
15616
15624
  function digestToken(token) {
15617
15625
  return createHash("sha256").update(token, "utf8").digest("hex");
15618
15626
  }
15619
- var LEGACY_SESSION_KEY = "legacy";
15620
- function sessionKeyFor(credential) {
15621
- return credential.kind === "claimed" ? credential.browserId : LEGACY_SESSION_KEY;
15622
- }
15623
15627
  var DEFAULT_RATE_LIMIT = BRIDGE_LIMITS;
15624
- var STALE_TOKEN_LIMIT = 5;
15625
15628
  function daemonIdTag() {
15626
15629
  return Math.floor(Math.random() * 16777215).toString(36).padStart(4, "0");
15627
15630
  }
@@ -15631,8 +15634,7 @@ var Bridge = class {
15631
15634
  #idTag = daemonIdTag();
15632
15635
  #sessionSeq = 0;
15633
15636
  #defaultKey = null;
15634
- /** Consecutive handshakes refused for our own token. See `#noteStaleToken`. */
15635
- #badTokenStreak = 0;
15637
+ #retireSession = null;
15636
15638
  #bucketTokens = 0;
15637
15639
  #bucketRefilledAt = 0;
15638
15640
  // Last catalog the panel reported, kept across disconnects: many hosts cache
@@ -15642,6 +15644,12 @@ var Bridge = class {
15642
15644
  constructor(deps) {
15643
15645
  this.#deps = deps;
15644
15646
  }
15647
+ bindRetireSession(retire) {
15648
+ if (this.#retireSession !== null) {
15649
+ throw new Error("bridge transport retirement is already bound");
15650
+ }
15651
+ this.#retireSession = retire;
15652
+ }
15645
15653
  /**
15646
15654
  * The session a request with no browser named goes to. Map preserves
15647
15655
  * insertion order, so this is the oldest one — while the key space holds a
@@ -15673,6 +15681,9 @@ var Bridge = class {
15673
15681
  primary: primary === id
15674
15682
  }));
15675
15683
  }
15684
+ browserRegistryStatus() {
15685
+ return this.#deps.browserRegistryStatus?.() ?? { state: "ready" };
15686
+ }
15676
15687
  /**
15677
15688
  * The only place a registry is born, and it is born already in the map.
15678
15689
  * One living anywhere else would be missed by `sweep` and by `#closeSession`,
@@ -15696,7 +15707,6 @@ var Bridge = class {
15696
15707
  key,
15697
15708
  session,
15698
15709
  tokenHash: auth.tokenHash,
15699
- authRequired: auth.authRequired,
15700
15710
  routingRequired: auth.routingRequired,
15701
15711
  fileRequestBound: auth.fileRequestBound,
15702
15712
  streamActive: false,
@@ -15748,9 +15758,6 @@ var Bridge = class {
15748
15758
  sessionForKey(key) {
15749
15759
  return this.#sessions.get(key)?.session ?? null;
15750
15760
  }
15751
- legacySessionKey() {
15752
- return this.#sessions.has(LEGACY_SESSION_KEY) ? LEGACY_SESSION_KEY : null;
15753
- }
15754
15761
  /** Is a panel connected and ready to serve tool calls? */
15755
15762
  get connected() {
15756
15763
  return this.#primary() !== null;
@@ -15797,9 +15804,6 @@ var Bridge = class {
15797
15804
  if (!timingSafeEqual(hash2, entry.tokenHash) || this.#deps.wasRevoked(hash2)) {
15798
15805
  return null;
15799
15806
  }
15800
- if (entry.key === LEGACY_SESSION_KEY) {
15801
- return this.verifyRootToken(presented) ? "root" : null;
15802
- }
15803
15807
  const current = this.#deps.claimedBrowsers.get(entry.key);
15804
15808
  return current !== void 0 && timingSafeEqual(hash2, current) ? "claimed_browser" : null;
15805
15809
  }
@@ -15811,33 +15815,6 @@ var Bridge = class {
15811
15815
  }
15812
15816
  return "unknown";
15813
15817
  }
15814
- authorizeLegacyFileCredential(presented) {
15815
- if (typeof presented !== "string" || presented.length === 0) {
15816
- return {
15817
- ok: false,
15818
- reason: "file_session_token_missing",
15819
- credential: "missing"
15820
- };
15821
- }
15822
- const credential = this.#fileCredentialClass(presented);
15823
- for (const entry of this.#sessions.values()) {
15824
- const accepted = this.#fileCredential(entry, presented);
15825
- if (accepted && !entry.fileRequestBound) {
15826
- return {
15827
- ok: true,
15828
- key: entry.key,
15829
- sessionId: entry.session.sessionId,
15830
- credential: accepted,
15831
- requestBound: false
15832
- };
15833
- }
15834
- }
15835
- return {
15836
- ok: false,
15837
- reason: "file_session_token_mismatch",
15838
- credential
15839
- };
15840
- }
15841
15818
  authorizeFileRequest(input) {
15842
15819
  const entry = [...this.#sessions.values()].find(
15843
15820
  (candidate) => candidate.session.sessionId === input.sessionId
@@ -15856,7 +15833,7 @@ var Bridge = class {
15856
15833
  credential: "missing",
15857
15834
  key: entry.key,
15858
15835
  sessionId: entry.session.sessionId,
15859
- ...input.requestId === void 0 ? {} : { requestId: input.requestId }
15836
+ requestId: input.requestId
15860
15837
  };
15861
15838
  }
15862
15839
  const credential = this.#fileCredential(entry, input.token);
@@ -15867,32 +15844,23 @@ var Bridge = class {
15867
15844
  credential: this.#fileCredentialClass(input.token),
15868
15845
  key: entry.key,
15869
15846
  sessionId: entry.session.sessionId,
15870
- ...input.requestId === void 0 ? {} : { requestId: input.requestId }
15847
+ requestId: input.requestId
15871
15848
  };
15872
15849
  }
15873
15850
  if (!entry.fileRequestBound) {
15874
15851
  return {
15875
- ok: true,
15852
+ ok: false,
15853
+ reason: "file_request_expired",
15854
+ credential,
15876
15855
  key: entry.key,
15877
15856
  sessionId: entry.session.sessionId,
15878
- ...input.requestId === void 0 ? {} : { requestId: input.requestId },
15879
- credential,
15880
- requestBound: false
15857
+ requestId: input.requestId
15881
15858
  };
15882
15859
  }
15883
15860
  const now = (this.#deps.now ?? Date.now)();
15884
15861
  for (const [requestId, permit2] of entry.filePermits) {
15885
15862
  if (permit2.expiresAt <= now) entry.filePermits.delete(requestId);
15886
15863
  }
15887
- if (input.requestId === void 0) {
15888
- return {
15889
- ok: false,
15890
- reason: "file_request_expired",
15891
- credential,
15892
- key: entry.key,
15893
- sessionId: entry.session.sessionId
15894
- };
15895
- }
15896
15864
  const permit = entry.filePermits.get(input.requestId);
15897
15865
  if (!permit) {
15898
15866
  return {
@@ -15948,36 +15916,6 @@ var Bridge = class {
15948
15916
  }
15949
15917
  return null;
15950
15918
  }
15951
- /**
15952
- * The extension is right about the token, and we can be wrong about it.
15953
- *
15954
- * This server is detached and outlives the app that started it, and a
15955
- * replacement only takes over when its version is newer. Once the user
15956
- * issues a fresh token, stepping aside is gated on the very token that no
15957
- * longer matches, so the server has to notice on its own. A run of refusals
15958
- * with nobody connected says exactly that, and exiting costs nothing.
15959
- */
15960
- #noteStaleToken(browserId, tokenHash) {
15961
- if (this.#deps.wasRevoked(tokenHash)) {
15962
- this.#deps.log?.("refused a revoked registration; not ours to leave for");
15963
- return;
15964
- }
15965
- if (this.#isClaimed(browserId)) {
15966
- this.#deps.log?.("refused a claimed browser; not ours to step aside for");
15967
- return;
15968
- }
15969
- if (this.#sessions.has(LEGACY_SESSION_KEY)) return;
15970
- this.#badTokenStreak += 1;
15971
- if (this.#badTokenStreak < STALE_TOKEN_LIMIT) return;
15972
- this.#deps.log?.(
15973
- `refused ${this.#badTokenStreak} handshakes for a stale token; stepping aside`
15974
- );
15975
- this.#badTokenStreak = 0;
15976
- this.#deps.onStaleToken?.();
15977
- }
15978
- #isClaimed(browserId) {
15979
- return browserId !== void 0 && this.#deps.claimedBrowsers.has(browserId);
15980
- }
15981
15919
  /**
15982
15920
  * Run the handshake.
15983
15921
  *
@@ -16018,7 +15956,9 @@ var Bridge = class {
16018
15956
  { rootToken: this.#deps.token, claimed: this.#deps.claimedBrowsers }
16019
15957
  );
16020
15958
  if (credential.kind === "refused") {
16021
- this.#noteStaleToken(parsed.data.browserId, tokenHash);
15959
+ this.#deps.log?.(
15960
+ this.#deps.wasRevoked(tokenHash) ? "refused a revoked browser credential" : "refused an unauthorized browser credential"
15961
+ );
16022
15962
  return {
16023
15963
  t: "hello_nack",
16024
15964
  reason: credential.reason,
@@ -16026,7 +15966,14 @@ var Bridge = class {
16026
15966
  message: credential.message
16027
15967
  };
16028
15968
  }
16029
- if (credential.kind === "root") this.#badTokenStreak = 0;
15969
+ if (credential.kind !== "claimed") {
15970
+ return {
15971
+ t: "hello_nack",
15972
+ reason: "browser_registration_required",
15973
+ supported: [],
15974
+ message: "The root token is management-only. Register this browser with its own token."
15975
+ };
15976
+ }
16030
15977
  const negotiated = negotiateProtocol(parsed.data.protocolVersions);
16031
15978
  if (!negotiated.ok) {
16032
15979
  return {
@@ -16036,14 +15983,10 @@ var Bridge = class {
16036
15983
  message: negotiated.message
16037
15984
  };
16038
15985
  }
16039
- const key = sessionKeyFor(credential);
16040
- if (this.#sessions.get(key)?.streamActive) {
16041
- return {
16042
- t: "hello_nack",
16043
- reason: "session_active",
16044
- supported: [BRIDGE_PROTOCOL_VERSION],
16045
- message: "This browser already has an active panel session."
16046
- };
15986
+ const key = credential.browserId;
15987
+ const held = this.#sessions.get(key);
15988
+ if (held?.streamActive) {
15989
+ this.#retireSession?.(key, held.session.sessionId);
16047
15990
  }
16048
15991
  const session = {
16049
15992
  // A counter, not a timestamp: a panel reload produces two handshakes
@@ -16053,16 +15996,12 @@ var Bridge = class {
16053
15996
  protocolVersion: negotiated.version,
16054
15997
  extensionVersion: parsed.data.extensionVersion
16055
15998
  };
16056
- const authRequired = parsed.data.capabilities.includes(
16057
- BRIDGE_AUTH_CAPABILITY
16058
- );
16059
15999
  const routingRequired = credential.kind === "claimed" && parsed.data.capabilities.includes(BROWSER_ROUTING_CAPABILITY);
16060
16000
  const fileRequestBound = parsed.data.capabilities.includes(
16061
16001
  LOCAL_FILE_REQUEST_CAPABILITY
16062
16002
  );
16063
16003
  this.#installSession(key, session, {
16064
16004
  tokenHash,
16065
- authRequired,
16066
16005
  routingRequired,
16067
16006
  fileRequestBound
16068
16007
  });
@@ -16074,10 +16013,8 @@ var Bridge = class {
16074
16013
  protocolVersion: negotiated.version,
16075
16014
  serverVersion: this.#deps.serverVersion,
16076
16015
  sessionId: session.sessionId,
16077
- // Echoed only when it is actually on, so the panel can tell a server that
16078
- // is holding it to the header from one that predates the gate entirely.
16079
16016
  capabilities: [
16080
- ...authRequired ? [BRIDGE_AUTH_CAPABILITY] : [],
16017
+ BRIDGE_AUTH_CAPABILITY,
16081
16018
  ...routingRequired ? [BROWSER_ROUTING_CAPABILITY] : [],
16082
16019
  ...fileRequestBound ? [LOCAL_FILE_REQUEST_CAPABILITY] : []
16083
16020
  ]
@@ -16098,15 +16035,13 @@ var Bridge = class {
16098
16035
  return this.#mayAct(entry, presented);
16099
16036
  }
16100
16037
  #mayAct(entry, presented) {
16101
- if (!entry.authRequired) return true;
16102
16038
  if (typeof presented !== "string" || presented.length === 0) return false;
16103
16039
  return timingSafeEqual(digestToken(presented), entry.tokenHash);
16104
16040
  }
16105
16041
  /**
16106
16042
  * Route a frame from the panel to whoever is waiting for it.
16107
16043
  *
16108
- * `presented` is the credential the poster carried. A session that asked to
16109
- * be held to it refuses anything else; one that did not is unaffected.
16044
+ * `presented` is the credential the poster carried.
16110
16045
  */
16111
16046
  receive(raw, presented) {
16112
16047
  const classified = classifyFrame(
@@ -16298,7 +16233,7 @@ var Bridge = class {
16298
16233
  if (browser === void 0) {
16299
16234
  entry = this.#primary();
16300
16235
  } else {
16301
- const known = this.#deps.claimedBrowsers.has(browser) || browser === LEGACY_SESSION_KEY && this.#sessions.has(browser);
16236
+ const known = this.#deps.claimedBrowsers.has(browser);
16302
16237
  if (!known) {
16303
16238
  return {
16304
16239
  ok: false,
@@ -16887,14 +16822,22 @@ async function handleMcpRequest(bridge, body, accept, stage) {
16887
16822
  delete args["browser"];
16888
16823
  }
16889
16824
  if (params.name === BROWSERS_TOOL) {
16825
+ const registryStatus = bridge.browserRegistryStatus();
16826
+ const { state: registryState, ...registryDetail } = registryStatus;
16827
+ const payload = {
16828
+ browsers: bridge.connectedBrowsers(),
16829
+ registryStatus: registryState,
16830
+ ...registryState === "ready" ? {} : registryDetail
16831
+ };
16890
16832
  return frameMcpResponse(
16891
16833
  rpcResult(id, {
16892
16834
  content: [
16893
16835
  {
16894
16836
  type: "text",
16895
- text: JSON.stringify({ browsers: bridge.connectedBrowsers() })
16837
+ text: JSON.stringify(payload)
16896
16838
  }
16897
- ]
16839
+ ],
16840
+ ...registryState === "disabled" ? { isError: true } : {}
16898
16841
  }),
16899
16842
  accept
16900
16843
  );
@@ -17194,6 +17137,13 @@ function createHttpBridge(opts) {
17194
17137
  opts.bridge.disconnect(key);
17195
17138
  if (wasPrimary) scheduleToolsDrop();
17196
17139
  };
17140
+ opts.bridge.bindRetireSession((key, sessionId) => {
17141
+ const stream = streams.get(key);
17142
+ if (!stream || stream.sessionId !== sessionId) return;
17143
+ const wasPrimary = key === opts.bridge.sessionKey;
17144
+ removeStream(key)?.end();
17145
+ if (wasPrimary) scheduleToolsDrop();
17146
+ });
17197
17147
  const server = createServer((req, res) => {
17198
17148
  const url2 = new URL(req.url ?? "/", `http://${host}`);
17199
17149
  const origin = req.headers.origin;
@@ -17235,6 +17185,18 @@ function createHttpBridge(opts) {
17235
17185
  });
17236
17186
  return;
17237
17187
  }
17188
+ if (req.method === "POST" && url2.pathname === "/mcp/authority") {
17189
+ void readJson(req, MAX_BODY_BYTES).then((body) => {
17190
+ const token = body?.token;
17191
+ if (!opts.bridge.verifyRootToken(typeof token === "string" ? token : "")) {
17192
+ json2(res, 403, { error: "authority_mismatch" });
17193
+ return;
17194
+ }
17195
+ res.writeHead(204, { "cache-control": "no-store" });
17196
+ res.end();
17197
+ }).catch(() => json2(res, 400, { error: "bad request" }));
17198
+ return;
17199
+ }
17238
17200
  if (req.method === "POST" && url2.pathname === "/bridge/hello") {
17239
17201
  void readJson(req, MAX_BODY_BYTES).then((body) => {
17240
17202
  const out = opts.bridge.handshake(body, origin);
@@ -17251,7 +17213,11 @@ function createHttpBridge(opts) {
17251
17213
  }
17252
17214
  if (req.method === "GET" && url2.pathname === "/bridge/events") {
17253
17215
  const claimed = url2.searchParams.get("session");
17254
- const key = claimed === null ? opts.bridge.legacySessionKey() : opts.bridge.keyForSession(claimed);
17216
+ if (claimed === null || claimed.length === 0) {
17217
+ json2(res, 400, { error: "session_required" });
17218
+ return;
17219
+ }
17220
+ const key = claimed === null ? null : opts.bridge.keyForSession(claimed);
17255
17221
  if (key === null) {
17256
17222
  opts.log?.("refused a stream for an unknown session");
17257
17223
  res.writeHead(200, { "content-type": "text/event-stream" });
@@ -17278,13 +17244,8 @@ function createHttpBridge(opts) {
17278
17244
  // by nature.
17279
17245
  "x-accel-buffering": "no"
17280
17246
  });
17281
- const sessionId = claimed ?? opts.bridge.sessionForKey(key)?.sessionId ?? "";
17282
- if (!sessionId) {
17283
- res.end();
17284
- return;
17285
- }
17286
17247
  try {
17287
- installStream(key, sessionId, res);
17248
+ installStream(key, claimed, res);
17288
17249
  } catch {
17289
17250
  res.end();
17290
17251
  return;
@@ -17325,7 +17286,11 @@ function createHttpBridge(opts) {
17325
17286
  }
17326
17287
  const action = b?.["action"];
17327
17288
  if (action === "list") {
17328
- json2(res, 200, { ok: true, browsers: await registry2.list() });
17289
+ json2(res, 200, {
17290
+ ok: true,
17291
+ browsers: await registry2.list(),
17292
+ registryStatus: registry2.status()
17293
+ });
17329
17294
  return;
17330
17295
  }
17331
17296
  if (action === "add") {
@@ -17341,6 +17306,10 @@ function createHttpBridge(opts) {
17341
17306
  });
17342
17307
  return;
17343
17308
  }
17309
+ if (action === "repair") {
17310
+ json2(res, 200, { ok: true, ...await registry2.repair() });
17311
+ return;
17312
+ }
17344
17313
  json2(res, 400, { error: "unknown action" });
17345
17314
  }).catch((err) => {
17346
17315
  const why = err instanceof Error ? err.message : String(err);
@@ -17358,20 +17327,20 @@ function createHttpBridge(opts) {
17358
17327
  const hasRequestId = b !== null && Object.hasOwn(b, "requestId");
17359
17328
  const invalidSessionId = hasSessionId && (typeof b?.sessionId !== "string" || b.sessionId.length === 0);
17360
17329
  const invalidRequestId = hasRequestId && (typeof b?.requestId !== "string" || b.requestId.length === 0);
17361
- if (invalidSessionId || invalidRequestId) {
17330
+ if (!hasSessionId || !hasRequestId || invalidSessionId || invalidRequestId) {
17362
17331
  json2(res, 403, {
17363
17332
  error: "unauthorized",
17364
17333
  message: "\uC0AC\uC9C4 \uC694\uCCAD\uC758 \uC5F0\uACB0 \uC815\uBCF4\uB97C \uD655\uC778\uD558\uC9C0 \uBABB\uD588\uC5B4\uC694."
17365
17334
  });
17366
17335
  return;
17367
17336
  }
17368
- const auth = hasSessionId ? opts.bridge.authorizeFileRequest({
17337
+ const auth = opts.bridge.authorizeFileRequest({
17369
17338
  sessionId: b.sessionId,
17370
- ...hasRequestId ? { requestId: b.requestId } : {},
17339
+ requestId: b.requestId,
17371
17340
  token,
17372
17341
  path,
17373
17342
  ...typeof b?.tool === "string" ? { tool: b.tool } : {}
17374
- }) : opts.bridge.authorizeLegacyFileCredential(token);
17343
+ });
17375
17344
  if (!auth.ok) {
17376
17345
  opts.log?.(
17377
17346
  `file request refused: reason=${auth.reason} credential=${auth.credential} tokenPresent=${String(token.length > 0)} sessionMatch=${String(auth.key !== void 0)} open=not-entered`
@@ -17394,7 +17363,7 @@ function createHttpBridge(opts) {
17394
17363
  return;
17395
17364
  }
17396
17365
  opts.log?.(
17397
- `file request authorised: browser=${correlationValue(auth.key, 8)} session=${correlationValue(auth.sessionId, 32)} request=${auth.requestId ? correlationValue(auth.requestId, 96) : "legacy"} credential=${auth.credential} registryMatch=${String(auth.credential === "claimed_browser")} open=entered`
17366
+ `file request authorised: browser=${correlationValue(auth.key, 8)} session=${correlationValue(auth.sessionId, 32)} request=${correlationValue(auth.requestId, 96)} credential=${auth.credential} registryMatch=${String(auth.credential === "claimed_browser")} open=entered`
17398
17367
  );
17399
17368
  let source = null;
17400
17369
  let owner = null;
@@ -17853,6 +17822,7 @@ async function writeLedger(path, claims) {
17853
17822
  var writing = /* @__PURE__ */ new Map();
17854
17823
  async function writeOnce(path, claims) {
17855
17824
  const tmp = `${path}.${process.pid}.tmp`;
17825
+ await mkdir2(dirname(path), { recursive: true, mode: 448 });
17856
17826
  await writeFile2(tmp, `${JSON.stringify(claims, null, 2)}
17857
17827
  `, {
17858
17828
  mode: 384
@@ -17884,17 +17854,24 @@ function matches(claim, handle) {
17884
17854
  function createBrowserRegistry(opts) {
17885
17855
  const now = opts.now ?? (() => /* @__PURE__ */ new Date());
17886
17856
  const revoked = /* @__PURE__ */ new Set();
17887
- const current = () => {
17857
+ const readCurrent = () => {
17888
17858
  const read = readLedger(opts.path);
17889
17859
  if (read.state === "unreadable") {
17890
17860
  throw new Error(`browser registry is unreadable: ${read.reason}`);
17891
17861
  }
17892
17862
  const rows = read.state === "ok" ? [...read.rows] : [];
17893
- if (opts.forbiddenTokenHash && ledgerOverlapsToken(rows, opts.forbiddenTokenHash)) {
17894
- throw new Error("browser registry overlaps the root authority");
17895
- }
17896
- return rows;
17863
+ if (!opts.forbiddenTokenHash) return { rows, quarantined: [] };
17864
+ const quarantined = rows.filter(
17865
+ (row) => timingSafeEqual(row.tokenHash, opts.forbiddenTokenHash)
17866
+ );
17867
+ return {
17868
+ rows: rows.filter(
17869
+ (row) => !timingSafeEqual(row.tokenHash, opts.forbiddenTokenHash)
17870
+ ),
17871
+ quarantined
17872
+ };
17897
17873
  };
17874
+ const current = () => readCurrent().rows;
17898
17875
  return {
17899
17876
  async add(label) {
17900
17877
  const token = mintToken();
@@ -17951,6 +17928,33 @@ function createBrowserRegistry(opts) {
17951
17928
  }
17952
17929
  return true;
17953
17930
  },
17931
+ async repair() {
17932
+ const view = readCurrent();
17933
+ if (view.quarantined.length === 0) return { removed: [] };
17934
+ await writeLedger(opts.path, view.rows);
17935
+ return { removed: view.quarantined.map(handleOf) };
17936
+ },
17937
+ status() {
17938
+ const read = readLedger(opts.path);
17939
+ if (read.state === "unreadable") {
17940
+ return {
17941
+ state: "disabled",
17942
+ error: "browser_registry_unreadable",
17943
+ detail: read.reason
17944
+ };
17945
+ }
17946
+ if (read.state === "ok" && opts.forbiddenTokenHash && ledgerOverlapsToken(read.rows, opts.forbiddenTokenHash)) {
17947
+ return {
17948
+ state: "degraded",
17949
+ error: "browser_registry_root_authority_overlap",
17950
+ quarantinedHandles: read.rows.filter(
17951
+ (row) => timingSafeEqual(row.tokenHash, opts.forbiddenTokenHash)
17952
+ ).map(handleOf),
17953
+ recovery: "browsers repair"
17954
+ };
17955
+ }
17956
+ return { state: "ready" };
17957
+ },
17954
17958
  wasRevoked(tokenHash) {
17955
17959
  return revoked.has(tokenHash);
17956
17960
  }
@@ -17983,12 +17987,12 @@ function runDaemon(env, deps = {}) {
17983
17987
  const browserLabels = /* @__PURE__ */ new Map();
17984
17988
  const ledger = readLedger(ledgerPath(deps.home));
17985
17989
  const rootTokenHash = digestToken(token);
17986
- const ledgerAuthorityValid = ledger.state !== "ok" || !ledgerOverlapsToken(ledger.rows, rootTokenHash);
17987
- if (ledger.state === "ok" && ledgerAuthorityValid) {
17988
- for (const [id, hash2] of claimedDigestsFrom(ledger.rows)) {
17990
+ const safeLedgerRows = ledger.state === "ok" ? ledger.rows.filter((row) => row.tokenHash !== rootTokenHash) : [];
17991
+ if (ledger.state === "ok") {
17992
+ for (const [id, hash2] of claimedDigestsFrom(safeLedgerRows)) {
17989
17993
  claimedBrowsers.set(id, hash2);
17990
17994
  }
17991
- for (const row of ledger.rows) {
17995
+ for (const row of safeLedgerRows) {
17992
17996
  if (row.browserId !== null) browserLabels.set(row.browserId, row.label);
17993
17997
  }
17994
17998
  }
@@ -17997,26 +18001,21 @@ function runDaemon(env, deps = {}) {
17997
18001
  `browser registry is unreadable (${ledger.reason}); registering is off`
17998
18002
  );
17999
18003
  }
18000
- if (!ledgerAuthorityValid) {
18001
- log("browser registry overlaps the root authority; registering is off");
18004
+ if (ledger.state === "ok" && ledgerOverlapsToken(ledger.rows, rootTokenHash)) {
18005
+ log(
18006
+ "browser registry overlaps the root authority; conflicting rows are quarantined"
18007
+ );
18002
18008
  }
18003
18009
  const bridge = new Bridge({
18004
18010
  send: (frame, key) => http.send(frame, key),
18005
- // Step aside once our own token looks stale. This process is detached
18006
- // and survives an app restart, and a replacement only takes over on a newer
18007
- // version — so nothing outside can unblock the install if we do not.
18008
- onStaleToken: () => {
18009
- log("pairing token is stale; exiting so the next call can start fresh");
18010
- void http.close().finally(() => exit(0));
18011
- },
18012
18011
  token,
18013
18012
  extensionIds,
18014
18013
  serverVersion: version2,
18015
18014
  rateLimit: deps.rateLimit,
18016
- // Empty until a browser is registered, and nothing issues registrations
18017
- // yet — so every panel still shares the one slot it always had.
18015
+ // Empty until a browser is registered.
18018
18016
  claimedBrowsers,
18019
18017
  browserLabels,
18018
+ browserRegistryStatus: () => registry2.status(),
18020
18019
  claimBrowser: (browserId, tokenHash) => {
18021
18020
  const took = registry2.claim(browserId, tokenHash);
18022
18021
  if (took) {
@@ -4,7 +4,7 @@ import {
4
4
  mediaToolDescriptors,
5
5
  spawnDaemon,
6
6
  tryConnect
7
- } from "./chunk-U7AIWUSN.js";
7
+ } from "./chunk-DOPJ4OZ4.js";
8
8
  import {
9
9
  __export
10
10
  } from "./chunk-MLKGABMK.js";
@@ -14601,7 +14601,9 @@ var HelloNackReasonSchema = external_exports.enum([
14601
14601
  /** Origin header absent or not our extension. */
14602
14602
  "forbidden_origin",
14603
14603
  /** This browser already has an active event stream. */
14604
- "session_active"
14604
+ "session_active",
14605
+ /** Root authority cannot open a browser session. */
14606
+ "browser_registration_required"
14605
14607
  ]);
14606
14608
  var BrowserRoutingSchema = external_exports.object({
14607
14609
  targetBrowserId: external_exports.string(),
@@ -15586,6 +15588,21 @@ async function defaultShutdown(base, token) {
15586
15588
  return false;
15587
15589
  }
15588
15590
  }
15591
+ async function defaultCheckAuthority(base, token) {
15592
+ try {
15593
+ const res = await globalThis.fetch(
15594
+ `${base}/mcp/authority`,
15595
+ {
15596
+ method: "POST",
15597
+ headers: { "content-type": "application/json" },
15598
+ body: JSON.stringify({ token })
15599
+ }
15600
+ );
15601
+ return res.status === 204;
15602
+ } catch {
15603
+ return false;
15604
+ }
15605
+ }
15589
15606
  async function ensureDaemonRunning(deps = {}) {
15590
15607
  const host = deps.host ?? DEFAULT_HOST;
15591
15608
  const port = deps.port ?? DEFAULT_PORT;
@@ -15601,6 +15618,7 @@ async function ensureDaemonRunning(deps = {}) {
15601
15618
  const token = deps.token ?? process.env["DATALAB_MCP_TOKEN"];
15602
15619
  const readVersion = deps.readVersion ?? defaultReadVersion;
15603
15620
  const shutdown = deps.shutdown ?? defaultShutdown;
15621
+ const checkAuthority = deps.checkAuthority ?? defaultCheckAuthority;
15604
15622
  const spawnAndWait = async () => {
15605
15623
  spawn(daemonEntry, ["serve"]);
15606
15624
  for (let i = 0; i < attempts; i += 1) {
@@ -15623,14 +15641,26 @@ async function ensureDaemonRunning(deps = {}) {
15623
15641
  fast.destroy();
15624
15642
  if (!selfVersion || !token) return;
15625
15643
  const running = await readVersion(base);
15626
- if (!running || !isOlderVersion(running, selfVersion)) return;
15644
+ if (!running) {
15645
+ throw new Error(
15646
+ `port ${String(port)} is occupied by something other than the DataLab connector`
15647
+ );
15648
+ }
15649
+ if (!isOlderVersion(running, selfVersion)) {
15650
+ if (await checkAuthority(base, token)) return;
15651
+ throw new Error(
15652
+ `connector authority conflict on port ${String(port)}; every MCP host on this machine must use the same DataLab workbench token`
15653
+ );
15654
+ }
15627
15655
  log(`updating the connector: replacing ${running} with ${selfVersion}`);
15628
15656
  const accepted = await shutdown(base, token);
15629
15657
  if (!accepted) {
15630
15658
  log(
15631
15659
  "the running connector is too old to update automatically; restart it (or wait for it to idle out) to pick up the new version"
15632
15660
  );
15633
- return;
15661
+ throw new Error(
15662
+ `connector authority conflict on port ${String(port)}; the running connector did not accept this workbench token`
15663
+ );
15634
15664
  }
15635
15665
  for (let i = 0; i < attempts; i += 1) {
15636
15666
  const socket = await connect(host, port, PROBE_TIMEOUT_MS);
package/dist/cli.js CHANGED
@@ -2,10 +2,10 @@
2
2
  import {
3
3
  dispatchCli,
4
4
  runAdapter
5
- } from "./chunk-DR3Q6KT2.js";
5
+ } from "./chunk-YB7L4SZC.js";
6
6
  import {
7
7
  runDaemon
8
- } from "./chunk-U7AIWUSN.js";
8
+ } from "./chunk-DOPJ4OZ4.js";
9
9
  import "./chunk-MLKGABMK.js";
10
10
 
11
11
  // src/cli.ts
@@ -62,7 +62,7 @@ async function main() {
62
62
  // Loaded lazily, like the installer: the adapter path is what an MCP host
63
63
  // spawns and must not pay for a command only an operator types.
64
64
  browsers: async (argv) => {
65
- const { runBrowsers } = await import("./browsers-KBFQARNA.js");
65
+ const { runBrowsers } = await import("./browsers-AGEUPMJA.js");
66
66
  const { createNodeIo } = await import("./dist-JICXADYR.js");
67
67
  const io = createNodeIo();
68
68
  const code = await runBrowsers(argv, {
@@ -109,7 +109,7 @@ async function main() {
109
109
  unknown: (sub) => {
110
110
  log(`unknown command "${sub}".`);
111
111
  log(`usage: ${NAME} [install|uninstall|serve|browsers|skills]`);
112
- log(` browsers [add <label>|list|revoke <handle>] [--token <t>]`);
112
+ log(` browsers [add <label>|list|revoke <handle>|repair] [--token <t>]`);
113
113
  log(` skills [list|status|attach <slug>|detach <slug>]`);
114
114
  log(`run with no command to serve an MCP host over stdio.`);
115
115
  process.exit(2);
package/dist/index.d.ts CHANGED
@@ -62,6 +62,8 @@ interface EnsureRunningDeps {
62
62
  readVersion?: (base: string) => Promise<string | null>;
63
63
  /** Injected in tests; defaults to `POST /mcp/shutdown`. */
64
64
  shutdown?: (base: string, token: string) => Promise<boolean>;
65
+ /** Injected in tests; defaults to the authenticated authority probe. */
66
+ checkAuthority?: (base: string, token: string) => Promise<boolean>;
65
67
  }
66
68
  /**
67
69
  * Ensure a daemon is up before the adapter starts proxying. A daemon already
package/dist/index.js CHANGED
@@ -3,8 +3,8 @@ import {
3
3
  dispatchCli,
4
4
  ensureDaemonRunning,
5
5
  runAdapter
6
- } from "./chunk-DR3Q6KT2.js";
7
- import "./chunk-U7AIWUSN.js";
6
+ } from "./chunk-YB7L4SZC.js";
7
+ import "./chunk-DOPJ4OZ4.js";
8
8
  import "./chunk-MLKGABMK.js";
9
9
  export {
10
10
  createAdapterServer,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@modootoday/datalab-extension-mcp",
3
- "version": "1.8.8",
3
+ "version": "1.8.10",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",