@ractive-ch/tab-mcp 0.4.0 → 0.5.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.
- package/README.md +2 -2
- package/index.js +141 -19
- package/index.js.map +8 -8
- package/main.js +141 -19
- package/main.js.map +8 -8
- package/oauth/pairing.d.ts +2 -0
- package/package.json +1 -1
- package/protocol.d.ts +9 -1
- package/relay.d.ts +2 -0
- package/session.d.ts +6 -1
package/main.js
CHANGED
|
@@ -603,7 +603,7 @@ function installCrashLogging(logger, exit = (code) => process.exit(code)) {
|
|
|
603
603
|
};
|
|
604
604
|
}
|
|
605
605
|
// package.json
|
|
606
|
-
var version = "0.
|
|
606
|
+
var version = "0.5.0";
|
|
607
607
|
|
|
608
608
|
// src/address.ts
|
|
609
609
|
var BASE32_ALPHABET = "abcdefghijklmnopqrstuvwxyz234567";
|
|
@@ -1384,7 +1384,8 @@ var CloseCode = {
|
|
|
1384
1384
|
heartbeatLost: 4003,
|
|
1385
1385
|
shuttingDown: 4004,
|
|
1386
1386
|
replaced: 4005,
|
|
1387
|
-
rateLimited: 4008
|
|
1387
|
+
rateLimited: 4008,
|
|
1388
|
+
addressMismatch: 4009
|
|
1388
1389
|
};
|
|
1389
1390
|
var MAX_TITLE = 300;
|
|
1390
1391
|
var MAX_NAME = 200;
|
|
@@ -1446,6 +1447,10 @@ function parseTabMessage(text) {
|
|
|
1446
1447
|
if (app !== undefined && (!isObject(app) || !isAppName(app["name"]))) {
|
|
1447
1448
|
return bad(`hello.app.name must be 1-${MAX_APP_NAME} characters`);
|
|
1448
1449
|
}
|
|
1450
|
+
const revokeAll = v["revokeAll"];
|
|
1451
|
+
if (revokeAll !== undefined && typeof revokeAll !== "boolean") {
|
|
1452
|
+
return bad("hello.revokeAll must be a boolean");
|
|
1453
|
+
}
|
|
1449
1454
|
const hello = {
|
|
1450
1455
|
type,
|
|
1451
1456
|
library: { name: library["name"], version: library["version"] },
|
|
@@ -1455,6 +1460,8 @@ function parseTabMessage(text) {
|
|
|
1455
1460
|
if (isObject(app) && isAppName(app["name"])) {
|
|
1456
1461
|
hello.app = { name: app["name"] };
|
|
1457
1462
|
}
|
|
1463
|
+
if (revokeAll === true)
|
|
1464
|
+
hello.revokeAll = true;
|
|
1458
1465
|
return { ok: true, message: hello };
|
|
1459
1466
|
}
|
|
1460
1467
|
case "pong": {
|
|
@@ -2308,7 +2315,7 @@ class OidcPairing {
|
|
|
2308
2315
|
reason: "rate_limited"
|
|
2309
2316
|
};
|
|
2310
2317
|
}
|
|
2311
|
-
const state = random2(24)
|
|
2318
|
+
const state = `${context.addressId}.${random2(24)}`;
|
|
2312
2319
|
const nonce = random2(24);
|
|
2313
2320
|
const verifier = random2(32);
|
|
2314
2321
|
this.#flowOf.set(context.requestId, state);
|
|
@@ -3098,7 +3105,8 @@ ${intro}
|
|
|
3098
3105
|
const admit = () => this.#guessesPerAddressIp.take(`${pending.addressId}|${ip}`) && this.#guessesPerAddress.take(pending.addressId);
|
|
3099
3106
|
const result = await method.check(session, form, admit, {
|
|
3100
3107
|
requestId: pending.id,
|
|
3101
|
-
browserId: browser.id
|
|
3108
|
+
browserId: browser.id,
|
|
3109
|
+
addressId: pending.addressId
|
|
3102
3110
|
});
|
|
3103
3111
|
if (result.ok === "redirect") {
|
|
3104
3112
|
this.#host.logger.info("oauth.pairing_redirect", {
|
|
@@ -3127,7 +3135,7 @@ ${intro}
|
|
|
3127
3135
|
}
|
|
3128
3136
|
#paired(pending, kind, result) {
|
|
3129
3137
|
this.#dropPending(pending);
|
|
3130
|
-
const code = random3(32)
|
|
3138
|
+
const code = `${pending.addressId}.${random3(32)}`;
|
|
3131
3139
|
if (this.#codes.size >= MAX_CODES)
|
|
3132
3140
|
this.#sweepCodes(true);
|
|
3133
3141
|
const { id: _id, expiresAt: _expires, ip: _ip, ...request } = pending;
|
|
@@ -3247,7 +3255,7 @@ ${intro}
|
|
|
3247
3255
|
${errorHtml}
|
|
3248
3256
|
<p>${clientLabel} wants to use the tools of a web page, but that page is not open right now.</p>
|
|
3249
3257
|
<p>Open the page, click \u201CConnect your AI app\u201D, then try again here.</p>
|
|
3250
|
-
<form method="post" action="/authorize">
|
|
3258
|
+
<form method="post" action="/authorize?a=${pending.addressId}">
|
|
3251
3259
|
${hidden}
|
|
3252
3260
|
<div class="actions">
|
|
3253
3261
|
<button type="submit" class="primary" name="action" value="retry">Try again</button>
|
|
@@ -3268,7 +3276,7 @@ ${cancel}
|
|
|
3268
3276
|
<p>You're connecting ${clientLabel} to ${target}.</p>
|
|
3269
3277
|
<p>${escapeHtml(client.name)} can then use this page's tools while the page is open. You can disconnect it in the page at any time.</p>
|
|
3270
3278
|
${errorHtml}
|
|
3271
|
-
<form method="post" action="/authorize">
|
|
3279
|
+
<form method="post" action="/authorize?a=${pending.addressId}">
|
|
3272
3280
|
${hidden}
|
|
3273
3281
|
${method.render(session)}
|
|
3274
3282
|
<div class="actions">
|
|
@@ -3610,6 +3618,7 @@ class TabSession {
|
|
|
3610
3618
|
origin;
|
|
3611
3619
|
site;
|
|
3612
3620
|
openedAt = Date.now();
|
|
3621
|
+
expectedAddress;
|
|
3613
3622
|
addressId;
|
|
3614
3623
|
appName;
|
|
3615
3624
|
codes;
|
|
@@ -3628,12 +3637,13 @@ class TabSession {
|
|
|
3628
3637
|
#helloTimer;
|
|
3629
3638
|
#heartbeatTimer;
|
|
3630
3639
|
#lifetimeTimer;
|
|
3631
|
-
constructor(ws, ip, origin, site, host) {
|
|
3640
|
+
constructor(ws, ip, origin, site, host, expectedAddress) {
|
|
3632
3641
|
this.#ws = ws;
|
|
3633
3642
|
this.ip = ip;
|
|
3634
3643
|
this.origin = origin;
|
|
3635
3644
|
this.site = site;
|
|
3636
3645
|
this.#host = host;
|
|
3646
|
+
this.expectedAddress = expectedAddress;
|
|
3637
3647
|
this.codes = new PairingCodes((code, expiresAt) => {
|
|
3638
3648
|
this.send({
|
|
3639
3649
|
type: "pairing.code",
|
|
@@ -3722,7 +3732,7 @@ class TabSession {
|
|
|
3722
3732
|
this.#protocolError("bad_message", "hello expected");
|
|
3723
3733
|
return;
|
|
3724
3734
|
}
|
|
3725
|
-
await this.#hello(msg.secret, msg.library, msg.app?.name);
|
|
3735
|
+
await this.#hello(msg.secret, msg.library, msg.app?.name, msg.revokeAll === true);
|
|
3726
3736
|
return;
|
|
3727
3737
|
}
|
|
3728
3738
|
if (this.#state === "hello")
|
|
@@ -3789,19 +3799,38 @@ class TabSession {
|
|
|
3789
3799
|
}
|
|
3790
3800
|
}
|
|
3791
3801
|
}
|
|
3792
|
-
async#hello(secret, library, appName) {
|
|
3802
|
+
async#hello(secret, library, appName, revokeAll = false) {
|
|
3793
3803
|
if (!isWellFormedSecret(secret)) {
|
|
3794
3804
|
this.#protocolError("bad_message", "secret must be 43 base64url characters");
|
|
3795
3805
|
return;
|
|
3796
3806
|
}
|
|
3807
|
+
const addressId = addressIdFromSecret(secret);
|
|
3808
|
+
if (this.expectedAddress !== undefined && this.expectedAddress !== addressId) {
|
|
3809
|
+
this.#host.logger.warn("tab.refused", { reason: "address_mismatch" });
|
|
3810
|
+
this.#sendError("address_mismatch", "the address id in the URL is not the one of the secret");
|
|
3811
|
+
this.end(CloseCode.addressMismatch, "address mismatch");
|
|
3812
|
+
return;
|
|
3813
|
+
}
|
|
3797
3814
|
clearTimeout(this.#helloTimer);
|
|
3798
3815
|
this.#state = "hello";
|
|
3799
|
-
this.addressId =
|
|
3816
|
+
this.addressId = addressId;
|
|
3800
3817
|
this.appName = this.site.name ?? appName;
|
|
3801
3818
|
const replaced = this.#host.claimAddress(this);
|
|
3802
3819
|
if (replaced !== undefined) {
|
|
3803
3820
|
replaced.end(CloseCode.replaced, "replaced by a newer session", "replaced");
|
|
3804
3821
|
}
|
|
3822
|
+
if (revokeAll) {
|
|
3823
|
+
try {
|
|
3824
|
+
await this.#host.revokeAll(this);
|
|
3825
|
+
} catch (error) {
|
|
3826
|
+
this.#host.logger.error("store.error", {
|
|
3827
|
+
op: "revokeAll",
|
|
3828
|
+
error: errorName(error)
|
|
3829
|
+
});
|
|
3830
|
+
this.end(CloseCode.policyViolation, "could not revoke the grants");
|
|
3831
|
+
return;
|
|
3832
|
+
}
|
|
3833
|
+
}
|
|
3805
3834
|
let grants = [];
|
|
3806
3835
|
try {
|
|
3807
3836
|
const list = await this.#host.store.listGrants(this.addressId, Date.now());
|
|
@@ -4235,6 +4264,33 @@ var FORWARDED_HEADERS = new Set([
|
|
|
4235
4264
|
"traceparent",
|
|
4236
4265
|
"tracestate"
|
|
4237
4266
|
]);
|
|
4267
|
+
var HEALTH_STORE_TIMEOUT_MS = 1000;
|
|
4268
|
+
var HEALTH_REUSE_MS = 1000;
|
|
4269
|
+
var HEALTH_HUNG_RETRY_MS = 30000;
|
|
4270
|
+
var HEALTH_PROBE_HASH = hashToken("tab-mcp /healthz probe");
|
|
4271
|
+
function rawAddressParam(search) {
|
|
4272
|
+
let found;
|
|
4273
|
+
for (const pair of search.replace(/^\?/, "").split("&")) {
|
|
4274
|
+
if (pair === "")
|
|
4275
|
+
continue;
|
|
4276
|
+
const eq = pair.indexOf("=");
|
|
4277
|
+
const rawName = eq < 0 ? pair : pair.slice(0, eq);
|
|
4278
|
+
let name;
|
|
4279
|
+
try {
|
|
4280
|
+
name = decodeURIComponent(rawName.replaceAll("+", " "));
|
|
4281
|
+
} catch {
|
|
4282
|
+
continue;
|
|
4283
|
+
}
|
|
4284
|
+
if (name !== "a")
|
|
4285
|
+
continue;
|
|
4286
|
+
const value = eq < 0 ? "" : pair.slice(eq + 1);
|
|
4287
|
+
if (rawName !== "a" || found !== undefined || !isAddressId(value)) {
|
|
4288
|
+
return false;
|
|
4289
|
+
}
|
|
4290
|
+
found = value;
|
|
4291
|
+
}
|
|
4292
|
+
return found;
|
|
4293
|
+
}
|
|
4238
4294
|
function json(status, value, headers) {
|
|
4239
4295
|
const h = new Headers(headers);
|
|
4240
4296
|
h.set("content-type", "application/json");
|
|
@@ -4590,6 +4646,11 @@ function startRelay(options, hooks) {
|
|
|
4590
4646
|
supported: [SUBPROTOCOL]
|
|
4591
4647
|
});
|
|
4592
4648
|
}
|
|
4649
|
+
const address = rawAddressParam(new URL(req.url).search);
|
|
4650
|
+
if (address === false) {
|
|
4651
|
+
logger.info("tab.refused", { reason: "bad_address" });
|
|
4652
|
+
return json(400, { error: "bad_address" });
|
|
4653
|
+
}
|
|
4593
4654
|
const realIp = clientIp(req, server);
|
|
4594
4655
|
const ip = ipPrefixKey(realIp);
|
|
4595
4656
|
if (allSessions.size >= config.maxSessions) {
|
|
@@ -4606,7 +4667,12 @@ function startRelay(options, hooks) {
|
|
|
4606
4667
|
}
|
|
4607
4668
|
perIp.set(ip, count + 1);
|
|
4608
4669
|
const ok = server.upgrade(req, {
|
|
4609
|
-
data: {
|
|
4670
|
+
data: {
|
|
4671
|
+
ip,
|
|
4672
|
+
origin: origin ?? "",
|
|
4673
|
+
site,
|
|
4674
|
+
...address === undefined ? {} : { address }
|
|
4675
|
+
},
|
|
4610
4676
|
headers: { "Sec-WebSocket-Protocol": SUBPROTOCOL }
|
|
4611
4677
|
});
|
|
4612
4678
|
if (ok)
|
|
@@ -4898,6 +4964,56 @@ function startRelay(options, hooks) {
|
|
|
4898
4964
|
headers: { ...corsHeaders, allow: "POST" }
|
|
4899
4965
|
});
|
|
4900
4966
|
const postOnly = () => new Response(null, { status: 405, headers: { allow: "POST, OPTIONS" } });
|
|
4967
|
+
const healthRetryMs = hooks?.healthRetryMs ?? HEALTH_HUNG_RETRY_MS;
|
|
4968
|
+
let probe;
|
|
4969
|
+
let probed;
|
|
4970
|
+
let hungReads = 0;
|
|
4971
|
+
let lastHungAt = 0;
|
|
4972
|
+
async function runProbe() {
|
|
4973
|
+
let timer;
|
|
4974
|
+
const deadline = new Promise((resolve) => {
|
|
4975
|
+
timer = setTimeout(() => resolve("store_timeout"), HEALTH_STORE_TIMEOUT_MS);
|
|
4976
|
+
});
|
|
4977
|
+
const read = Promise.resolve().then(() => store.get(HEALTH_PROBE_HASH)).then(() => "ok");
|
|
4978
|
+
read.catch(() => {});
|
|
4979
|
+
try {
|
|
4980
|
+
const result = await Promise.race([read, deadline]);
|
|
4981
|
+
if (result === "store_timeout") {
|
|
4982
|
+
logger.warn("health.store", { error: "timeout" });
|
|
4983
|
+
hungReads++;
|
|
4984
|
+
lastHungAt = Date.now();
|
|
4985
|
+
const settled = () => {
|
|
4986
|
+
hungReads--;
|
|
4987
|
+
};
|
|
4988
|
+
read.then(settled, settled);
|
|
4989
|
+
}
|
|
4990
|
+
return result;
|
|
4991
|
+
} catch (error) {
|
|
4992
|
+
logger.warn("health.store", { error: errorName(error) });
|
|
4993
|
+
return "store_unavailable";
|
|
4994
|
+
} finally {
|
|
4995
|
+
clearTimeout(timer);
|
|
4996
|
+
}
|
|
4997
|
+
}
|
|
4998
|
+
function storeHealth() {
|
|
4999
|
+
if (probe !== undefined)
|
|
5000
|
+
return probe;
|
|
5001
|
+
const now = Date.now();
|
|
5002
|
+
if (probed !== undefined && now - probed.at < HEALTH_REUSE_MS) {
|
|
5003
|
+
return Promise.resolve(probed.result);
|
|
5004
|
+
}
|
|
5005
|
+
if (hungReads > 0 && now - lastHungAt < healthRetryMs) {
|
|
5006
|
+
return Promise.resolve("store_timeout");
|
|
5007
|
+
}
|
|
5008
|
+
const current = runProbe().catch(() => "store_unavailable");
|
|
5009
|
+
probe = current;
|
|
5010
|
+
current.then((result) => {
|
|
5011
|
+
probed = { at: Date.now(), result };
|
|
5012
|
+
if (probe === current)
|
|
5013
|
+
probe = undefined;
|
|
5014
|
+
});
|
|
5015
|
+
return current;
|
|
5016
|
+
}
|
|
4901
5017
|
const largest = largestMessageBytes(config);
|
|
4902
5018
|
const ipOf = (req, srv) => ipPrefixKey(clientIp(req, srv));
|
|
4903
5019
|
const server = Bun.serve({
|
|
@@ -4908,11 +5024,17 @@ function startRelay(options, hooks) {
|
|
|
4908
5024
|
idleTimeout: config.httpIdleTimeoutS,
|
|
4909
5025
|
routes: {
|
|
4910
5026
|
"/healthz": {
|
|
4911
|
-
GET: () =>
|
|
4912
|
-
|
|
4913
|
-
|
|
4914
|
-
|
|
4915
|
-
|
|
5027
|
+
GET: async () => {
|
|
5028
|
+
const store = await storeHealth();
|
|
5029
|
+
if (store !== "ok") {
|
|
5030
|
+
return json(503, { status: "unhealthy", reason: store, version: VERSION }, { "cache-control": "no-store" });
|
|
5031
|
+
}
|
|
5032
|
+
return json(200, {
|
|
5033
|
+
status: "ok",
|
|
5034
|
+
version: VERSION,
|
|
5035
|
+
counters: { claudeHeaderWorkaround: claudeWorkarounds }
|
|
5036
|
+
}, { "cache-control": "no-store" });
|
|
5037
|
+
}
|
|
4916
5038
|
},
|
|
4917
5039
|
"/": {
|
|
4918
5040
|
GET: () => new Response(INDEX_HTML, {
|
|
@@ -4975,7 +5097,7 @@ function startRelay(options, hooks) {
|
|
|
4975
5097
|
sendPings: true,
|
|
4976
5098
|
perMessageDeflate: false,
|
|
4977
5099
|
open(ws) {
|
|
4978
|
-
const session = new TabSession(ws, ws.data.ip, ws.data.origin, ws.data.site, host);
|
|
5100
|
+
const session = new TabSession(ws, ws.data.ip, ws.data.origin, ws.data.site, host, ws.data.address);
|
|
4979
5101
|
ws.data.session = session;
|
|
4980
5102
|
allSessions.add(session);
|
|
4981
5103
|
},
|
|
@@ -5140,5 +5262,5 @@ var shutdown = () => {
|
|
|
5140
5262
|
process.on("SIGTERM", shutdown);
|
|
5141
5263
|
process.on("SIGINT", shutdown);
|
|
5142
5264
|
|
|
5143
|
-
//# debugId=
|
|
5265
|
+
//# debugId=ED4BD668E1904D7C64756E2164756E21
|
|
5144
5266
|
//# sourceMappingURL=main.js.map
|