@chrrxs/robloxstudio-mcp 3.1.1 → 3.1.3
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/index.js +1370 -347
- package/package.json +1 -1
- package/studio-plugin/MCPPlugin.rbxmx +667 -485
package/dist/index.js
CHANGED
|
@@ -1939,10 +1939,82 @@ import { serveStdio } from "@modelcontextprotocol/server/stdio";
|
|
|
1939
1939
|
// ../core/dist/http-server.js
|
|
1940
1940
|
import express from "express";
|
|
1941
1941
|
import http from "http";
|
|
1942
|
+
import { randomBytes as randomBytes2 } from "crypto";
|
|
1943
|
+
import { WebSocketServer } from "ws";
|
|
1942
1944
|
import { toNodeHandler } from "@modelcontextprotocol/node";
|
|
1943
1945
|
|
|
1944
1946
|
// ../core/dist/bridge-service.js
|
|
1945
|
-
import { randomUUID } from "crypto";
|
|
1947
|
+
import { createHash, randomUUID } from "crypto";
|
|
1948
|
+
function isExecutionOutcome(value) {
|
|
1949
|
+
return value === "success" || value === "error" || value === "not_executed" || value === "unknown";
|
|
1950
|
+
}
|
|
1951
|
+
function isRequestStage(value) {
|
|
1952
|
+
return value === "queued" || value === "dispatched" || value === "executing" || value === "response_delivery";
|
|
1953
|
+
}
|
|
1954
|
+
function handlerOutcome(response) {
|
|
1955
|
+
if (!response || typeof response !== "object")
|
|
1956
|
+
return "success";
|
|
1957
|
+
if ("error" in response && response.error !== void 0 && response.error !== null || "success" in response && response.success === false || "ok" in response && response.ok === false)
|
|
1958
|
+
return "error";
|
|
1959
|
+
if ("summary" in response && response.summary && typeof response.summary === "object" && "failed" in response.summary && typeof response.summary.failed === "number" && response.summary.failed > 0)
|
|
1960
|
+
return "error";
|
|
1961
|
+
return "success";
|
|
1962
|
+
}
|
|
1963
|
+
function observations(status) {
|
|
1964
|
+
return {
|
|
1965
|
+
executionStartedAt: status.executionStartedAt,
|
|
1966
|
+
executionCompletedAt: status.executionCompletedAt,
|
|
1967
|
+
executionOutcome: status.executionOutcome,
|
|
1968
|
+
connectionLostAt: status.connectionLostAt,
|
|
1969
|
+
connectionRestoredAt: status.connectionRestoredAt
|
|
1970
|
+
};
|
|
1971
|
+
}
|
|
1972
|
+
var RequestFailure = class extends Error {
|
|
1973
|
+
code;
|
|
1974
|
+
details;
|
|
1975
|
+
constructor(message, code, details) {
|
|
1976
|
+
super(message);
|
|
1977
|
+
this.code = code;
|
|
1978
|
+
this.details = details;
|
|
1979
|
+
this.name = "RequestFailure";
|
|
1980
|
+
}
|
|
1981
|
+
};
|
|
1982
|
+
function parseObservations(value) {
|
|
1983
|
+
const result = {};
|
|
1984
|
+
if ("executionOutcome" in value) {
|
|
1985
|
+
if (!isExecutionOutcome(value.executionOutcome))
|
|
1986
|
+
throw new Error("Invalid execution outcome");
|
|
1987
|
+
result.executionOutcome = value.executionOutcome;
|
|
1988
|
+
}
|
|
1989
|
+
for (const key of ["executionStartedAt", "executionCompletedAt", "connectionLostAt", "connectionRestoredAt"]) {
|
|
1990
|
+
if (!(key in value))
|
|
1991
|
+
continue;
|
|
1992
|
+
const timestamp = Reflect.get(value, key);
|
|
1993
|
+
if (typeof timestamp !== "number" || !Number.isFinite(timestamp))
|
|
1994
|
+
throw new Error("Invalid observation timestamp");
|
|
1995
|
+
result[key] = timestamp;
|
|
1996
|
+
}
|
|
1997
|
+
return result;
|
|
1998
|
+
}
|
|
1999
|
+
function parseFailureDetails(value, identity) {
|
|
2000
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
2001
|
+
return void 0;
|
|
2002
|
+
const isHttpRejection = "transportStage" in value && value.transportStage === "http_receive";
|
|
2003
|
+
const requestId = "requestId" in value ? value.requestId : isHttpRejection ? identity.requestId : void 0;
|
|
2004
|
+
const targetPeerId = "targetPeerId" in value ? value.targetPeerId : isHttpRejection ? identity.targetPeerId : void 0;
|
|
2005
|
+
if (typeof requestId !== "string" || typeof targetPeerId !== "string" || requestId !== identity.requestId || targetPeerId !== identity.targetPeerId || !("stage" in value) || !isRequestStage(value.stage) || !("outcome" in value) || value.outcome !== "not_executed" && value.outcome !== "unknown")
|
|
2006
|
+
return void 0;
|
|
2007
|
+
return {
|
|
2008
|
+
requestId,
|
|
2009
|
+
targetPeerId,
|
|
2010
|
+
stage: value.stage,
|
|
2011
|
+
outcome: value.outcome,
|
|
2012
|
+
...parseObservations(value),
|
|
2013
|
+
..."bytes" in value && typeof value.bytes === "number" ? { bytes: value.bytes } : {},
|
|
2014
|
+
..."limitBytes" in value && typeof value.limitBytes === "number" ? { limitBytes: value.limitBytes } : {},
|
|
2015
|
+
..."transportStage" in value && (value.transportStage === "server_send" || value.transportStage === "proxy_send" || value.transportStage === "http_receive") ? { transportStage: value.transportStage } : {}
|
|
2016
|
+
};
|
|
2017
|
+
}
|
|
1946
2018
|
var RoutingFailure = class extends Error {
|
|
1947
2019
|
routingError;
|
|
1948
2020
|
constructor(routingError) {
|
|
@@ -1970,11 +2042,16 @@ function toPublicPeer(peer) {
|
|
|
1970
2042
|
};
|
|
1971
2043
|
}
|
|
1972
2044
|
var STALE_PEER_MS = 3e4;
|
|
1973
|
-
var
|
|
1974
|
-
var
|
|
2045
|
+
var OPERATION_RETENTION_MS = 5 * 6e4;
|
|
2046
|
+
var MAX_OPERATION_RECORDS = 32768;
|
|
2047
|
+
var MAX_RETAINED_RESULTS = 1024;
|
|
2048
|
+
var MAX_RETAINED_RESULT_BYTES = 64 * 1024 * 1024;
|
|
2049
|
+
var MAX_REQUEST_BYTES = 64 * 1024 * 1024;
|
|
2050
|
+
var MAX_PENDING_REQUESTS = 1024;
|
|
2051
|
+
var MAX_PENDING_REQUEST_BYTES = 64 * 1024 * 1024;
|
|
1975
2052
|
var CANCELLATION_TOMBSTONE_TTL_MS = 6e4;
|
|
1976
2053
|
var MAX_CANCELLATION_TOMBSTONES = 4096;
|
|
1977
|
-
var
|
|
2054
|
+
var MAX_OUTSTANDING_REQUESTS_PER_TRANSPORT = 4;
|
|
1978
2055
|
function roleOrder(role) {
|
|
1979
2056
|
if (role === "edit")
|
|
1980
2057
|
return 0;
|
|
@@ -2005,7 +2082,10 @@ function copyGroup(group) {
|
|
|
2005
2082
|
}
|
|
2006
2083
|
var BridgeService = class {
|
|
2007
2084
|
pendingRequests = /* @__PURE__ */ new Map();
|
|
2008
|
-
|
|
2085
|
+
operations = /* @__PURE__ */ new Map();
|
|
2086
|
+
retainedResults = /* @__PURE__ */ new Map();
|
|
2087
|
+
retainedResultBytes = 0;
|
|
2088
|
+
pendingRequestBytes = 0;
|
|
2009
2089
|
pendingCancellations = /* @__PURE__ */ new Map();
|
|
2010
2090
|
peersById = /* @__PURE__ */ new Map();
|
|
2011
2091
|
multiplayerGroupsById = /* @__PURE__ */ new Map();
|
|
@@ -2048,13 +2128,25 @@ var BridgeService = class {
|
|
|
2048
2128
|
this.deliveryOwnersByTransportPeer.set(transportPeerId, owners);
|
|
2049
2129
|
}
|
|
2050
2130
|
owners.add(owner);
|
|
2131
|
+
for (const operation of this.operations.values()) {
|
|
2132
|
+
if (operation.transportPeerId === transportPeerId && operation.status.state !== "settled" && operation.status.connectionLostAt !== void 0 && operation.status.connectionRestoredAt === void 0) {
|
|
2133
|
+
operation.status.connectionRestoredAt = Date.now();
|
|
2134
|
+
}
|
|
2135
|
+
}
|
|
2051
2136
|
return;
|
|
2052
2137
|
}
|
|
2053
2138
|
if (!owners)
|
|
2054
2139
|
return;
|
|
2055
2140
|
owners.delete(owner);
|
|
2056
|
-
if (owners.size === 0)
|
|
2141
|
+
if (owners.size === 0) {
|
|
2057
2142
|
this.deliveryOwnersByTransportPeer.delete(transportPeerId);
|
|
2143
|
+
for (const operation of this.operations.values()) {
|
|
2144
|
+
if (operation.transportPeerId !== transportPeerId || operation.status.state === "settled")
|
|
2145
|
+
continue;
|
|
2146
|
+
operation.status.connectionLostAt = Date.now();
|
|
2147
|
+
delete operation.status.connectionRestoredAt;
|
|
2148
|
+
}
|
|
2149
|
+
}
|
|
2058
2150
|
}
|
|
2059
2151
|
notifyRequestAvailable(transportPeerId) {
|
|
2060
2152
|
for (const listener of this.requestAvailableListeners) {
|
|
@@ -2285,8 +2377,7 @@ var BridgeService = class {
|
|
|
2285
2377
|
if (request.targetPeerId !== peerId)
|
|
2286
2378
|
continue;
|
|
2287
2379
|
const deliveryTransportPeerId = request.lastDeliveryTransportPeerId;
|
|
2288
|
-
this.
|
|
2289
|
-
request.reject(new Error(`Target Peer "${peerId}" disconnected`));
|
|
2380
|
+
this.endRequestWaiter(request, "disconnected", `Target Peer "${peerId}" disconnected`);
|
|
2290
2381
|
if (deliveryTransportPeerId)
|
|
2291
2382
|
this.notifyRequestAvailable(deliveryTransportPeerId);
|
|
2292
2383
|
}
|
|
@@ -2392,6 +2483,12 @@ var BridgeService = class {
|
|
|
2392
2483
|
multiplayerGroups: this.getMultiplayerGroups()
|
|
2393
2484
|
};
|
|
2394
2485
|
}
|
|
2486
|
+
/** Local topology is authoritative; remote adapters must refresh before fanout. */
|
|
2487
|
+
refreshTopologyForRouting(signal) {
|
|
2488
|
+
if (signal?.aborted)
|
|
2489
|
+
throw new Error("Request aborted before topology resolution");
|
|
2490
|
+
return void 0;
|
|
2491
|
+
}
|
|
2395
2492
|
resolveConnectedInstanceId(instanceId) {
|
|
2396
2493
|
const exact = this.getInstances().find((instance) => instance.id === instanceId);
|
|
2397
2494
|
const groupedRuntime = this.getPeers().find((peer) => peer.multiplayerGroupId !== void 0 && isRuntimeRole(peer.role) && connectedRuntimeInstanceId(peer) === instanceId);
|
|
@@ -2545,44 +2642,126 @@ var BridgeService = class {
|
|
|
2545
2642
|
const peers = this.getPeers().filter((peer) => this.peerScopeKey(peer) === onlyScope);
|
|
2546
2643
|
return this.resolveWithinScope(peers, input.target, errorData);
|
|
2547
2644
|
}
|
|
2548
|
-
sendRequest(endpoint, data, targetPeerId, timeoutMs = this.requestTimeout, signal) {
|
|
2549
|
-
const requestId = randomUUID();
|
|
2645
|
+
sendRequest(endpoint, data, targetPeerId, timeoutMs = this.requestTimeout, signal, operationId) {
|
|
2646
|
+
const requestId = operationId ?? randomUUID();
|
|
2550
2647
|
const effectiveTimeoutMs = Math.max(1, timeoutMs);
|
|
2551
|
-
|
|
2552
|
-
|
|
2648
|
+
const details = { requestId, targetPeerId, stage: "queued", outcome: "not_executed", executionOutcome: "not_executed" };
|
|
2649
|
+
if (typeof requestId !== "string" || requestId.trim().length === 0 || requestId.length > 128) {
|
|
2650
|
+
return Promise.reject(new RequestFailure("operationId must be a nonempty string of at most 128 characters", "invalid_operation_id", details));
|
|
2651
|
+
}
|
|
2652
|
+
if (signal?.aborted) {
|
|
2653
|
+
return Promise.reject(new RequestFailure(`Request aborted: ${requestId}; queued; not_executed`, "request_aborted", details));
|
|
2654
|
+
}
|
|
2655
|
+
let requestBytes;
|
|
2656
|
+
let fingerprint;
|
|
2657
|
+
try {
|
|
2658
|
+
const target2 = this.getPeerById(targetPeerId);
|
|
2659
|
+
fingerprint = createHash("sha256").update(JSON.stringify({ targetPeerId, endpoint, data })).digest("hex");
|
|
2660
|
+
requestBytes = Buffer.byteLength(JSON.stringify({
|
|
2661
|
+
kind: "request",
|
|
2662
|
+
requestId,
|
|
2663
|
+
peerId: targetPeerId,
|
|
2664
|
+
target: target2?.role,
|
|
2665
|
+
endpoint,
|
|
2666
|
+
data: data ?? null,
|
|
2667
|
+
remainingMs: effectiveTimeoutMs
|
|
2668
|
+
}));
|
|
2669
|
+
} catch {
|
|
2670
|
+
return Promise.reject(new RequestFailure(`Request ${requestId} cannot be serialized; queued; not_executed`, "request_serialization_failed", details));
|
|
2671
|
+
}
|
|
2672
|
+
this.pruneOperations(Date.now());
|
|
2673
|
+
const existing = this.operations.get(requestId);
|
|
2674
|
+
if (existing) {
|
|
2675
|
+
if (existing.fingerprint !== fingerprint) {
|
|
2676
|
+
return Promise.reject(new RequestFailure(`Request ${requestId} already identifies a different operation; existing operation ${existing.status.stage}; not replayed`, "operation_id_collision", { requestId, targetPeerId: existing.status.targetPeerId, stage: existing.status.stage, outcome: "unknown", ...observations(existing.status) }));
|
|
2677
|
+
}
|
|
2678
|
+
const pending = this.pendingRequests.get(requestId);
|
|
2679
|
+
if (pending)
|
|
2680
|
+
return pending.promise;
|
|
2681
|
+
const status = this.getRequestStatus(requestId);
|
|
2682
|
+
if (status.state === "settled" && !status.resultUnavailable) {
|
|
2683
|
+
const error = status.error;
|
|
2684
|
+
if (error && typeof error === "object" && "name" in error && error.name === "RequestFailure" && "code" in error && typeof error.code === "string" && "message" in error && typeof error.message === "string" && "details" in error) {
|
|
2685
|
+
const failureDetails = parseFailureDetails(error.details, { requestId, targetPeerId });
|
|
2686
|
+
if (failureDetails)
|
|
2687
|
+
return Promise.reject(new RequestFailure(error.message, error.code, failureDetails));
|
|
2688
|
+
}
|
|
2689
|
+
return Object.hasOwn(status, "error") ? Promise.reject(status.error) : Promise.resolve(status.response);
|
|
2690
|
+
}
|
|
2691
|
+
return Promise.reject(new RequestFailure(`Request ${requestId} already exists: ${status.state}; ${status.stage}; ${status.outcome}; use get_request_status; not replayed`, "operation_not_replayed", { requestId, targetPeerId, stage: status.stage, outcome: status.executionOutcome === "not_executed" ? "not_executed" : "unknown", ...observations(status) }));
|
|
2692
|
+
}
|
|
2693
|
+
if (requestBytes > MAX_REQUEST_BYTES) {
|
|
2694
|
+
return Promise.reject(new RequestFailure(`Request ${requestId} is ${requestBytes} bytes at server_send; limit ${MAX_REQUEST_BYTES} bytes; queued; not_executed`, "request_too_large", { ...details, bytes: requestBytes, limitBytes: MAX_REQUEST_BYTES, transportStage: "server_send" }));
|
|
2695
|
+
}
|
|
2696
|
+
if (this.pendingRequests.size >= MAX_PENDING_REQUESTS || this.pendingRequestBytes + requestBytes > MAX_PENDING_REQUEST_BYTES) {
|
|
2697
|
+
return Promise.reject(new RequestFailure(`Request ${requestId} rejected at admission: pending capacity exceeded (${this.pendingRequests.size}/${MAX_PENDING_REQUESTS} requests, ${this.pendingRequestBytes + requestBytes}/${MAX_PENDING_REQUEST_BYTES} bytes); queued; not_executed`, "request_capacity_exceeded", { ...details, bytes: this.pendingRequestBytes + requestBytes, limitBytes: MAX_PENDING_REQUEST_BYTES }));
|
|
2698
|
+
}
|
|
2553
2699
|
const { promise, resolve: resolve5, reject } = Promise.withResolvers();
|
|
2554
|
-
const
|
|
2700
|
+
const abortListener = () => {
|
|
2555
2701
|
const pending = this.pendingRequests.get(requestId);
|
|
2556
|
-
if (
|
|
2557
|
-
|
|
2558
|
-
this.notifyRequestCancelled(pending, reason);
|
|
2559
|
-
pending.reject(error);
|
|
2702
|
+
if (pending)
|
|
2703
|
+
this.endRequestWaiter(pending, "aborted", "Request aborted");
|
|
2560
2704
|
};
|
|
2561
|
-
const timeoutId = setTimeout(() =>
|
|
2562
|
-
|
|
2705
|
+
const timeoutId = setTimeout(() => {
|
|
2706
|
+
const pending = this.pendingRequests.get(requestId);
|
|
2707
|
+
if (pending)
|
|
2708
|
+
this.endRequestWaiter(pending, "timed_out", "Request timeout");
|
|
2709
|
+
}, effectiveTimeoutMs);
|
|
2710
|
+
const now = Date.now();
|
|
2563
2711
|
const request = {
|
|
2564
2712
|
id: requestId,
|
|
2565
2713
|
endpoint,
|
|
2566
2714
|
data,
|
|
2567
2715
|
targetPeerId,
|
|
2568
|
-
timestamp:
|
|
2716
|
+
timestamp: now,
|
|
2569
2717
|
resolve: resolve5,
|
|
2570
2718
|
reject,
|
|
2719
|
+
promise,
|
|
2571
2720
|
timeoutId,
|
|
2572
2721
|
timeoutMs: effectiveTimeoutMs,
|
|
2722
|
+
requestBytes,
|
|
2573
2723
|
abortSignal: signal,
|
|
2574
2724
|
abortListener
|
|
2575
2725
|
};
|
|
2576
2726
|
this.pendingRequests.set(requestId, request);
|
|
2727
|
+
this.pendingRequestBytes += requestBytes;
|
|
2728
|
+
this.operations.set(requestId, {
|
|
2729
|
+
status: { requestId, targetPeerId, queuedAt: now, stage: "queued", state: "pending", outcome: "pending", executionOutcome: "unknown" },
|
|
2730
|
+
fingerprint,
|
|
2731
|
+
updatedAt: now,
|
|
2732
|
+
resultBytes: 0
|
|
2733
|
+
});
|
|
2734
|
+
this.pruneOperations(now);
|
|
2577
2735
|
signal?.addEventListener("abort", abortListener, { once: true });
|
|
2578
2736
|
if (signal?.aborted)
|
|
2579
2737
|
abortListener();
|
|
2580
2738
|
const target = this.getPeerById(targetPeerId);
|
|
2581
|
-
if (this.pendingRequests.has(requestId) && target)
|
|
2739
|
+
if (this.pendingRequests.has(requestId) && target)
|
|
2582
2740
|
this.notifyRequestAvailable(target.transportPeerId);
|
|
2583
|
-
}
|
|
2584
2741
|
return promise;
|
|
2585
2742
|
}
|
|
2743
|
+
endRequestWaiter(request, state, message) {
|
|
2744
|
+
if (!this.removePendingRequest(request))
|
|
2745
|
+
return;
|
|
2746
|
+
const operation = this.operations.get(request.id);
|
|
2747
|
+
const stage = operation?.status.stage ?? (request.lastDeliveryTransportPeerId ? "dispatched" : "queued");
|
|
2748
|
+
const outcome = stage === "queued" || operation?.status.executionOutcome === "not_executed" ? "not_executed" : "unknown";
|
|
2749
|
+
if (operation) {
|
|
2750
|
+
operation.status.state = state;
|
|
2751
|
+
operation.status.outcome = outcome;
|
|
2752
|
+
if (stage === "queued")
|
|
2753
|
+
operation.status.executionOutcome = "not_executed";
|
|
2754
|
+
operation.status.waiterEndedAt = Date.now();
|
|
2755
|
+
operation.updatedAt = Date.now();
|
|
2756
|
+
this.operations.delete(request.id);
|
|
2757
|
+
this.operations.set(request.id, operation);
|
|
2758
|
+
}
|
|
2759
|
+
if (state !== "disconnected") {
|
|
2760
|
+
this.notifyRequestCancelled(request, state === "timed_out" ? "timeout" : "aborted");
|
|
2761
|
+
}
|
|
2762
|
+
const connectionLost = operation?.status.connectionLostAt !== void 0 && operation.status.connectionRestoredAt === void 0;
|
|
2763
|
+
request.reject(new RequestFailure(`${message}: ${request.id}; ${stage}; ${outcome}${connectionLost ? "; connection lost" : ""}; waiter ended, execution is not cancelled or rolled back`, state === "timed_out" ? connectionLost ? "request_connection_lost" : "request_timeout" : `request_${state}`, { requestId: request.id, targetPeerId: request.targetPeerId, stage, outcome, ...operation ? observations(operation.status) : {} }));
|
|
2764
|
+
}
|
|
2586
2765
|
removePendingRequest(request) {
|
|
2587
2766
|
if (this.pendingRequests.get(request.id) !== request)
|
|
2588
2767
|
return false;
|
|
@@ -2591,15 +2770,16 @@ var BridgeService = class {
|
|
|
2591
2770
|
request.abortSignal.removeEventListener("abort", request.abortListener);
|
|
2592
2771
|
}
|
|
2593
2772
|
this.pendingRequests.delete(request.id);
|
|
2773
|
+
this.pendingRequestBytes -= request.requestBytes;
|
|
2594
2774
|
return true;
|
|
2595
2775
|
}
|
|
2596
2776
|
claimNextRequestForTransport(transportPeerId, claimOwner) {
|
|
2597
2777
|
let outstandingCount = 0;
|
|
2598
2778
|
for (const request of this.pendingRequests.values()) {
|
|
2599
|
-
if (request.
|
|
2779
|
+
if (request.lastDeliveryTransportPeerId === transportPeerId)
|
|
2600
2780
|
outstandingCount++;
|
|
2601
2781
|
}
|
|
2602
|
-
if (outstandingCount >=
|
|
2782
|
+
if (outstandingCount >= MAX_OUTSTANDING_REQUESTS_PER_TRANSPORT)
|
|
2603
2783
|
return null;
|
|
2604
2784
|
let oldestRequest;
|
|
2605
2785
|
for (const request of this.pendingRequests.values()) {
|
|
@@ -2618,6 +2798,13 @@ var BridgeService = class {
|
|
|
2618
2798
|
return null;
|
|
2619
2799
|
oldestRequest.claimOwner = claimOwner;
|
|
2620
2800
|
oldestRequest.lastDeliveryTransportPeerId = transportPeerId;
|
|
2801
|
+
const operation = this.operations.get(oldestRequest.id);
|
|
2802
|
+
if (operation) {
|
|
2803
|
+
operation.transportPeerId = transportPeerId;
|
|
2804
|
+
operation.status.stage = "dispatched";
|
|
2805
|
+
operation.status.dispatchedAt = Date.now();
|
|
2806
|
+
operation.updatedAt = Date.now();
|
|
2807
|
+
}
|
|
2621
2808
|
return {
|
|
2622
2809
|
requestId: oldestRequest.id,
|
|
2623
2810
|
peerId: oldestRequest.targetPeerId,
|
|
@@ -2640,14 +2827,6 @@ var BridgeService = class {
|
|
|
2640
2827
|
}
|
|
2641
2828
|
releaseDeliveryClaims(claimOwner) {
|
|
2642
2829
|
const transportPeerIds = /* @__PURE__ */ new Set();
|
|
2643
|
-
for (const request of this.pendingRequests.values()) {
|
|
2644
|
-
if (request.claimOwner !== claimOwner)
|
|
2645
|
-
continue;
|
|
2646
|
-
request.claimOwner = void 0;
|
|
2647
|
-
const peer = this.getPeerById(request.targetPeerId);
|
|
2648
|
-
if (peer)
|
|
2649
|
-
transportPeerIds.add(peer.transportPeerId);
|
|
2650
|
-
}
|
|
2651
2830
|
for (const cancellation of this.pendingCancellations.values()) {
|
|
2652
2831
|
if (cancellation.claimOwner !== claimOwner)
|
|
2653
2832
|
continue;
|
|
@@ -2657,38 +2836,169 @@ var BridgeService = class {
|
|
|
2657
2836
|
for (const transportPeerId of transportPeerIds)
|
|
2658
2837
|
this.notifyRequestAvailable(transportPeerId);
|
|
2659
2838
|
}
|
|
2839
|
+
ownedOperation(transportPeerId, requestId) {
|
|
2840
|
+
this.pruneOperations(Date.now());
|
|
2841
|
+
const operation = this.operations.get(requestId);
|
|
2842
|
+
if (!operation || operation.transportPeerId !== transportPeerId)
|
|
2843
|
+
return void 0;
|
|
2844
|
+
const peer = this.getPeerById(operation.status.targetPeerId);
|
|
2845
|
+
const transport = this.getPeerById(transportPeerId);
|
|
2846
|
+
return peer?.transportPeerId === transportPeerId && transport?.transportPeerId === transportPeerId ? operation : void 0;
|
|
2847
|
+
}
|
|
2848
|
+
observeTransportProgress(transportPeerId, requestId, phase, outcome) {
|
|
2849
|
+
const operation = this.ownedOperation(transportPeerId, requestId);
|
|
2850
|
+
if (!operation || operation.status.state === "settled" || operation.status.stage === "response_delivery" || phase === "executing" && operation.status.stage !== "dispatched")
|
|
2851
|
+
return;
|
|
2852
|
+
const now = Date.now();
|
|
2853
|
+
operation.status.stage = phase;
|
|
2854
|
+
if (phase === "executing")
|
|
2855
|
+
operation.status.executionStartedAt = now;
|
|
2856
|
+
else {
|
|
2857
|
+
operation.status.executionOutcome = outcome ?? "unknown";
|
|
2858
|
+
if (outcome !== "not_executed")
|
|
2859
|
+
operation.status.executionCompletedAt = now;
|
|
2860
|
+
}
|
|
2861
|
+
operation.updatedAt = now;
|
|
2862
|
+
this.operations.delete(requestId);
|
|
2863
|
+
this.operations.set(requestId, operation);
|
|
2864
|
+
}
|
|
2865
|
+
settleTransportResponse(transportPeerId, requestId, response, error, executionOutcome) {
|
|
2866
|
+
const operation = this.ownedOperation(transportPeerId, requestId);
|
|
2867
|
+
if (!operation)
|
|
2868
|
+
return "unknown";
|
|
2869
|
+
if (error !== void 0 && executionOutcome !== void 0) {
|
|
2870
|
+
error = new RequestFailure(typeof error === "string" ? error : "Studio response failed", "studio_response_error", {
|
|
2871
|
+
requestId,
|
|
2872
|
+
targetPeerId: operation.status.targetPeerId,
|
|
2873
|
+
...observations(operation.status),
|
|
2874
|
+
executionCompletedAt: executionOutcome === "not_executed" ? void 0 : operation.status.executionCompletedAt ?? Date.now(),
|
|
2875
|
+
stage: "response_delivery",
|
|
2876
|
+
outcome: executionOutcome === "not_executed" ? "not_executed" : "unknown",
|
|
2877
|
+
executionOutcome
|
|
2878
|
+
});
|
|
2879
|
+
}
|
|
2880
|
+
return this.recordResponse(requestId, response, error, executionOutcome);
|
|
2881
|
+
}
|
|
2882
|
+
/** Trusted in-process settlement; transport handlers must use settleTransportResponse. */
|
|
2660
2883
|
resolveRequest(requestId, response) {
|
|
2661
|
-
return this.
|
|
2884
|
+
return this.recordResponse(requestId, response);
|
|
2662
2885
|
}
|
|
2886
|
+
/** Trusted in-process settlement; transport handlers must use settleTransportResponse. */
|
|
2663
2887
|
rejectRequest(requestId, error) {
|
|
2664
|
-
return this.
|
|
2888
|
+
return this.recordResponse(requestId, void 0, error);
|
|
2665
2889
|
}
|
|
2666
|
-
|
|
2890
|
+
recordResponse(requestId, response, error, executionOutcome) {
|
|
2667
2891
|
const now = Date.now();
|
|
2668
|
-
this.
|
|
2892
|
+
this.pruneOperations(now);
|
|
2893
|
+
const operation = this.operations.get(requestId);
|
|
2894
|
+
if (!operation)
|
|
2895
|
+
return "unknown";
|
|
2896
|
+
if (operation.status.state === "settled")
|
|
2897
|
+
return "already_settled";
|
|
2898
|
+
const hasError = error !== void 0;
|
|
2899
|
+
operation.status.state = "settled";
|
|
2900
|
+
const localRejection = error instanceof RequestFailure && error.details.transportStage === "server_send";
|
|
2901
|
+
const responseOutcome = handlerOutcome(response);
|
|
2902
|
+
const completedOutcome = !hasError && responseOutcome === "error" ? "error" : executionOutcome ?? (localRejection ? "not_executed" : hasError ? operation.status.executionOutcome === "success" ? "success" : "error" : responseOutcome);
|
|
2903
|
+
operation.status.executionOutcome = completedOutcome;
|
|
2904
|
+
operation.status.outcome = hasError || completedOutcome === "error" || completedOutcome === "not_executed" ? "error" : "success";
|
|
2905
|
+
if (!localRejection) {
|
|
2906
|
+
operation.status.stage = "response_delivery";
|
|
2907
|
+
if (completedOutcome !== "not_executed")
|
|
2908
|
+
operation.status.executionCompletedAt ??= now;
|
|
2909
|
+
}
|
|
2910
|
+
operation.status.settledAt = now;
|
|
2911
|
+
operation.updatedAt = now;
|
|
2912
|
+
try {
|
|
2913
|
+
const recordedError = error instanceof Error ? { ...error, name: error.name, message: error.message } : error;
|
|
2914
|
+
const serialized = JSON.stringify(hasError ? { error: recordedError } : { response });
|
|
2915
|
+
const bytes = Buffer.byteLength(serialized);
|
|
2916
|
+
if (bytes > MAX_RETAINED_RESULT_BYTES) {
|
|
2917
|
+
operation.status.resultUnavailable = { reason: "size_limit", bytes, limitBytes: MAX_RETAINED_RESULT_BYTES };
|
|
2918
|
+
} else {
|
|
2919
|
+
operation.serializedResult = serialized;
|
|
2920
|
+
operation.resultBytes = bytes;
|
|
2921
|
+
this.retainedResultBytes += bytes;
|
|
2922
|
+
this.retainedResults.set(requestId, operation);
|
|
2923
|
+
}
|
|
2924
|
+
} catch {
|
|
2925
|
+
operation.status.resultUnavailable = { reason: "serialization_failed", limitBytes: MAX_RETAINED_RESULT_BYTES };
|
|
2926
|
+
}
|
|
2927
|
+
this.operations.delete(requestId);
|
|
2928
|
+
this.operations.set(requestId, operation);
|
|
2669
2929
|
const request = this.pendingRequests.get(requestId);
|
|
2670
|
-
if (
|
|
2671
|
-
|
|
2672
|
-
|
|
2673
|
-
|
|
2674
|
-
|
|
2675
|
-
|
|
2676
|
-
|
|
2677
|
-
|
|
2678
|
-
|
|
2930
|
+
if (request && this.removePendingRequest(request)) {
|
|
2931
|
+
if (hasError)
|
|
2932
|
+
request.reject(error);
|
|
2933
|
+
else
|
|
2934
|
+
request.resolve(response);
|
|
2935
|
+
}
|
|
2936
|
+
this.pendingCancellations.delete(requestId);
|
|
2937
|
+
this.pruneOperations(now);
|
|
2938
|
+
if (operation.transportPeerId)
|
|
2939
|
+
this.notifyRequestAvailable(operation.transportPeerId);
|
|
2679
2940
|
return "accepted";
|
|
2680
2941
|
}
|
|
2681
|
-
|
|
2682
|
-
|
|
2683
|
-
|
|
2942
|
+
getRequestStatus(requestId) {
|
|
2943
|
+
this.pruneOperations(Date.now());
|
|
2944
|
+
const operation = this.operations.get(requestId);
|
|
2945
|
+
if (!operation)
|
|
2946
|
+
return void 0;
|
|
2947
|
+
const status = { ...operation.status };
|
|
2948
|
+
if (status.resultUnavailable)
|
|
2949
|
+
status.resultUnavailable = { ...status.resultUnavailable };
|
|
2950
|
+
if (operation.serializedResult !== void 0) {
|
|
2951
|
+
const result = JSON.parse(operation.serializedResult);
|
|
2952
|
+
if (result && typeof result === "object") {
|
|
2953
|
+
if ("response" in result)
|
|
2954
|
+
status.response = result.response;
|
|
2955
|
+
if ("error" in result)
|
|
2956
|
+
status.error = result.error;
|
|
2957
|
+
}
|
|
2958
|
+
}
|
|
2959
|
+
return status;
|
|
2960
|
+
}
|
|
2961
|
+
async getRequestStatusEverywhere(requestId) {
|
|
2962
|
+
return this.getRequestStatus(requestId);
|
|
2963
|
+
}
|
|
2964
|
+
pruneOperations(now) {
|
|
2965
|
+
for (const [requestId, operation] of this.operations) {
|
|
2966
|
+
if (this.pendingRequests.has(requestId))
|
|
2967
|
+
continue;
|
|
2968
|
+
if (now - operation.updatedAt < OPERATION_RETENTION_MS)
|
|
2969
|
+
break;
|
|
2970
|
+
this.operations.delete(requestId);
|
|
2971
|
+
this.retainedResults.delete(requestId);
|
|
2972
|
+
this.retainedResultBytes -= operation.resultBytes;
|
|
2973
|
+
}
|
|
2974
|
+
while (this.operations.size > MAX_OPERATION_RECORDS) {
|
|
2975
|
+
let removed = false;
|
|
2976
|
+
for (const [requestId, operation] of this.operations) {
|
|
2977
|
+
if (this.pendingRequests.has(requestId))
|
|
2978
|
+
continue;
|
|
2979
|
+
this.operations.delete(requestId);
|
|
2980
|
+
this.retainedResults.delete(requestId);
|
|
2981
|
+
this.retainedResultBytes -= operation.resultBytes;
|
|
2982
|
+
removed = true;
|
|
2983
|
+
break;
|
|
2984
|
+
}
|
|
2985
|
+
if (!removed)
|
|
2684
2986
|
break;
|
|
2685
|
-
this.acceptedRequestIds.delete(requestId);
|
|
2686
2987
|
}
|
|
2687
|
-
|
|
2688
|
-
|
|
2689
|
-
|
|
2988
|
+
if (this.retainedResults.size <= MAX_RETAINED_RESULTS && this.retainedResultBytes <= MAX_RETAINED_RESULT_BYTES)
|
|
2989
|
+
return;
|
|
2990
|
+
for (const [requestId, operation] of this.retainedResults) {
|
|
2991
|
+
operation.status.resultUnavailable = {
|
|
2992
|
+
reason: "retention_capacity",
|
|
2993
|
+
bytes: operation.resultBytes,
|
|
2994
|
+
limitBytes: MAX_RETAINED_RESULT_BYTES
|
|
2995
|
+
};
|
|
2996
|
+
this.retainedResultBytes -= operation.resultBytes;
|
|
2997
|
+
operation.resultBytes = 0;
|
|
2998
|
+
operation.serializedResult = void 0;
|
|
2999
|
+
this.retainedResults.delete(requestId);
|
|
3000
|
+
if (this.retainedResults.size <= MAX_RETAINED_RESULTS && this.retainedResultBytes <= MAX_RETAINED_RESULT_BYTES)
|
|
2690
3001
|
break;
|
|
2691
|
-
this.acceptedRequestIds.delete(oldestRequestId);
|
|
2692
3002
|
}
|
|
2693
3003
|
}
|
|
2694
3004
|
prunePendingCancellations(now) {
|
|
@@ -2707,16 +3017,15 @@ var BridgeService = class {
|
|
|
2707
3017
|
cleanupOldRequests() {
|
|
2708
3018
|
const now = Date.now();
|
|
2709
3019
|
for (const request of this.pendingRequests.values()) {
|
|
2710
|
-
if (now - request.timestamp
|
|
2711
|
-
this.
|
|
2712
|
-
request.reject(new Error("Request timeout"));
|
|
3020
|
+
if (now - request.timestamp >= request.timeoutMs) {
|
|
3021
|
+
this.endRequestWaiter(request, "timed_out", "Request timeout");
|
|
2713
3022
|
}
|
|
2714
3023
|
}
|
|
3024
|
+
this.pruneOperations(now);
|
|
2715
3025
|
}
|
|
2716
3026
|
clearAllPendingRequests() {
|
|
2717
3027
|
for (const request of Array.from(this.pendingRequests.values())) {
|
|
2718
|
-
this.
|
|
2719
|
-
request.reject(new Error("Connection closed"));
|
|
3028
|
+
this.endRequestWaiter(request, "disconnected", "Connection closed");
|
|
2720
3029
|
}
|
|
2721
3030
|
this.pendingCancellations.clear();
|
|
2722
3031
|
}
|
|
@@ -2981,7 +3290,7 @@ Tool descriptions explain selection. Input schemas explain arguments. This guide
|
|
|
2981
3290
|
|
|
2982
3291
|
- Use selection with action=get when the user's Studio selection should define the scope.
|
|
2983
3292
|
- Use action=set with instance paths to replace, add to, or remove from the selection. An empty paths array in set mode clears it.
|
|
2984
|
-
- Use action=view with a BasePart or Model path to frame it. The current viewing direction is preserved unless from or angleY overrides it. padding below 1 crops closer and above 1 pulls back.
|
|
3293
|
+
- Use action=view with a BasePart or Model path to frame it, or omit path to frame exactly one selected BasePart or Model. Empty, multiple, or unsupported selections require an explicit target or a changed selection. The original camera type is restored after framing, including on failure. The current viewing direction is preserved unless from or angleY overrides it. padding below 1 crops closer and above 1 pulls back.
|
|
2985
3294
|
- For visual proof, change the instance, frame it with selection, then call capture_screenshot.
|
|
2986
3295
|
|
|
2987
3296
|
## Script changes
|
|
@@ -4471,6 +4780,21 @@ async function resolveStudioExeAsync() {
|
|
|
4471
4780
|
}
|
|
4472
4781
|
return candidates[0];
|
|
4473
4782
|
}
|
|
4783
|
+
var WINDOWS_STUDIO_PROCESS_QUERY = [
|
|
4784
|
+
"$ErrorActionPreference = 'Stop'",
|
|
4785
|
+
// Get-Process reports a missing name as an error, not a successful empty set.
|
|
4786
|
+
'$studio = @(); try { $studio = @(Get-Process RobloxStudioBeta -ErrorAction Stop) } catch { if ($_.FullyQualifiedErrorId -notlike "NoProcessFoundForGivenName,*") { throw } }',
|
|
4787
|
+
"$processes = @($studio | ForEach-Object { [PSCustomObject]@{ Id = $_.Id; Name = $_.Name; Path = $_.Path; MainWindowTitle = $_.MainWindowTitle; StartTimeUtcFileTime = $_.StartTime.ToUniversalTime().ToFileTimeUtc().ToString() } })",
|
|
4788
|
+
"ConvertTo-Json -InputObject $processes -Compress"
|
|
4789
|
+
].join("; ");
|
|
4790
|
+
function parseWindowsStudioProcesses(output) {
|
|
4791
|
+
const parsed = JSON.parse(output);
|
|
4792
|
+
const processes = Array.isArray(parsed) ? parsed : [parsed];
|
|
4793
|
+
if (!processes.every((value) => value !== null && typeof value === "object" && "Id" in value && typeof value.Id === "number" && Number.isSafeInteger(value.Id) && value.Id > 0 && "Name" in value && typeof value.Name === "string" && "Path" in value && typeof value.Path === "string" && "MainWindowTitle" in value && typeof value.MainWindowTitle === "string" && "StartTimeUtcFileTime" in value && typeof value.StartTimeUtcFileTime === "string" && /^[1-9]\d*$/u.test(value.StartTimeUtcFileTime))) {
|
|
4794
|
+
throw new Error("Malformed Roblox Studio process enumeration result.");
|
|
4795
|
+
}
|
|
4796
|
+
return processes;
|
|
4797
|
+
}
|
|
4474
4798
|
async function observeStudioProcesses() {
|
|
4475
4799
|
const observedAt = Date.now();
|
|
4476
4800
|
try {
|
|
@@ -4493,11 +4817,8 @@ async function observeStudioProcesses() {
|
|
|
4493
4817
|
if (process.platform !== "win32" && !isWsl()) {
|
|
4494
4818
|
return { status: "ok", observedAt, processes: [] };
|
|
4495
4819
|
}
|
|
4496
|
-
const out = await powershellAsync(
|
|
4497
|
-
|
|
4498
|
-
return { status: "ok", observedAt, processes: [] };
|
|
4499
|
-
const parsed = JSON.parse(out);
|
|
4500
|
-
return { status: "ok", observedAt, processes: Array.isArray(parsed) ? parsed : [parsed] };
|
|
4820
|
+
const out = await powershellAsync(WINDOWS_STUDIO_PROCESS_QUERY);
|
|
4821
|
+
return { status: "ok", observedAt, processes: parseWindowsStudioProcesses(out) };
|
|
4501
4822
|
} catch (error) {
|
|
4502
4823
|
return {
|
|
4503
4824
|
status: "error",
|
|
@@ -5529,7 +5850,6 @@ var INTERNAL_RESULT_KEYS = /* @__PURE__ */ new Set([
|
|
|
5529
5850
|
"transportPeerId",
|
|
5530
5851
|
"pluginVariant",
|
|
5531
5852
|
"pluginVersion",
|
|
5532
|
-
"requestId",
|
|
5533
5853
|
"serverVersion"
|
|
5534
5854
|
]);
|
|
5535
5855
|
var TEXT_RESULT_TOOLS = /* @__PURE__ */ new Set(["get_roblox_docs"]);
|
|
@@ -5651,8 +5971,11 @@ function publicToolErrorBody(name, error) {
|
|
|
5651
5971
|
}
|
|
5652
5972
|
if (error instanceof RoutingFailure)
|
|
5653
5973
|
return publicRoutingError(error);
|
|
5974
|
+
if (error instanceof RequestFailure) {
|
|
5975
|
+
return { error: error.code, message: error.message.slice(0, 500), ...error.details };
|
|
5976
|
+
}
|
|
5654
5977
|
console.error(`[tool:${name}]`, error);
|
|
5655
|
-
const message = error instanceof Error ? error.message : "Tool execution failed.";
|
|
5978
|
+
const message = error instanceof Error ? error.message : typeof error === "string" ? error : "Tool execution failed.";
|
|
5656
5979
|
return { error: "tool_failed", message: message.slice(0, 500) };
|
|
5657
5980
|
}
|
|
5658
5981
|
function concise(text, maxLength, firstSentence = false) {
|
|
@@ -5709,6 +6032,9 @@ function serverInstructions(definitions) {
|
|
|
5709
6032
|
if (has("get_connected_instances")) {
|
|
5710
6033
|
instructions.push("When more than one Studio process scope is connected, call get_connected_instances and pass either a top-level instance id or a multiplayer group's role-suffixed instance id as instance_id.");
|
|
5711
6034
|
}
|
|
6035
|
+
if (has("get_request_status", "execute_luau", "set_properties")) {
|
|
6036
|
+
instructions.push("Supply a unique operation_id to execute_luau or set_properties when retry safety matters. After a timeout, query get_request_status with that ID before retrying. Identical arguments reuse a retained outcome; changed arguments are rejected. Recovery and deduplication are bounded to the current server session and five-minute retention window; result payloads may be evicted earlier. Unknown status does not mean unexecuted, and cancellation cannot roll back mutations.", "Request stages are queued, dispatched, executing (plugin handler entered), and response_delivery (handler returned or admission rejected). executionOutcome is separate from waiter state and delivery outcome; handler observations do not prove user Luau instructions ran. A waiter timeout is not an execution deadline or rollback. Neither missing progress nor connection loss proves completion.");
|
|
6037
|
+
}
|
|
5712
6038
|
if (has("search_objects", "get_project_structure", "grep_scripts", "execute_luau")) {
|
|
5713
6039
|
instructions.push("Use search_objects, get_project_structure, or grep_scripts for standard discovery. Use execute_luau for custom traversal or bulk edits.");
|
|
5714
6040
|
}
|
|
@@ -5771,7 +6097,7 @@ function createToolHttpHandler(options) {
|
|
|
5771
6097
|
}
|
|
5772
6098
|
|
|
5773
6099
|
// ../core/dist/auth.js
|
|
5774
|
-
import { randomBytes, createHash, timingSafeEqual } from "crypto";
|
|
6100
|
+
import { randomBytes, createHash as createHash2, timingSafeEqual } from "crypto";
|
|
5775
6101
|
import { existsSync as existsSync3, mkdirSync as mkdirSync2, readFileSync as readFileSync3, writeFileSync, chmodSync } from "fs";
|
|
5776
6102
|
import { join as join3, dirname as dirname2 } from "path";
|
|
5777
6103
|
import { homedir as homedir3 } from "os";
|
|
@@ -5809,192 +6135,308 @@ function resolveAuthToken() {
|
|
|
5809
6135
|
}
|
|
5810
6136
|
}
|
|
5811
6137
|
function tokensMatch(provided, expected) {
|
|
5812
|
-
const a =
|
|
5813
|
-
const b =
|
|
6138
|
+
const a = createHash2("sha256").update(provided).digest();
|
|
6139
|
+
const b = createHash2("sha256").update(expected).digest();
|
|
5814
6140
|
return timingSafeEqual(a, b);
|
|
5815
6141
|
}
|
|
5816
6142
|
|
|
6143
|
+
// ../core/dist/http-body-limits.js
|
|
6144
|
+
var HTTP_BODY_LIMIT_BYTES = 50 * 1024 * 1024;
|
|
6145
|
+
|
|
5817
6146
|
// ../core/dist/studio-transport.js
|
|
5818
6147
|
var HEARTBEAT_INTERVAL_MS = 1e4;
|
|
5819
|
-
var
|
|
5820
|
-
var
|
|
6148
|
+
var MAX_PENDING_ACKS = 128;
|
|
6149
|
+
var STUDIO_PROTOCOL_VERSION = 1;
|
|
6150
|
+
var MAX_ACTIVE_STUDIO_SOCKETS = 64;
|
|
6151
|
+
var MAX_STUDIO_FRAME_BYTES = 64 * 1024 * 1024;
|
|
6152
|
+
var MAX_STUDIO_BUFFERED_BYTES = MAX_STUDIO_FRAME_BYTES + 14;
|
|
6153
|
+
var WebSocketStudioTransport = class {
|
|
5821
6154
|
queue;
|
|
5822
|
-
|
|
6155
|
+
sockets = /* @__PURE__ */ new Map();
|
|
6156
|
+
closing = /* @__PURE__ */ new Set();
|
|
5823
6157
|
unsubscribeRequestAvailable;
|
|
5824
6158
|
unsubscribePeerClosed;
|
|
5825
6159
|
nextGeneration = 0;
|
|
6160
|
+
closed = false;
|
|
5826
6161
|
constructor(queue) {
|
|
5827
6162
|
this.queue = queue;
|
|
5828
6163
|
this.unsubscribeRequestAvailable = queue.onRequestAvailable((transportPeerId) => {
|
|
5829
|
-
const
|
|
5830
|
-
if (
|
|
5831
|
-
this.pump(
|
|
6164
|
+
const connection = this.sockets.get(transportPeerId);
|
|
6165
|
+
if (connection)
|
|
6166
|
+
this.pump(connection);
|
|
5832
6167
|
});
|
|
5833
|
-
this.unsubscribePeerClosed = queue.onPeerClosed((
|
|
5834
|
-
if (
|
|
5835
|
-
this.closeTransport(
|
|
5836
|
-
}
|
|
6168
|
+
this.unsubscribePeerClosed = queue.onPeerClosed((peer) => {
|
|
6169
|
+
if (peer.peerId === peer.transportPeerId)
|
|
6170
|
+
this.closeTransport(peer.transportPeerId);
|
|
5837
6171
|
});
|
|
5838
6172
|
}
|
|
5839
|
-
get
|
|
5840
|
-
return this.
|
|
6173
|
+
get activeSocketCount() {
|
|
6174
|
+
return this.sockets.size;
|
|
5841
6175
|
}
|
|
5842
6176
|
canOpen(transportPeerId) {
|
|
5843
|
-
return this.
|
|
6177
|
+
return !this.closed && (this.sockets.has(transportPeerId) || this.sockets.size < MAX_ACTIVE_STUDIO_SOCKETS);
|
|
5844
6178
|
}
|
|
5845
|
-
open(transportPeerId,
|
|
5846
|
-
if (!this.canOpen(transportPeerId))
|
|
6179
|
+
open(transportPeerId, socket, status) {
|
|
6180
|
+
if (!this.canOpen(transportPeerId) || socket.readyState !== 1)
|
|
5847
6181
|
return void 0;
|
|
5848
6182
|
this.nextGeneration += 1;
|
|
5849
|
-
const claimOwner = `
|
|
5850
|
-
const
|
|
6183
|
+
const claimOwner = `ws:${transportPeerId}:${this.nextGeneration}`;
|
|
6184
|
+
const connection = {
|
|
5851
6185
|
transportPeerId,
|
|
5852
6186
|
claimOwner,
|
|
5853
|
-
|
|
6187
|
+
socket,
|
|
5854
6188
|
status,
|
|
5855
6189
|
closed: false,
|
|
5856
|
-
|
|
6190
|
+
sending: false,
|
|
6191
|
+
pumping: false,
|
|
6192
|
+
settling: false,
|
|
5857
6193
|
statusPending: true,
|
|
5858
|
-
|
|
5859
|
-
|
|
5860
|
-
|
|
5861
|
-
|
|
5862
|
-
|
|
5863
|
-
|
|
5864
|
-
|
|
6194
|
+
heartbeatPending: false,
|
|
6195
|
+
acknowledgements: /* @__PURE__ */ new Map(),
|
|
6196
|
+
onClose: () => {
|
|
6197
|
+
this.closeSocket(connection);
|
|
6198
|
+
this.finishClose(connection);
|
|
6199
|
+
},
|
|
6200
|
+
onError: () => this.closeSocket(connection, 1011, "socket_error"),
|
|
6201
|
+
onMessage: (data, isBinary) => this.receive(connection, data, isBinary)
|
|
5865
6202
|
};
|
|
5866
6203
|
this.queue.setDeliveryActive(transportPeerId, claimOwner, true);
|
|
5867
|
-
this.
|
|
5868
|
-
const replaced = this.streams.get(transportPeerId);
|
|
6204
|
+
const replaced = this.sockets.get(transportPeerId);
|
|
5869
6205
|
if (replaced)
|
|
5870
|
-
this.
|
|
5871
|
-
this.
|
|
5872
|
-
|
|
5873
|
-
|
|
5874
|
-
|
|
5875
|
-
|
|
5876
|
-
|
|
5877
|
-
|
|
5878
|
-
|
|
5879
|
-
|
|
5880
|
-
|
|
5881
|
-
|
|
5882
|
-
|
|
5883
|
-
}
|
|
6206
|
+
this.closeSocket(replaced, 1012, "transport_replaced");
|
|
6207
|
+
this.sockets.set(transportPeerId, connection);
|
|
6208
|
+
this.queue.updatePeerActivity(transportPeerId);
|
|
6209
|
+
socket.on("close", connection.onClose);
|
|
6210
|
+
socket.on("error", connection.onError);
|
|
6211
|
+
socket.on("message", connection.onMessage);
|
|
6212
|
+
connection.heartbeatTimer = setInterval(() => {
|
|
6213
|
+
if (!this.isCurrent(connection))
|
|
6214
|
+
return;
|
|
6215
|
+
this.queue.updatePeerActivity(transportPeerId);
|
|
6216
|
+
connection.statusPending = true;
|
|
6217
|
+
connection.heartbeatPending = true;
|
|
6218
|
+
this.pump(connection);
|
|
5884
6219
|
}, HEARTBEAT_INTERVAL_MS);
|
|
5885
|
-
|
|
5886
|
-
this.pump(
|
|
5887
|
-
return {
|
|
5888
|
-
transportPeerId,
|
|
5889
|
-
close: () => this.closeStream(stream, true)
|
|
5890
|
-
};
|
|
6220
|
+
connection.heartbeatTimer.unref();
|
|
6221
|
+
this.pump(connection);
|
|
6222
|
+
return { transportPeerId, close: () => this.closeSocket(connection, 1e3, "transport_closed") };
|
|
5891
6223
|
}
|
|
5892
6224
|
refreshStatus(transportPeerId) {
|
|
5893
6225
|
if (transportPeerId !== void 0) {
|
|
5894
|
-
const
|
|
5895
|
-
if (
|
|
5896
|
-
|
|
5897
|
-
|
|
5898
|
-
this.pump(stream);
|
|
6226
|
+
const connection = this.sockets.get(transportPeerId);
|
|
6227
|
+
if (connection) {
|
|
6228
|
+
connection.statusPending = true;
|
|
6229
|
+
this.pump(connection);
|
|
5899
6230
|
}
|
|
5900
6231
|
return;
|
|
5901
6232
|
}
|
|
5902
|
-
for (const
|
|
5903
|
-
|
|
5904
|
-
|
|
5905
|
-
this.pump(stream);
|
|
6233
|
+
for (const connection of this.sockets.values()) {
|
|
6234
|
+
connection.statusPending = true;
|
|
6235
|
+
this.pump(connection);
|
|
5906
6236
|
}
|
|
5907
6237
|
}
|
|
5908
6238
|
closeTransport(transportPeerId) {
|
|
5909
|
-
const
|
|
5910
|
-
if (
|
|
5911
|
-
this.
|
|
6239
|
+
const connection = this.sockets.get(transportPeerId);
|
|
6240
|
+
if (connection)
|
|
6241
|
+
this.closeSocket(connection, 1e3, "peer_unregistered");
|
|
5912
6242
|
}
|
|
5913
6243
|
close() {
|
|
5914
|
-
|
|
5915
|
-
|
|
5916
|
-
|
|
6244
|
+
this.closed = true;
|
|
6245
|
+
for (const connection of this.sockets.values())
|
|
6246
|
+
this.closeSocket(connection, 1001, "server_shutdown");
|
|
5917
6247
|
this.unsubscribeRequestAvailable();
|
|
5918
6248
|
this.unsubscribePeerClosed();
|
|
5919
6249
|
}
|
|
5920
|
-
|
|
5921
|
-
|
|
6250
|
+
isCurrent(connection) {
|
|
6251
|
+
return !connection.closed && this.sockets.get(connection.transportPeerId) === connection;
|
|
6252
|
+
}
|
|
6253
|
+
receive(connection, data, isBinary) {
|
|
6254
|
+
if (!this.isCurrent(connection))
|
|
6255
|
+
return;
|
|
6256
|
+
if (isBinary) {
|
|
6257
|
+
this.closeSocket(connection, 1003, "text_frames_required");
|
|
5922
6258
|
return;
|
|
5923
|
-
if (stream.statusPending) {
|
|
5924
|
-
stream.statusPending = false;
|
|
5925
|
-
let status;
|
|
5926
|
-
try {
|
|
5927
|
-
status = stream.status();
|
|
5928
|
-
} catch {
|
|
5929
|
-
this.closeStream(stream);
|
|
5930
|
-
return;
|
|
5931
|
-
}
|
|
5932
|
-
const statusJson = JSON.stringify(status);
|
|
5933
|
-
if (statusJson !== stream.lastStatusJson) {
|
|
5934
|
-
stream.lastStatusJson = statusJson;
|
|
5935
|
-
if (!this.write(stream, status))
|
|
5936
|
-
return;
|
|
5937
|
-
}
|
|
5938
6259
|
}
|
|
5939
|
-
|
|
5940
|
-
|
|
5941
|
-
|
|
5942
|
-
|
|
5943
|
-
|
|
6260
|
+
const bytes = Array.isArray(data) ? data.reduce((total, chunk) => total + chunk.byteLength, 0) : data.byteLength;
|
|
6261
|
+
if (bytes > MAX_STUDIO_FRAME_BYTES) {
|
|
6262
|
+
this.closeSocket(connection, 1009, `server_receive bytes=${bytes} limit=${MAX_STUDIO_FRAME_BYTES}`);
|
|
6263
|
+
return;
|
|
6264
|
+
}
|
|
6265
|
+
let message;
|
|
6266
|
+
try {
|
|
6267
|
+
const buffer = Array.isArray(data) ? Buffer.concat(data, bytes) : data instanceof ArrayBuffer ? Buffer.from(data) : data;
|
|
6268
|
+
message = JSON.parse(buffer.toString("utf8"));
|
|
6269
|
+
} catch {
|
|
6270
|
+
this.closeSocket(connection, 1007, "invalid_json");
|
|
6271
|
+
return;
|
|
6272
|
+
}
|
|
6273
|
+
if (message === null || typeof message !== "object" || Array.isArray(message) || !("kind" in message) || message.kind !== "response" && message.kind !== "progress" || !("requestId" in message) || typeof message.requestId !== "string" || message.requestId.length === 0 || message.requestId.length > 1024) {
|
|
6274
|
+
this.closeSocket(connection, 1008, "invalid_response");
|
|
6275
|
+
return;
|
|
6276
|
+
}
|
|
6277
|
+
if (message.kind === "progress") {
|
|
6278
|
+
const outcome = "outcome" in message ? message.outcome : void 0;
|
|
6279
|
+
if (!("phase" in message) || message.phase !== "executing" && message.phase !== "response_delivery" || outcome !== void 0 && !isExecutionOutcome(outcome) || message.phase === "executing" && "outcome" in message)
|
|
5944
6280
|
return;
|
|
6281
|
+
this.queue.observeTransportProgress(connection.transportPeerId, message.requestId, message.phase, outcome);
|
|
6282
|
+
this.queue.updatePeerActivity(connection.transportPeerId);
|
|
6283
|
+
return;
|
|
5945
6284
|
}
|
|
5946
|
-
|
|
5947
|
-
|
|
5948
|
-
|
|
6285
|
+
const executionOutcome = "executionOutcome" in message ? message.executionOutcome : void 0;
|
|
6286
|
+
if (executionOutcome !== void 0 && !isExecutionOutcome(executionOutcome))
|
|
6287
|
+
return;
|
|
6288
|
+
const response = "response" in message ? message.response : void 0;
|
|
6289
|
+
const error = "error" in message ? message.error : void 0;
|
|
6290
|
+
connection.settling = true;
|
|
6291
|
+
try {
|
|
6292
|
+
const disposition = this.queue.settleTransportResponse(connection.transportPeerId, message.requestId, response, error, executionOutcome);
|
|
6293
|
+
if (!this.isCurrent(connection))
|
|
5949
6294
|
return;
|
|
5950
|
-
|
|
5951
|
-
|
|
5952
|
-
requestId: request.requestId,
|
|
5953
|
-
peerId: request.peerId,
|
|
5954
|
-
target: request.target,
|
|
5955
|
-
endpoint: request.endpoint,
|
|
5956
|
-
data: request.data === void 0 ? null : request.data,
|
|
5957
|
-
remainingMs: request.remainingMs
|
|
5958
|
-
};
|
|
5959
|
-
if (!this.write(stream, event))
|
|
6295
|
+
if (!connection.acknowledgements.has(message.requestId) && connection.acknowledgements.size >= MAX_PENDING_ACKS) {
|
|
6296
|
+
this.closeSocket(connection, 1013, "ack_backpressure");
|
|
5960
6297
|
return;
|
|
6298
|
+
}
|
|
6299
|
+
connection.acknowledgements.set(message.requestId, {
|
|
6300
|
+
kind: "ack",
|
|
6301
|
+
requestId: message.requestId,
|
|
6302
|
+
disposition
|
|
6303
|
+
});
|
|
6304
|
+
this.queue.updatePeerActivity(connection.transportPeerId);
|
|
6305
|
+
} catch {
|
|
6306
|
+
this.closeSocket(connection, 1011, "response_recording_failed");
|
|
6307
|
+
} finally {
|
|
6308
|
+
connection.settling = false;
|
|
5961
6309
|
}
|
|
6310
|
+
this.pump(connection);
|
|
5962
6311
|
}
|
|
5963
|
-
|
|
5964
|
-
if (
|
|
5965
|
-
return
|
|
6312
|
+
pump(connection) {
|
|
6313
|
+
if (!this.isCurrent(connection) || connection.sending || connection.pumping || connection.settling)
|
|
6314
|
+
return;
|
|
6315
|
+
connection.pumping = true;
|
|
5966
6316
|
try {
|
|
5967
|
-
|
|
5968
|
-
|
|
5969
|
-
|
|
5970
|
-
|
|
5971
|
-
|
|
5972
|
-
|
|
6317
|
+
while (this.isCurrent(connection) && !connection.sending) {
|
|
6318
|
+
const ack = connection.acknowledgements.values().next().value;
|
|
6319
|
+
if (ack) {
|
|
6320
|
+
connection.acknowledgements.delete(ack.requestId);
|
|
6321
|
+
this.send(connection, ack);
|
|
6322
|
+
continue;
|
|
6323
|
+
}
|
|
6324
|
+
if (connection.statusPending) {
|
|
6325
|
+
connection.statusPending = false;
|
|
6326
|
+
const status = connection.status();
|
|
6327
|
+
const statusJson = JSON.stringify(status);
|
|
6328
|
+
if (statusJson !== connection.lastStatusJson) {
|
|
6329
|
+
connection.lastStatusJson = statusJson;
|
|
6330
|
+
this.send(connection, status, statusJson);
|
|
6331
|
+
continue;
|
|
6332
|
+
}
|
|
6333
|
+
}
|
|
6334
|
+
const cancellation = this.queue.claimNextCancellationForTransport(connection.transportPeerId, connection.claimOwner);
|
|
6335
|
+
if (cancellation) {
|
|
6336
|
+
this.send(connection, { kind: "cancel", ...cancellation });
|
|
6337
|
+
continue;
|
|
6338
|
+
}
|
|
6339
|
+
const request = this.queue.claimNextRequestForTransport(connection.transportPeerId, connection.claimOwner);
|
|
6340
|
+
if (request) {
|
|
6341
|
+
this.send(connection, { kind: "request", ...request, data: request.data ?? null });
|
|
6342
|
+
continue;
|
|
6343
|
+
}
|
|
6344
|
+
if (connection.heartbeatPending) {
|
|
6345
|
+
connection.heartbeatPending = false;
|
|
6346
|
+
this.send(connection, { kind: "heartbeat", timestamp: Date.now() });
|
|
6347
|
+
continue;
|
|
6348
|
+
}
|
|
6349
|
+
return;
|
|
6350
|
+
}
|
|
5973
6351
|
} catch {
|
|
5974
|
-
this.
|
|
5975
|
-
|
|
6352
|
+
this.closeSocket(connection, 1011, "transport_pump_failed");
|
|
6353
|
+
} finally {
|
|
6354
|
+
connection.pumping = false;
|
|
5976
6355
|
}
|
|
5977
6356
|
}
|
|
5978
|
-
|
|
5979
|
-
|
|
6357
|
+
send(connection, event, serialized) {
|
|
6358
|
+
let json;
|
|
6359
|
+
try {
|
|
6360
|
+
json = serialized ?? JSON.stringify(event);
|
|
6361
|
+
} catch {
|
|
6362
|
+
if (event.kind !== "request")
|
|
6363
|
+
throw new Error("Unserializable Studio event");
|
|
6364
|
+
this.queue.settleTransportResponse(connection.transportPeerId, event.requestId, void 0, new RequestFailure("Studio request could not be serialized before WebSocket delivery at server_send", "studio_frame_serialization_failed", {
|
|
6365
|
+
requestId: event.requestId,
|
|
6366
|
+
targetPeerId: event.peerId,
|
|
6367
|
+
stage: "dispatched",
|
|
6368
|
+
outcome: "not_executed",
|
|
6369
|
+
executionOutcome: "not_executed",
|
|
6370
|
+
transportStage: "server_send"
|
|
6371
|
+
}));
|
|
5980
6372
|
return;
|
|
5981
|
-
|
|
5982
|
-
|
|
5983
|
-
|
|
5984
|
-
|
|
5985
|
-
|
|
5986
|
-
|
|
5987
|
-
|
|
5988
|
-
|
|
5989
|
-
|
|
5990
|
-
|
|
5991
|
-
|
|
5992
|
-
|
|
5993
|
-
|
|
5994
|
-
|
|
5995
|
-
}
|
|
6373
|
+
}
|
|
6374
|
+
const bytes = Buffer.byteLength(json);
|
|
6375
|
+
if (bytes > MAX_STUDIO_FRAME_BYTES) {
|
|
6376
|
+
if (event.kind === "request") {
|
|
6377
|
+
this.queue.settleTransportResponse(connection.transportPeerId, event.requestId, void 0, new RequestFailure(`Studio request frame is ${bytes} bytes; limit is ${MAX_STUDIO_FRAME_BYTES} at server_send`, "studio_frame_too_large", {
|
|
6378
|
+
requestId: event.requestId,
|
|
6379
|
+
targetPeerId: event.peerId,
|
|
6380
|
+
stage: "dispatched",
|
|
6381
|
+
outcome: "not_executed",
|
|
6382
|
+
executionOutcome: "not_executed",
|
|
6383
|
+
transportStage: "server_send",
|
|
6384
|
+
bytes,
|
|
6385
|
+
limitBytes: MAX_STUDIO_FRAME_BYTES
|
|
6386
|
+
}));
|
|
6387
|
+
} else {
|
|
6388
|
+
this.closeSocket(connection, 1009, `server_send bytes=${bytes} limit=${MAX_STUDIO_FRAME_BYTES}`);
|
|
5996
6389
|
}
|
|
6390
|
+
return;
|
|
6391
|
+
}
|
|
6392
|
+
if (connection.socket.readyState !== 1 || connection.socket.bufferedAmount + bytes > MAX_STUDIO_BUFFERED_BYTES) {
|
|
6393
|
+
this.closeSocket(connection, 1013, "server_send_backpressure");
|
|
6394
|
+
return;
|
|
5997
6395
|
}
|
|
6396
|
+
connection.sending = true;
|
|
6397
|
+
connection.socket.send(json, (error) => {
|
|
6398
|
+
if (!this.isCurrent(connection))
|
|
6399
|
+
return;
|
|
6400
|
+
connection.sending = false;
|
|
6401
|
+
if (error)
|
|
6402
|
+
this.closeSocket(connection, 1011, "server_send_failed");
|
|
6403
|
+
else
|
|
6404
|
+
this.pump(connection);
|
|
6405
|
+
});
|
|
6406
|
+
}
|
|
6407
|
+
closeSocket(connection, code, reason) {
|
|
6408
|
+
if (connection.closed)
|
|
6409
|
+
return;
|
|
6410
|
+
connection.closed = true;
|
|
6411
|
+
clearInterval(connection.heartbeatTimer);
|
|
6412
|
+
connection.socket.removeListener("message", connection.onMessage);
|
|
6413
|
+
connection.acknowledgements.clear();
|
|
6414
|
+
if (this.sockets.get(connection.transportPeerId) === connection)
|
|
6415
|
+
this.sockets.delete(connection.transportPeerId);
|
|
6416
|
+
this.queue.setDeliveryActive(connection.transportPeerId, connection.claimOwner, false);
|
|
6417
|
+
this.queue.releaseDeliveryClaims(connection.claimOwner);
|
|
6418
|
+
if (code !== void 0) {
|
|
6419
|
+
this.closing.add(connection);
|
|
6420
|
+
connection.closeTimer = setTimeout(() => {
|
|
6421
|
+
connection.socket.terminate();
|
|
6422
|
+
this.finishClose(connection);
|
|
6423
|
+
}, 1e3);
|
|
6424
|
+
connection.closeTimer.unref();
|
|
6425
|
+
connection.socket.close(code, reason);
|
|
6426
|
+
if (this.closing.size > MAX_ACTIVE_STUDIO_SOCKETS) {
|
|
6427
|
+
const oldest = this.closing.values().next().value;
|
|
6428
|
+
if (oldest) {
|
|
6429
|
+
oldest.socket.terminate();
|
|
6430
|
+
this.finishClose(oldest);
|
|
6431
|
+
}
|
|
6432
|
+
}
|
|
6433
|
+
}
|
|
6434
|
+
}
|
|
6435
|
+
finishClose(connection) {
|
|
6436
|
+
clearTimeout(connection.closeTimer);
|
|
6437
|
+
this.closing.delete(connection);
|
|
6438
|
+
connection.socket.removeListener("close", connection.onClose);
|
|
6439
|
+
connection.socket.removeListener("error", connection.onError);
|
|
5998
6440
|
}
|
|
5999
6441
|
};
|
|
6000
6442
|
|
|
@@ -6074,13 +6516,14 @@ function requiredClosedLineRange(body, toolName) {
|
|
|
6074
6516
|
return { startLine: parsed.startLine, endLine: parsed.endLine };
|
|
6075
6517
|
}
|
|
6076
6518
|
var TOOL_HANDLERS = {
|
|
6519
|
+
get_request_status: (tools, body) => tools.getRequestStatus(body.request_id),
|
|
6077
6520
|
get_roblox_skills: (tools, body) => tools.getRobloxSkills(body.action, body.name),
|
|
6078
6521
|
get_roblox_docs: (tools, body) => tools.getRobloxDocs(body.name, body.doc_type, body.section),
|
|
6079
6522
|
get_place_info: (tools, body) => tools.getPlaceInfo(body.instance_id),
|
|
6080
6523
|
search_objects: (tools, body) => tools.searchObjects(body.query, body.searchType, body.propertyName, body.instance_id),
|
|
6081
6524
|
get_instance_properties: (tools, body) => tools.getInstanceProperties(body.instancePath, body.excludeSource, body.instance_id),
|
|
6082
6525
|
get_project_structure: (tools, body) => tools.getProjectStructure(body.path, body.maxDepth, body.scriptsOnly, body.instance_id),
|
|
6083
|
-
set_properties: (tools, body) => tools.setProperties(body.instancePath, body.properties, body.instance_id),
|
|
6526
|
+
set_properties: (tools, body) => tools.setProperties(body.instancePath, body.properties, body.instance_id, body.operation_id),
|
|
6084
6527
|
grep_scripts: (tools, body, context) => tools.grepScripts(body.pattern, {
|
|
6085
6528
|
caseSensitive: body.caseSensitive,
|
|
6086
6529
|
usePattern: body.usePattern,
|
|
@@ -6104,7 +6547,7 @@ var TOOL_HANDLERS = {
|
|
|
6104
6547
|
},
|
|
6105
6548
|
get_attributes: (tools, body) => tools.getAttributes(body.instancePath, body.instance_id),
|
|
6106
6549
|
selection: (tools, body) => tools.selection(body.action, body, body.instance_id),
|
|
6107
|
-
execute_luau: (tools, body) => tools.executeLuau(body.code, body.target, body.instance_id),
|
|
6550
|
+
execute_luau: (tools, body) => tools.executeLuau(body.code, body.target, body.instance_id, body.operation_id),
|
|
6108
6551
|
eval_server_runtime: (tools, body) => tools.evalServerRuntime(body.code, body.instance_id),
|
|
6109
6552
|
eval_client_runtime: (tools, body) => tools.evalClientRuntime(body.code, body.target, body.instance_id),
|
|
6110
6553
|
set_network_profile: (tools, body) => tools.setNetworkProfile(body.profile, body.target, body.overrides, body.instance_id),
|
|
@@ -6172,6 +6615,16 @@ var TOOL_HANDLERS = {
|
|
|
6172
6615
|
maxReplacements: body.maxReplacements
|
|
6173
6616
|
}, body.instance_id)
|
|
6174
6617
|
};
|
|
6618
|
+
var MAX_STUDIO_SESSIONS = 256;
|
|
6619
|
+
function rejectStudioUpgrade(socket, status, error) {
|
|
6620
|
+
const body = JSON.stringify({ error });
|
|
6621
|
+
socket.end(`HTTP/1.1 ${status} ${http.STATUS_CODES[status]}\r
|
|
6622
|
+
Connection: close\r
|
|
6623
|
+
Content-Type: application/json\r
|
|
6624
|
+
Content-Length: ${Buffer.byteLength(body)}\r
|
|
6625
|
+
\r
|
|
6626
|
+
${body}`);
|
|
6627
|
+
}
|
|
6175
6628
|
function createHttpServer(tools, bridge, allowedTools, serverConfig, security) {
|
|
6176
6629
|
const app = express();
|
|
6177
6630
|
const studioLifecycleCallable = !allowedTools || allowedTools.has("manage_instance");
|
|
@@ -6181,8 +6634,19 @@ function createHttpServer(tools, bridge, allowedTools, serverConfig, security) {
|
|
|
6181
6634
|
let mcpServerStartTime = 0;
|
|
6182
6635
|
const proxyInstances = /* @__PURE__ */ new Set();
|
|
6183
6636
|
const rejectedVersionPeers = /* @__PURE__ */ new Set();
|
|
6184
|
-
const
|
|
6185
|
-
const
|
|
6637
|
+
const studioTransport = new WebSocketStudioTransport(bridge);
|
|
6638
|
+
const transportTokens = /* @__PURE__ */ new Map();
|
|
6639
|
+
const boundServers = /* @__PURE__ */ new Set();
|
|
6640
|
+
const webSocketServer = new WebSocketServer({
|
|
6641
|
+
noServer: true,
|
|
6642
|
+
clientTracking: false,
|
|
6643
|
+
perMessageDeflate: false,
|
|
6644
|
+
maxPayload: MAX_STUDIO_FRAME_BYTES
|
|
6645
|
+
});
|
|
6646
|
+
let closed = false;
|
|
6647
|
+
const unsubscribePeerClosed = bridge.onPeerClosed((peer) => {
|
|
6648
|
+
transportTokens.delete(peer.peerId);
|
|
6649
|
+
});
|
|
6186
6650
|
const setMCPServerActive = (active) => {
|
|
6187
6651
|
mcpServerActive = active;
|
|
6188
6652
|
if (active) {
|
|
@@ -6192,14 +6656,14 @@ function createHttpServer(tools, bridge, allowedTools, serverConfig, security) {
|
|
|
6192
6656
|
mcpServerStartTime = 0;
|
|
6193
6657
|
lastMCPActivity = 0;
|
|
6194
6658
|
}
|
|
6195
|
-
|
|
6659
|
+
studioTransport.refreshStatus();
|
|
6196
6660
|
};
|
|
6197
6661
|
const trackMCPActivity = () => {
|
|
6198
6662
|
if (mcpServerActive) {
|
|
6199
6663
|
const wasConnected = Date.now() - lastMCPActivity < 3e4;
|
|
6200
6664
|
lastMCPActivity = Date.now();
|
|
6201
6665
|
if (!wasConnected)
|
|
6202
|
-
|
|
6666
|
+
studioTransport.refreshStatus();
|
|
6203
6667
|
}
|
|
6204
6668
|
};
|
|
6205
6669
|
const isMCPServerActive = () => {
|
|
@@ -6223,6 +6687,67 @@ function createHttpServer(tools, bridge, allowedTools, serverConfig, security) {
|
|
|
6223
6687
|
return bridge.getPeers().length > 0;
|
|
6224
6688
|
};
|
|
6225
6689
|
const allowedOrigins = new Set(security?.allowedOrigins ?? []);
|
|
6690
|
+
const upgradeStudio = (req, socket, head) => {
|
|
6691
|
+
socket.on("error", () => socket.destroy());
|
|
6692
|
+
if (closed) {
|
|
6693
|
+
rejectStudioUpgrade(socket, 503, "server_shutdown");
|
|
6694
|
+
return;
|
|
6695
|
+
}
|
|
6696
|
+
let url;
|
|
6697
|
+
try {
|
|
6698
|
+
url = new URL(req.url ?? "/", "http://localhost");
|
|
6699
|
+
} catch {
|
|
6700
|
+
rejectStudioUpgrade(socket, 400, "invalid_websocket_url");
|
|
6701
|
+
return;
|
|
6702
|
+
}
|
|
6703
|
+
if (url.pathname !== "/studio") {
|
|
6704
|
+
rejectStudioUpgrade(socket, 404, "unknown_websocket_endpoint");
|
|
6705
|
+
return;
|
|
6706
|
+
}
|
|
6707
|
+
const origin = req.headers.origin;
|
|
6708
|
+
if (origin && !allowedOrigins.has(origin)) {
|
|
6709
|
+
rejectStudioUpgrade(socket, 403, "forbidden_origin");
|
|
6710
|
+
return;
|
|
6711
|
+
}
|
|
6712
|
+
if (req.method !== "GET" || url.searchParams.getAll("protocolVersion").length !== 1 || url.searchParams.get("protocolVersion") !== String(STUDIO_PROTOCOL_VERSION)) {
|
|
6713
|
+
rejectStudioUpgrade(socket, 426, "studio_protocol_mismatch");
|
|
6714
|
+
return;
|
|
6715
|
+
}
|
|
6716
|
+
const peerId = url.searchParams.get("peerId");
|
|
6717
|
+
if (!peerId || url.searchParams.getAll("peerId").length !== 1) {
|
|
6718
|
+
rejectStudioUpgrade(socket, 400, "missing_peer_id");
|
|
6719
|
+
return;
|
|
6720
|
+
}
|
|
6721
|
+
const peer = bridge.getPeerById(peerId);
|
|
6722
|
+
if (!peer) {
|
|
6723
|
+
rejectStudioUpgrade(socket, 404, "unknown_peer");
|
|
6724
|
+
return;
|
|
6725
|
+
}
|
|
6726
|
+
if (peer.transportPeerId !== peerId) {
|
|
6727
|
+
rejectStudioUpgrade(socket, 403, "peer_has_no_socket");
|
|
6728
|
+
return;
|
|
6729
|
+
}
|
|
6730
|
+
const token = transportTokens.get(peerId);
|
|
6731
|
+
const provided = req.headers["x-studio-token"];
|
|
6732
|
+
if (!token || typeof provided !== "string" || !tokensMatch(provided, token)) {
|
|
6733
|
+
rejectStudioUpgrade(socket, 401, "invalid_studio_token");
|
|
6734
|
+
return;
|
|
6735
|
+
}
|
|
6736
|
+
if (!studioTransport.canOpen(peerId)) {
|
|
6737
|
+
rejectStudioUpgrade(socket, 503, "studio_socket_capacity_reached");
|
|
6738
|
+
return;
|
|
6739
|
+
}
|
|
6740
|
+
webSocketServer.handleUpgrade(req, socket, head, (webSocket) => {
|
|
6741
|
+
webSocket.on("error", (error) => {
|
|
6742
|
+
if ("code" in error && error.code === "WS_ERR_UNSUPPORTED_MESSAGE_LENGTH") {
|
|
6743
|
+
console.error(`[studio-websocket] server_receive frame exceeds limitBytes=${MAX_STUDIO_FRAME_BYTES}`);
|
|
6744
|
+
}
|
|
6745
|
+
});
|
|
6746
|
+
const handle = studioTransport.open(peerId, webSocket, () => eventStatus(peerId));
|
|
6747
|
+
if (!handle)
|
|
6748
|
+
webSocket.close(1013, "studio_socket_capacity_reached");
|
|
6749
|
+
});
|
|
6750
|
+
};
|
|
6226
6751
|
app.use((req, res, next) => {
|
|
6227
6752
|
const origin = req.headers.origin;
|
|
6228
6753
|
if (typeof origin !== "string" || origin === "") {
|
|
@@ -6247,7 +6772,10 @@ function createHttpServer(tools, bridge, allowedTools, serverConfig, security) {
|
|
|
6247
6772
|
next();
|
|
6248
6773
|
});
|
|
6249
6774
|
const authToken = security?.authToken;
|
|
6250
|
-
const authRequired = (
|
|
6775
|
+
const authRequired = (requestPath) => {
|
|
6776
|
+
const path6 = requestPath.toLowerCase().replace(/\/+$/, "");
|
|
6777
|
+
return path6 === "/mcp" || path6.startsWith("/mcp/") || path6 === "/proxy" || path6 === "/topology" || path6 === "/request-status" || path6 === "/unregister-instance-id" || path6 === "/create-multiplayer-group" || path6 === "/remove-multiplayer-group";
|
|
6778
|
+
};
|
|
6251
6779
|
app.use((req, res, next) => {
|
|
6252
6780
|
if (!authToken || !authRequired(req.path)) {
|
|
6253
6781
|
next();
|
|
@@ -6265,8 +6793,31 @@ function createHttpServer(tools, bridge, allowedTools, serverConfig, security) {
|
|
|
6265
6793
|
message: 'Missing or invalid auth token. Send it as "X-MCP-Auth: <token>" or "Authorization: Bearer <token>". ' + (security?.authTokenHint ?? "The token is in ~/.robloxstudio-mcp/auth-token (or ROBLOX_STUDIO_AUTH_TOKEN).")
|
|
6266
6794
|
});
|
|
6267
6795
|
});
|
|
6268
|
-
app.use(express.json({ limit:
|
|
6269
|
-
app.use(express.urlencoded({ limit:
|
|
6796
|
+
app.use(express.json({ limit: HTTP_BODY_LIMIT_BYTES }));
|
|
6797
|
+
app.use(express.urlencoded({ limit: HTTP_BODY_LIMIT_BYTES, extended: true }));
|
|
6798
|
+
const handleBodySizeError = (error, _req, res, next) => {
|
|
6799
|
+
if (!error || typeof error !== "object" || !("type" in error) || error.type !== "entity.too.large") {
|
|
6800
|
+
next(error);
|
|
6801
|
+
return;
|
|
6802
|
+
}
|
|
6803
|
+
const bytes = "received" in error && typeof error.received === "number" ? error.received : "length" in error && typeof error.length === "number" ? error.length : void 0;
|
|
6804
|
+
if (bytes === void 0) {
|
|
6805
|
+
next(error);
|
|
6806
|
+
return;
|
|
6807
|
+
}
|
|
6808
|
+
res.status(413).json({
|
|
6809
|
+
error: `HTTP request body is ${bytes} bytes at http_receive; limit ${HTTP_BODY_LIMIT_BYTES} bytes; queued; not_executed`,
|
|
6810
|
+
code: "request_too_large",
|
|
6811
|
+
details: {
|
|
6812
|
+
bytes,
|
|
6813
|
+
limitBytes: HTTP_BODY_LIMIT_BYTES,
|
|
6814
|
+
stage: "queued",
|
|
6815
|
+
outcome: "not_executed",
|
|
6816
|
+
transportStage: "http_receive"
|
|
6817
|
+
}
|
|
6818
|
+
});
|
|
6819
|
+
};
|
|
6820
|
+
app.use(handleBodySizeError);
|
|
6270
6821
|
app.get("/health", (req, res) => {
|
|
6271
6822
|
const peers = bridge.getPublicPeers().map(toPassivePeer);
|
|
6272
6823
|
const instances = bridge.getPublicInstances().map(toPassiveInstance);
|
|
@@ -6296,7 +6847,8 @@ function createHttpServer(tools, bridge, allowedTools, serverConfig, security) {
|
|
|
6296
6847
|
uptime: mcpServerActive ? Date.now() - mcpServerStartTime : 0,
|
|
6297
6848
|
pendingRequests: bridge.getPendingRequestCount(),
|
|
6298
6849
|
proxyInstanceCount: proxyInstances.size,
|
|
6299
|
-
|
|
6850
|
+
activeWebSockets: studioTransport.activeSocketCount,
|
|
6851
|
+
studioSocketCapacity: MAX_ACTIVE_STUDIO_SOCKETS,
|
|
6300
6852
|
streamableHttp: !!serverConfig
|
|
6301
6853
|
});
|
|
6302
6854
|
});
|
|
@@ -6401,6 +6953,10 @@ function createHttpServer(tools, bridge, allowedTools, serverConfig, security) {
|
|
|
6401
6953
|
return;
|
|
6402
6954
|
}
|
|
6403
6955
|
}
|
|
6956
|
+
if (closed || !isProxiedPeer && !transportTokens.has(peerId) && transportTokens.size >= MAX_STUDIO_SESSIONS) {
|
|
6957
|
+
res.status(503).json({ success: false, error: closed ? "server_shutdown" : "studio_session_capacity_reached" });
|
|
6958
|
+
return;
|
|
6959
|
+
}
|
|
6404
6960
|
let result;
|
|
6405
6961
|
try {
|
|
6406
6962
|
result = bridge.registerPeer({
|
|
@@ -6437,14 +6993,20 @@ function createHttpServer(tools, bridge, allowedTools, serverConfig, security) {
|
|
|
6437
6993
|
});
|
|
6438
6994
|
return;
|
|
6439
6995
|
}
|
|
6440
|
-
|
|
6996
|
+
let transportToken;
|
|
6997
|
+
if (!isProxiedPeer) {
|
|
6998
|
+
transportToken = transportTokens.get(peerId) ?? randomBytes2(32).toString("hex");
|
|
6999
|
+
transportTokens.set(peerId, transportToken);
|
|
7000
|
+
}
|
|
7001
|
+
studioTransport.refreshStatus(transportPeerId);
|
|
6441
7002
|
res.json({
|
|
6442
7003
|
success: true,
|
|
6443
7004
|
assignedRole: result.assignedRole,
|
|
6444
7005
|
peerId: result.peerId,
|
|
6445
7006
|
instanceId: result.instanceId,
|
|
6446
7007
|
multiplayerGroupId: result.multiplayerGroupId,
|
|
6447
|
-
serverVersion
|
|
7008
|
+
serverVersion,
|
|
7009
|
+
...!isProxiedPeer ? { protocolVersion: STUDIO_PROTOCOL_VERSION, transportToken } : {}
|
|
6448
7010
|
});
|
|
6449
7011
|
});
|
|
6450
7012
|
app.post("/disconnect", (req, res) => {
|
|
@@ -6504,71 +7066,24 @@ function createHttpServer(tools, bridge, allowedTools, serverConfig, security) {
|
|
|
6504
7066
|
serverVersion: serverConfig?.version
|
|
6505
7067
|
});
|
|
6506
7068
|
});
|
|
6507
|
-
app.get("/events", (
|
|
6508
|
-
|
|
6509
|
-
|
|
6510
|
-
res.status(400).json({
|
|
6511
|
-
error: "missing_peer_id",
|
|
6512
|
-
message: "peerId is required"
|
|
6513
|
-
});
|
|
6514
|
-
return;
|
|
6515
|
-
}
|
|
6516
|
-
const peer = bridge.getPeerById(peerId);
|
|
6517
|
-
if (!peer) {
|
|
6518
|
-
res.status(404).json({
|
|
6519
|
-
error: "unknown_peer",
|
|
6520
|
-
knownPeer: false
|
|
6521
|
-
});
|
|
6522
|
-
return;
|
|
6523
|
-
}
|
|
6524
|
-
if (peer.transportPeerId !== peerId) {
|
|
6525
|
-
res.status(409).json({
|
|
6526
|
-
error: "peer_has_no_event_stream",
|
|
6527
|
-
transportPeerId: peer.transportPeerId
|
|
6528
|
-
});
|
|
6529
|
-
return;
|
|
6530
|
-
}
|
|
6531
|
-
if (!eventTransport.canOpen(peerId)) {
|
|
6532
|
-
res.setHeader("Retry-After", "1");
|
|
6533
|
-
res.status(503).json({
|
|
6534
|
-
error: "event_stream_capacity_reached",
|
|
6535
|
-
capacity: MAX_ACTIVE_EVENT_STREAMS
|
|
6536
|
-
});
|
|
6537
|
-
return;
|
|
6538
|
-
}
|
|
6539
|
-
bridge.updatePeerActivity(peerId);
|
|
6540
|
-
res.status(200);
|
|
6541
|
-
res.setHeader("Content-Type", "text/event-stream; charset=utf-8");
|
|
6542
|
-
res.setHeader("Cache-Control", "no-cache, no-transform");
|
|
6543
|
-
res.setHeader("Connection", "keep-alive");
|
|
6544
|
-
res.setHeader("X-Accel-Buffering", "no");
|
|
6545
|
-
res.flushHeaders();
|
|
6546
|
-
const handle = eventTransport.open(peerId, res, () => eventStatus(peerId));
|
|
6547
|
-
if (!handle) {
|
|
6548
|
-
res.end();
|
|
6549
|
-
return;
|
|
6550
|
-
}
|
|
6551
|
-
eventStreamHandles.add(handle);
|
|
6552
|
-
res.once("close", () => eventStreamHandles.delete(handle));
|
|
7069
|
+
app.get(["/studio", "/events"], (_req, res) => {
|
|
7070
|
+
res.setHeader("Upgrade", "websocket");
|
|
7071
|
+
res.status(426).json({ error: "studio_websocket_required", protocolVersion: STUDIO_PROTOCOL_VERSION });
|
|
6553
7072
|
});
|
|
6554
|
-
app.post("/response", (
|
|
6555
|
-
|
|
6556
|
-
|
|
6557
|
-
|
|
6558
|
-
|
|
6559
|
-
|
|
6560
|
-
|
|
6561
|
-
|
|
6562
|
-
}
|
|
6563
|
-
const disposition = error !== void 0 ? bridge.rejectRequest(requestId, error) : bridge.resolveRequest(requestId, response);
|
|
6564
|
-
if (disposition === "unknown") {
|
|
6565
|
-
res.status(404).json({ success: false, disposition });
|
|
7073
|
+
app.post("/response", (_req, res) => {
|
|
7074
|
+
res.setHeader("Upgrade", "websocket");
|
|
7075
|
+
res.status(426).json({ error: "studio_websocket_required", protocolVersion: STUDIO_PROTOCOL_VERSION });
|
|
7076
|
+
});
|
|
7077
|
+
app.get("/request-status", (req, res) => {
|
|
7078
|
+
const requestId = req.query.requestId;
|
|
7079
|
+
if (typeof requestId !== "string" || requestId.length === 0 || requestId.length > 1024) {
|
|
7080
|
+
res.status(400).json({ error: "invalid_request_id" });
|
|
6566
7081
|
return;
|
|
6567
7082
|
}
|
|
6568
|
-
res.json({
|
|
7083
|
+
res.json({ status: bridge.getRequestStatus(requestId) ?? null });
|
|
6569
7084
|
});
|
|
6570
7085
|
app.post("/proxy", async (req, res) => {
|
|
6571
|
-
const { endpoint, data, targetPeerId, proxyInstanceId, timeoutMs } = req.body;
|
|
7086
|
+
const { endpoint, data, targetPeerId, proxyInstanceId, timeoutMs, operationId } = req.body;
|
|
6572
7087
|
if (!endpoint || !targetPeerId) {
|
|
6573
7088
|
res.status(400).json({ error: "endpoint and targetPeerId are required" });
|
|
6574
7089
|
return;
|
|
@@ -6580,17 +7095,22 @@ function createHttpServer(tools, bridge, allowedTools, serverConfig, security) {
|
|
|
6580
7095
|
if (proxyInstanceId) {
|
|
6581
7096
|
proxyInstances.add(proxyInstanceId);
|
|
6582
7097
|
}
|
|
7098
|
+
if (operationId !== void 0 && (typeof operationId !== "string" || operationId.trim().length === 0 || operationId.length > 128)) {
|
|
7099
|
+
res.status(400).json({ error: "operationId must be a non-empty string of at most 128 characters" });
|
|
7100
|
+
return;
|
|
7101
|
+
}
|
|
6583
7102
|
const controller = new AbortController();
|
|
6584
7103
|
const abort = () => controller.abort();
|
|
6585
7104
|
req.once("aborted", abort);
|
|
6586
7105
|
res.once("close", abort);
|
|
6587
7106
|
try {
|
|
6588
|
-
const response = await bridge.sendRequest(endpoint, data, targetPeerId, timeoutMs, controller.signal);
|
|
7107
|
+
const response = await bridge.sendRequest(endpoint, data, targetPeerId, timeoutMs, controller.signal, operationId);
|
|
6589
7108
|
res.json({ response });
|
|
6590
7109
|
} catch (error) {
|
|
6591
7110
|
if (!res.headersSent && !res.destroyed) {
|
|
6592
7111
|
res.status(500).json({
|
|
6593
|
-
error: error instanceof Error ? error.message : "Proxy request failed"
|
|
7112
|
+
error: error instanceof Error ? error.message : error === void 0 ? "Proxy request failed" : error,
|
|
7113
|
+
...error instanceof RequestFailure ? { code: error.code, details: error.details } : {}
|
|
6594
7114
|
});
|
|
6595
7115
|
}
|
|
6596
7116
|
} finally {
|
|
@@ -6642,11 +7162,29 @@ function createHttpServer(tools, bridge, allowedTools, serverConfig, security) {
|
|
|
6642
7162
|
app.isMCPServerActive = isMCPServerActive;
|
|
6643
7163
|
app.trackMCPActivity = trackMCPActivity;
|
|
6644
7164
|
app.closeMcpHandler = () => mcpHandler?.close();
|
|
7165
|
+
app.attachStudioTransport = (server) => {
|
|
7166
|
+
if (closed)
|
|
7167
|
+
throw new Error("Cannot attach a closed Studio transport");
|
|
7168
|
+
if (boundServers.has(server))
|
|
7169
|
+
return;
|
|
7170
|
+
boundServers.add(server);
|
|
7171
|
+
server.on("upgrade", upgradeStudio);
|
|
7172
|
+
server.once("close", () => {
|
|
7173
|
+
boundServers.delete(server);
|
|
7174
|
+
server.removeListener("upgrade", upgradeStudio);
|
|
7175
|
+
});
|
|
7176
|
+
};
|
|
6645
7177
|
app.cleanup = async () => {
|
|
6646
|
-
|
|
6647
|
-
|
|
6648
|
-
|
|
6649
|
-
|
|
7178
|
+
if (closed)
|
|
7179
|
+
return;
|
|
7180
|
+
closed = true;
|
|
7181
|
+
for (const server of boundServers)
|
|
7182
|
+
server.removeListener("upgrade", upgradeStudio);
|
|
7183
|
+
boundServers.clear();
|
|
7184
|
+
unsubscribePeerClosed();
|
|
7185
|
+
transportTokens.clear();
|
|
7186
|
+
studioTransport.close();
|
|
7187
|
+
webSocketServer.close();
|
|
6650
7188
|
await mcpHandler?.close();
|
|
6651
7189
|
};
|
|
6652
7190
|
return app;
|
|
@@ -6668,18 +7206,26 @@ async function listenWithRetry(app, host, startPort, maxAttempts = 5) {
|
|
|
6668
7206
|
throw new Error(`All ports ${startPort}-${startPort + maxAttempts - 1} are in use. Stop some MCP server instances and retry.`);
|
|
6669
7207
|
}
|
|
6670
7208
|
function bindPort(app, host, port) {
|
|
6671
|
-
|
|
6672
|
-
|
|
6673
|
-
|
|
6674
|
-
|
|
6675
|
-
|
|
6676
|
-
|
|
6677
|
-
|
|
6678
|
-
|
|
6679
|
-
|
|
7209
|
+
const { promise, resolve: resolve5, reject } = Promise.withResolvers();
|
|
7210
|
+
const server = http.createServer(app);
|
|
7211
|
+
const onError = (err2) => {
|
|
7212
|
+
server.removeListener("error", onError);
|
|
7213
|
+
reject(err2);
|
|
7214
|
+
};
|
|
7215
|
+
server.once("error", onError);
|
|
7216
|
+
server.listen(port, host, () => {
|
|
7217
|
+
server.removeListener("error", onError);
|
|
7218
|
+
try {
|
|
7219
|
+
if ("attachStudioTransport" in app && typeof app.attachStudioTransport === "function") {
|
|
7220
|
+
app.attachStudioTransport(server);
|
|
7221
|
+
}
|
|
6680
7222
|
resolve5(server);
|
|
6681
|
-
})
|
|
7223
|
+
} catch (error) {
|
|
7224
|
+
server.close();
|
|
7225
|
+
reject(error);
|
|
7226
|
+
}
|
|
6682
7227
|
});
|
|
7228
|
+
return promise;
|
|
6683
7229
|
}
|
|
6684
7230
|
|
|
6685
7231
|
// ../core/dist/tools/studio-client.js
|
|
@@ -6688,15 +7234,8 @@ var StudioHttpClient = class {
|
|
|
6688
7234
|
constructor(bridge) {
|
|
6689
7235
|
this.bridge = bridge;
|
|
6690
7236
|
}
|
|
6691
|
-
async request(endpoint, data, targetPeerId, timeoutMs, signal) {
|
|
6692
|
-
|
|
6693
|
-
return await this.bridge.sendRequest(endpoint, data, targetPeerId, timeoutMs, signal);
|
|
6694
|
-
} catch (error) {
|
|
6695
|
-
if (error instanceof Error && error.message === "Request timeout") {
|
|
6696
|
-
throw new Error("Studio plugin connection timeout. Make sure the Roblox Studio plugin is running and activated.");
|
|
6697
|
-
}
|
|
6698
|
-
throw error;
|
|
6699
|
-
}
|
|
7237
|
+
async request(endpoint, data, targetPeerId, timeoutMs, signal, operationId) {
|
|
7238
|
+
return this.bridge.sendRequest(endpoint, data, targetPeerId, timeoutMs, signal, operationId);
|
|
6700
7239
|
}
|
|
6701
7240
|
};
|
|
6702
7241
|
|
|
@@ -7521,7 +8060,7 @@ function decodeImagePathToRgba(imagePath) {
|
|
|
7521
8060
|
}
|
|
7522
8061
|
|
|
7523
8062
|
// ../core/dist/studio-skills.js
|
|
7524
|
-
import { createHash as
|
|
8063
|
+
import { createHash as createHash3 } from "crypto";
|
|
7525
8064
|
import { existsSync as existsSync4, readFileSync as readFileSync4, readdirSync as readdirSync2, statSync as statSync2 } from "fs";
|
|
7526
8065
|
import * as path4 from "path";
|
|
7527
8066
|
|
|
@@ -8406,7 +8945,7 @@ function parseBuiltInStudioSkills(buffer) {
|
|
|
8406
8945
|
hasCombinedDocument: group.combined !== void 0,
|
|
8407
8946
|
content: selected.content,
|
|
8408
8947
|
contentLength: Buffer.byteLength(selected.content, "utf8"),
|
|
8409
|
-
contentSha256:
|
|
8948
|
+
contentSha256: createHash3("sha256").update(selected.content).digest("hex")
|
|
8410
8949
|
};
|
|
8411
8950
|
}).sort((left, right) => left.name.localeCompare(right.name));
|
|
8412
8951
|
}
|
|
@@ -8473,7 +9012,7 @@ function loadBuiltInStudioSkills(bundlePath = resolveAssistantBundlePath()) {
|
|
|
8473
9012
|
const value = {
|
|
8474
9013
|
bundlePath,
|
|
8475
9014
|
bundleModifiedAt: stats.mtime.toISOString(),
|
|
8476
|
-
bundleSha256:
|
|
9015
|
+
bundleSha256: createHash3("sha256").update(buffer).digest("hex"),
|
|
8477
9016
|
studioVersion,
|
|
8478
9017
|
skills
|
|
8479
9018
|
};
|
|
@@ -10344,8 +10883,8 @@ var RobloxStudioTools = class {
|
|
|
10344
10883
|
_peerForRoleInScope(instanceId, role) {
|
|
10345
10884
|
return this.bridge.getPeersInScope(instanceId).find((peer) => peer.role === role);
|
|
10346
10885
|
}
|
|
10347
|
-
_requestPeer(endpoint, data, targetPeerId, timeoutMs, signal) {
|
|
10348
|
-
return this.client.request(endpoint, data, targetPeerId, timeoutMs, signal);
|
|
10886
|
+
_requestPeer(endpoint, data, targetPeerId, timeoutMs, signal, operationId) {
|
|
10887
|
+
return this.client.request(endpoint, data, targetPeerId, timeoutMs, signal, operationId);
|
|
10349
10888
|
}
|
|
10350
10889
|
_request(endpoint, data, instanceId, role, timeoutMs, signal) {
|
|
10351
10890
|
const peer = this._peerForRoleInScope(instanceId, role);
|
|
@@ -10359,7 +10898,7 @@ var RobloxStudioTools = class {
|
|
|
10359
10898
|
return this._requestPeer(endpoint, data, peer.peerId, timeoutMs, signal);
|
|
10360
10899
|
}
|
|
10361
10900
|
// Resolve an optional Studio process plus role to one exact Peer and dispatch.
|
|
10362
|
-
async _callSingle(endpoint, data, target, instance_id, timeoutMs, signal) {
|
|
10901
|
+
async _callSingle(endpoint, data, target, instance_id, timeoutMs, signal, operationId) {
|
|
10363
10902
|
const resolved = this.bridge.resolveTarget({ instance_id, target });
|
|
10364
10903
|
if (!resolved.ok)
|
|
10365
10904
|
throw new RoutingFailure(resolved.error);
|
|
@@ -10370,7 +10909,7 @@ var RobloxStudioTools = class {
|
|
|
10370
10909
|
data: this._routingErrorData()
|
|
10371
10910
|
});
|
|
10372
10911
|
}
|
|
10373
|
-
return this._requestPeer(endpoint, data, resolved.targetPeerId, timeoutMs, signal);
|
|
10912
|
+
return this._requestPeer(endpoint, data, resolved.targetPeerId, timeoutMs, signal, operationId);
|
|
10374
10913
|
}
|
|
10375
10914
|
// Prefer the first client role in the selected process/group scope for live
|
|
10376
10915
|
// viewport and input operations; otherwise retain the default Peer's Instance.
|
|
@@ -10769,11 +11308,11 @@ var RobloxStudioTools = class {
|
|
|
10769
11308
|
]
|
|
10770
11309
|
};
|
|
10771
11310
|
}
|
|
10772
|
-
async setProperties(instancePath, properties, instance_id) {
|
|
11311
|
+
async setProperties(instancePath, properties, instance_id, operation_id) {
|
|
10773
11312
|
if (!instancePath || !properties) {
|
|
10774
11313
|
throw new Error("instancePath and properties are required for set_properties");
|
|
10775
11314
|
}
|
|
10776
|
-
const response = await this._callSingle("/api/set-properties", { instancePath, properties }, void 0, instance_id);
|
|
11315
|
+
const response = await this._callSingle("/api/set-properties", { instancePath, properties }, void 0, instance_id, void 0, void 0, operation_id);
|
|
10777
11316
|
return { content: [{ type: "text", text: JSON.stringify(response) }] };
|
|
10778
11317
|
}
|
|
10779
11318
|
async getScriptSource(instancePath, startLine, endLine, instance_id) {
|
|
@@ -10903,9 +11442,6 @@ var RobloxStudioTools = class {
|
|
|
10903
11442
|
}
|
|
10904
11443
|
return this.setSelection(opts.paths, opts.mode, instance_id);
|
|
10905
11444
|
}
|
|
10906
|
-
if (!opts.path) {
|
|
10907
|
-
throw new Error("selection action=view requires path");
|
|
10908
|
-
}
|
|
10909
11445
|
return this.focusViewport(opts.path, opts.from, opts.padding, opts.angleY, instance_id);
|
|
10910
11446
|
}
|
|
10911
11447
|
async getSelection(instance_id) {
|
|
@@ -10938,8 +11474,8 @@ var RobloxStudioTools = class {
|
|
|
10938
11474
|
};
|
|
10939
11475
|
}
|
|
10940
11476
|
async focusViewport(instancePath, from, padding, angleY, instance_id) {
|
|
10941
|
-
if (
|
|
10942
|
-
throw new Error("selection
|
|
11477
|
+
if (instancePath !== void 0 && (typeof instancePath !== "string" || instancePath.length === 0)) {
|
|
11478
|
+
throw new Error("selection path must be a non-empty instance path when provided");
|
|
10943
11479
|
}
|
|
10944
11480
|
if (padding !== void 0 && (padding <= 0 || padding > 10)) {
|
|
10945
11481
|
throw new Error("selection padding must be greater than 0 and at most 10");
|
|
@@ -10963,11 +11499,11 @@ var RobloxStudioTools = class {
|
|
|
10963
11499
|
]
|
|
10964
11500
|
};
|
|
10965
11501
|
}
|
|
10966
|
-
async executeLuau(code, target, instance_id) {
|
|
11502
|
+
async executeLuau(code, target, instance_id, operation_id) {
|
|
10967
11503
|
if (!code) {
|
|
10968
11504
|
throw new Error("Code is required for execute_luau");
|
|
10969
11505
|
}
|
|
10970
|
-
const response = await this._callSingle("/api/execute-luau", { code }, target || "edit", instance_id);
|
|
11506
|
+
const response = await this._callSingle("/api/execute-luau", { code }, target || "edit", instance_id, void 0, void 0, operation_id);
|
|
10971
11507
|
return {
|
|
10972
11508
|
content: [
|
|
10973
11509
|
{
|
|
@@ -11378,6 +11914,9 @@ var RobloxStudioTools = class {
|
|
|
11378
11914
|
if (tail !== void 0 && (!Number.isInteger(tail) || tail < 0)) {
|
|
11379
11915
|
throw new Error("get_runtime_logs tail must be a non-negative integer.");
|
|
11380
11916
|
}
|
|
11917
|
+
const refresh = this.bridge.refreshTopologyForRouting(signal);
|
|
11918
|
+
if (refresh)
|
|
11919
|
+
await refresh;
|
|
11381
11920
|
const instances = this.bridge.getInstances();
|
|
11382
11921
|
const groups = this.bridge.getMultiplayerGroups();
|
|
11383
11922
|
let selectedGroup = multiplayer_group_id === void 0 ? void 0 : groups.find((group) => group.id === multiplayer_group_id);
|
|
@@ -12098,6 +12637,8 @@ var RobloxStudioTools = class {
|
|
|
12098
12637
|
});
|
|
12099
12638
|
}
|
|
12100
12639
|
return this._textResult({
|
|
12640
|
+
// Keep lifecycle diagnostics on failures; only successful responses are brief.
|
|
12641
|
+
...body2,
|
|
12101
12642
|
success: false,
|
|
12102
12643
|
action,
|
|
12103
12644
|
error: body2.error ?? "start_failed",
|
|
@@ -12114,6 +12655,7 @@ var RobloxStudioTools = class {
|
|
|
12114
12655
|
});
|
|
12115
12656
|
}
|
|
12116
12657
|
return this._textResult({
|
|
12658
|
+
...body,
|
|
12117
12659
|
success: false,
|
|
12118
12660
|
action,
|
|
12119
12661
|
error: body.error ?? "stop_failed",
|
|
@@ -12202,7 +12744,7 @@ var RobloxStudioTools = class {
|
|
|
12202
12744
|
wait = {
|
|
12203
12745
|
ok: false,
|
|
12204
12746
|
roles: this._rolesForScope(instanceId),
|
|
12205
|
-
timedOut:
|
|
12747
|
+
timedOut: response.timedOut === true
|
|
12206
12748
|
};
|
|
12207
12749
|
}
|
|
12208
12750
|
const body = wait ? {
|
|
@@ -12217,8 +12759,8 @@ var RobloxStudioTools = class {
|
|
|
12217
12759
|
...body,
|
|
12218
12760
|
success: false,
|
|
12219
12761
|
error: "Playtest teardown did not complete.",
|
|
12220
|
-
message: response?.success === true ? wait.timedOut ? "Stop signal was accepted, but runtime peers did not disconnect before timeout." : "Stop signal was accepted, but runtime peers are still connected." : "Edit stop request failed, and runtime peers are still connected.",
|
|
12221
|
-
stopSignalAccepted: response?.success === true,
|
|
12762
|
+
message: response.stopSignalAccepted === true && typeof response.message === "string" ? response.message : response?.success === true ? wait.timedOut ? "Stop signal was accepted, but runtime peers did not disconnect before timeout." : "Stop signal was accepted, but runtime peers are still connected." : "Edit stop request failed, and runtime peers are still connected.",
|
|
12763
|
+
stopSignalAccepted: response?.success === true || response.stopSignalAccepted === true,
|
|
12222
12764
|
stopRequestError,
|
|
12223
12765
|
runtimeRoles,
|
|
12224
12766
|
possibleCause: "A game shutdown hook such as BindToClose may be blocking Studio teardown. No runtime hard-stop or synthetic keyboard fallback was attempted."
|
|
@@ -12596,6 +13138,20 @@ var RobloxStudioTools = class {
|
|
|
12596
13138
|
multiplayerGroups: this.bridge.getConnectedMultiplayerGroups()
|
|
12597
13139
|
});
|
|
12598
13140
|
}
|
|
13141
|
+
async getRequestStatus(request_id) {
|
|
13142
|
+
if (typeof request_id !== "string" || request_id.length === 0 || request_id.length > 128) {
|
|
13143
|
+
throw new Error("request_id must contain between 1 and 128 characters");
|
|
13144
|
+
}
|
|
13145
|
+
const status = await this.bridge.getRequestStatusEverywhere(request_id);
|
|
13146
|
+
if (status)
|
|
13147
|
+
return this._textResult({ ...status });
|
|
13148
|
+
return this._textResult({
|
|
13149
|
+
requestId: request_id,
|
|
13150
|
+
state: "unknown",
|
|
13151
|
+
outcome: "unknown",
|
|
13152
|
+
message: "No retained operation record. It may have expired, been evicted, or belonged to an earlier server session. Do not infer that the mutation was not executed."
|
|
13153
|
+
});
|
|
13154
|
+
}
|
|
12599
13155
|
// === Asset Tools ===
|
|
12600
13156
|
async searchAssets(assetType, query, maxResults, sortBy, robloxCreatedOnly) {
|
|
12601
13157
|
const normalized = normalizeCreatorStoreSearch(assetType, query);
|
|
@@ -13438,6 +13994,42 @@ var RobloxStudioTools = class {
|
|
|
13438
13994
|
// ../core/dist/proxy-bridge-service.js
|
|
13439
13995
|
import { randomUUID as randomUUID4 } from "crypto";
|
|
13440
13996
|
var PROXY_RESPONSE_GRACE_MS = 5e3;
|
|
13997
|
+
function parseRequestStatus(value) {
|
|
13998
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
13999
|
+
throw new Error("Proxy returned an invalid request status");
|
|
14000
|
+
if (!("requestId" in value) || typeof value.requestId !== "string" || !("targetPeerId" in value) || typeof value.targetPeerId !== "string" || !("queuedAt" in value) || typeof value.queuedAt !== "number" || !("stage" in value) || !isRequestStage(value.stage) || !("state" in value) || value.state !== "pending" && value.state !== "timed_out" && value.state !== "aborted" && value.state !== "disconnected" && value.state !== "settled" || !("outcome" in value) || value.outcome !== "pending" && value.outcome !== "not_executed" && value.outcome !== "unknown" && value.outcome !== "success" && value.outcome !== "error")
|
|
14001
|
+
throw new Error("Proxy returned an invalid request status");
|
|
14002
|
+
const status = {
|
|
14003
|
+
requestId: value.requestId,
|
|
14004
|
+
targetPeerId: value.targetPeerId,
|
|
14005
|
+
queuedAt: value.queuedAt,
|
|
14006
|
+
stage: value.stage,
|
|
14007
|
+
state: value.state,
|
|
14008
|
+
outcome: value.outcome,
|
|
14009
|
+
...parseObservations(value)
|
|
14010
|
+
};
|
|
14011
|
+
if ("dispatchedAt" in value && typeof value.dispatchedAt === "number")
|
|
14012
|
+
status.dispatchedAt = value.dispatchedAt;
|
|
14013
|
+
if ("settledAt" in value && typeof value.settledAt === "number")
|
|
14014
|
+
status.settledAt = value.settledAt;
|
|
14015
|
+
if ("waiterEndedAt" in value && typeof value.waiterEndedAt === "number")
|
|
14016
|
+
status.waiterEndedAt = value.waiterEndedAt;
|
|
14017
|
+
if ("response" in value)
|
|
14018
|
+
status.response = value.response;
|
|
14019
|
+
if ("error" in value)
|
|
14020
|
+
status.error = value.error;
|
|
14021
|
+
if ("resultUnavailable" in value) {
|
|
14022
|
+
const unavailable = value.resultUnavailable;
|
|
14023
|
+
if (!unavailable || typeof unavailable !== "object" || !("reason" in unavailable) || unavailable.reason !== "size_limit" && unavailable.reason !== "serialization_failed" && unavailable.reason !== "retention_capacity" || !("limitBytes" in unavailable) || typeof unavailable.limitBytes !== "number")
|
|
14024
|
+
throw new Error("Proxy returned invalid result availability");
|
|
14025
|
+
status.resultUnavailable = {
|
|
14026
|
+
reason: unavailable.reason,
|
|
14027
|
+
limitBytes: unavailable.limitBytes,
|
|
14028
|
+
..."bytes" in unavailable && typeof unavailable.bytes === "number" ? { bytes: unavailable.bytes } : {}
|
|
14029
|
+
};
|
|
14030
|
+
}
|
|
14031
|
+
return status;
|
|
14032
|
+
}
|
|
13441
14033
|
function peerPublicationChanged(previous, current) {
|
|
13442
14034
|
return previous === void 0 || previous.transportPeerId !== current.transportPeerId || previous.instanceId !== current.instanceId || previous.multiplayerGroupId !== current.multiplayerGroupId || previous.role !== current.role || previous.placeId !== current.placeId || previous.placeName !== current.placeName || previous.placeKey !== current.placeKey || previous.dataModelName !== current.dataModelName || previous.isRunning !== current.isRunning || previous.pluginVersion !== current.pluginVersion || previous.pluginVariant !== current.pluginVariant || previous.serverVersion !== current.serverVersion;
|
|
13443
14035
|
}
|
|
@@ -13452,6 +14044,9 @@ var ProxyBridgeService = class _ProxyBridgeService extends BridgeService {
|
|
|
13452
14044
|
initialRefresh;
|
|
13453
14045
|
refreshTimer;
|
|
13454
14046
|
static REFRESH_INTERVAL_MS = 1e3;
|
|
14047
|
+
static TOPOLOGY_TIMEOUT_MS = 2e3;
|
|
14048
|
+
topologyGeneration = 0;
|
|
14049
|
+
appliedTopologyGeneration = 0;
|
|
13455
14050
|
constructor(primaryBaseUrl, authToken) {
|
|
13456
14051
|
super();
|
|
13457
14052
|
this.primaryBaseUrl = primaryBaseUrl;
|
|
@@ -13469,17 +14064,33 @@ var ProxyBridgeService = class _ProxyBridgeService extends BridgeService {
|
|
|
13469
14064
|
headers["X-MCP-Auth"] = this.authToken;
|
|
13470
14065
|
return headers;
|
|
13471
14066
|
}
|
|
13472
|
-
|
|
14067
|
+
refreshTopologyForRouting(signal) {
|
|
14068
|
+
return this.refreshTopology(signal, true);
|
|
14069
|
+
}
|
|
14070
|
+
async refreshTopology(signal, requireFresh = false) {
|
|
14071
|
+
const generation = ++this.topologyGeneration;
|
|
14072
|
+
const controller = new AbortController();
|
|
14073
|
+
const abort = () => controller.abort(signal?.reason);
|
|
14074
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
14075
|
+
if (signal?.aborted)
|
|
14076
|
+
abort();
|
|
14077
|
+
const timeout = setTimeout(() => controller.abort(new Error("Studio topology refresh timed out")), _ProxyBridgeService.TOPOLOGY_TIMEOUT_MS);
|
|
13473
14078
|
try {
|
|
14079
|
+
controller.signal.throwIfAborted();
|
|
13474
14080
|
const res = await fetch(`${this.primaryBaseUrl}/topology`, {
|
|
13475
|
-
headers: this.authHeaders()
|
|
14081
|
+
headers: this.authHeaders(),
|
|
14082
|
+
signal: controller.signal
|
|
13476
14083
|
});
|
|
13477
14084
|
if (!res.ok)
|
|
13478
|
-
|
|
14085
|
+
throw new Error(`Studio topology returned HTTP ${res.status}`);
|
|
13479
14086
|
const body = await res.json();
|
|
13480
|
-
|
|
13481
|
-
|
|
14087
|
+
controller.signal.throwIfAborted();
|
|
14088
|
+
if (!body || !Array.isArray(body.peers) || !Array.isArray(body.instances) || !Array.isArray(body.multiplayerGroups)) {
|
|
14089
|
+
throw new Error("Primary returned invalid Studio topology");
|
|
13482
14090
|
}
|
|
14091
|
+
if (generation < this.appliedTopologyGeneration)
|
|
14092
|
+
return;
|
|
14093
|
+
this.appliedTopologyGeneration = generation;
|
|
13483
14094
|
const previousPeers = new Map(this.cachedPeers.map((peer) => [peer.peerId, peer]));
|
|
13484
14095
|
this.cachedPeers = body.peers;
|
|
13485
14096
|
this.cachedInstances = body.instances;
|
|
@@ -13489,7 +14100,12 @@ var ProxyBridgeService = class _ProxyBridgeService extends BridgeService {
|
|
|
13489
14100
|
this.notifyPeerRegistered(toPublicPeer(peer));
|
|
13490
14101
|
}
|
|
13491
14102
|
}
|
|
13492
|
-
} catch {
|
|
14103
|
+
} catch (error) {
|
|
14104
|
+
if (requireFresh)
|
|
14105
|
+
throw error;
|
|
14106
|
+
} finally {
|
|
14107
|
+
clearTimeout(timeout);
|
|
14108
|
+
signal?.removeEventListener("abort", abort);
|
|
13493
14109
|
}
|
|
13494
14110
|
}
|
|
13495
14111
|
getPeers() {
|
|
@@ -13590,16 +14206,52 @@ var ProxyBridgeService = class _ProxyBridgeService extends BridgeService {
|
|
|
13590
14206
|
/** Called when this proxy is being discarded (e.g. promotion to primary
|
|
13591
14207
|
replaced it). Stops the background refresh so it doesn't leak. */
|
|
13592
14208
|
stop() {
|
|
13593
|
-
|
|
13594
|
-
|
|
13595
|
-
|
|
13596
|
-
|
|
14209
|
+
clearInterval(this.refreshTimer);
|
|
14210
|
+
this.refreshTimer = void 0;
|
|
14211
|
+
}
|
|
14212
|
+
async getRequestStatusEverywhere(requestId) {
|
|
14213
|
+
const response = await fetch(`${this.primaryBaseUrl}/request-status?requestId=${encodeURIComponent(requestId)}`, {
|
|
14214
|
+
headers: this.authHeaders()
|
|
14215
|
+
});
|
|
14216
|
+
if (!response.ok)
|
|
14217
|
+
throw new Error(`Proxy request status failed (${response.status})`);
|
|
14218
|
+
const body = await response.json();
|
|
14219
|
+
if (!body || typeof body !== "object" || !("status" in body))
|
|
14220
|
+
throw new Error("Proxy returned an invalid request status response");
|
|
14221
|
+
if (body.status === null)
|
|
14222
|
+
return void 0;
|
|
14223
|
+
const status = parseRequestStatus(body.status);
|
|
14224
|
+
if (status.requestId !== requestId)
|
|
14225
|
+
throw new Error("Proxy returned status for a different request");
|
|
14226
|
+
return status;
|
|
13597
14227
|
}
|
|
13598
|
-
async sendRequest(endpoint, data, targetPeerId, timeoutMs = this.proxyRequestTimeout, signal) {
|
|
14228
|
+
async sendRequest(endpoint, data, targetPeerId, timeoutMs = this.proxyRequestTimeout, signal, operationId) {
|
|
14229
|
+
const requestId = operationId ?? randomUUID4();
|
|
14230
|
+
const details = { requestId, targetPeerId, stage: "queued", outcome: "not_executed", executionOutcome: "not_executed" };
|
|
14231
|
+
if (typeof requestId !== "string" || requestId.trim().length === 0 || requestId.length > 128) {
|
|
14232
|
+
throw new RequestFailure("operationId must be a nonempty string of at most 128 characters", "invalid_operation_id", details);
|
|
14233
|
+
}
|
|
13599
14234
|
if (signal?.aborted)
|
|
13600
|
-
throw new
|
|
14235
|
+
throw new RequestFailure(`Request aborted: ${requestId}; queued; not_executed`, "request_aborted", details);
|
|
13601
14236
|
const controller = new AbortController();
|
|
13602
14237
|
const effectiveTimeoutMs = Math.max(1, timeoutMs);
|
|
14238
|
+
let requestBody;
|
|
14239
|
+
try {
|
|
14240
|
+
requestBody = JSON.stringify({
|
|
14241
|
+
endpoint,
|
|
14242
|
+
data,
|
|
14243
|
+
targetPeerId,
|
|
14244
|
+
proxyInstanceId: this.proxyInstanceId,
|
|
14245
|
+
timeoutMs: effectiveTimeoutMs,
|
|
14246
|
+
operationId: requestId
|
|
14247
|
+
});
|
|
14248
|
+
} catch {
|
|
14249
|
+
throw new RequestFailure(`Request ${requestId} cannot be serialized at proxy admission; queued; not_executed`, "request_serialization_failed", details);
|
|
14250
|
+
}
|
|
14251
|
+
const requestBytes = Buffer.byteLength(requestBody);
|
|
14252
|
+
if (requestBytes > HTTP_BODY_LIMIT_BYTES) {
|
|
14253
|
+
throw new RequestFailure(`Request ${requestId} is ${requestBytes} bytes at proxy_send; limit ${HTTP_BODY_LIMIT_BYTES} bytes; queued; not_executed`, "request_too_large", { ...details, bytes: requestBytes, limitBytes: HTTP_BODY_LIMIT_BYTES, transportStage: "proxy_send" });
|
|
14254
|
+
}
|
|
13603
14255
|
let timedOut = false;
|
|
13604
14256
|
const abortFromCaller = () => controller.abort();
|
|
13605
14257
|
signal?.addEventListener("abort", abortFromCaller, { once: true });
|
|
@@ -13609,39 +14261,54 @@ var ProxyBridgeService = class _ProxyBridgeService extends BridgeService {
|
|
|
13609
14261
|
timedOut = true;
|
|
13610
14262
|
controller.abort();
|
|
13611
14263
|
}, effectiveTimeoutMs + PROXY_RESPONSE_GRACE_MS);
|
|
14264
|
+
let primaryError = false;
|
|
13612
14265
|
try {
|
|
13613
14266
|
const response = await fetch(`${this.primaryBaseUrl}/proxy`, {
|
|
13614
14267
|
method: "POST",
|
|
13615
14268
|
headers: this.authHeaders({ "Content-Type": "application/json" }),
|
|
13616
|
-
body:
|
|
13617
|
-
endpoint,
|
|
13618
|
-
data,
|
|
13619
|
-
targetPeerId,
|
|
13620
|
-
proxyInstanceId: this.proxyInstanceId,
|
|
13621
|
-
timeoutMs: effectiveTimeoutMs
|
|
13622
|
-
}),
|
|
14269
|
+
body: requestBody,
|
|
13623
14270
|
signal: controller.signal
|
|
13624
14271
|
});
|
|
13625
|
-
|
|
13626
|
-
|
|
13627
|
-
|
|
14272
|
+
const body = await response.text();
|
|
14273
|
+
let result;
|
|
14274
|
+
try {
|
|
14275
|
+
result = JSON.parse(body);
|
|
14276
|
+
} catch {
|
|
14277
|
+
throw new Error(`Proxy request failed (${response.status}): ${body || response.statusText}`);
|
|
13628
14278
|
}
|
|
13629
|
-
const result = await response.json();
|
|
13630
14279
|
if (!result || typeof result !== "object" || Array.isArray(result)) {
|
|
13631
14280
|
throw new Error("Proxy returned an invalid response");
|
|
13632
14281
|
}
|
|
13633
|
-
if ("error" in result &&
|
|
13634
|
-
|
|
14282
|
+
if ("error" in result && result.error !== void 0 && typeof result.error !== "string") {
|
|
14283
|
+
primaryError = true;
|
|
14284
|
+
throw result.error;
|
|
14285
|
+
}
|
|
14286
|
+
if ("error" in result && typeof result.error === "string") {
|
|
14287
|
+
const details2 = "details" in result ? parseFailureDetails(result.details, { requestId, targetPeerId }) : void 0;
|
|
14288
|
+
if (details2 && "code" in result && typeof result.code === "string") {
|
|
14289
|
+
throw new RequestFailure(result.error, result.code, details2);
|
|
14290
|
+
}
|
|
14291
|
+
throw new RequestFailure(result.error, "studio_response_error", {
|
|
14292
|
+
requestId,
|
|
14293
|
+
targetPeerId,
|
|
14294
|
+
stage: "dispatched",
|
|
14295
|
+
outcome: "unknown",
|
|
14296
|
+
executionOutcome: "unknown"
|
|
14297
|
+
});
|
|
14298
|
+
}
|
|
14299
|
+
if (!response.ok) {
|
|
14300
|
+
throw new Error(`Proxy request failed (${response.status}): ${body || response.statusText}`);
|
|
13635
14301
|
}
|
|
13636
14302
|
return "response" in result ? result.response : void 0;
|
|
13637
14303
|
} catch (error) {
|
|
14304
|
+
if (error instanceof RequestFailure || primaryError)
|
|
14305
|
+
throw error;
|
|
13638
14306
|
const isAbortError = error instanceof Error ? error.name === "AbortError" : !!error && typeof error === "object" && "name" in error && error.name === "AbortError";
|
|
13639
14307
|
if (isAbortError) {
|
|
13640
|
-
|
|
13641
|
-
|
|
13642
|
-
throw new Error("Proxy request timeout");
|
|
14308
|
+
const message = !timedOut && signal?.aborted ? "Request aborted" : "Proxy request timeout";
|
|
14309
|
+
throw new RequestFailure(`${message}: ${requestId}; dispatched to primary; unknown; use get_request_status`, !timedOut && signal?.aborted ? "request_aborted" : "proxy_request_timeout", { requestId, targetPeerId, stage: "dispatched", outcome: "unknown", executionOutcome: "unknown", transportStage: "proxy_send" });
|
|
13643
14310
|
}
|
|
13644
|
-
throw error;
|
|
14311
|
+
throw new RequestFailure(`Proxy connection lost: ${requestId}; dispatched to primary; unknown; use get_request_status. ${error instanceof Error ? error.message : String(error)}`, "proxy_connection_lost", { requestId, targetPeerId, stage: "dispatched", outcome: "unknown", executionOutcome: "unknown", connectionLostAt: Date.now(), transportStage: "proxy_send" });
|
|
13645
14312
|
} finally {
|
|
13646
14313
|
clearTimeout(timeoutId);
|
|
13647
14314
|
signal?.removeEventListener("abort", abortFromCaller);
|
|
@@ -13653,6 +14320,251 @@ var ProxyBridgeService = class _ProxyBridgeService extends BridgeService {
|
|
|
13653
14320
|
}
|
|
13654
14321
|
};
|
|
13655
14322
|
|
|
14323
|
+
// ../core/dist/stdio-transport.js
|
|
14324
|
+
import { parseJSONRPCMessage } from "@modelcontextprotocol/server";
|
|
14325
|
+
import { TextDecoder } from "util";
|
|
14326
|
+
var MAX_STDIO_LINE_BYTES = 80 * 1024 * 1024;
|
|
14327
|
+
var MAX_STDIO_PENDING_OUTPUT_BYTES = 256 * 1024 * 1024;
|
|
14328
|
+
var INITIAL_BUFFER_BYTES = 64 * 1024;
|
|
14329
|
+
var BoundedStdioTransport = class {
|
|
14330
|
+
input;
|
|
14331
|
+
output;
|
|
14332
|
+
onclose;
|
|
14333
|
+
onerror;
|
|
14334
|
+
onmessage;
|
|
14335
|
+
limitBytes;
|
|
14336
|
+
outputLimitBytes;
|
|
14337
|
+
decoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
|
|
14338
|
+
buffer;
|
|
14339
|
+
lineBytes = 0;
|
|
14340
|
+
started = false;
|
|
14341
|
+
closed = false;
|
|
14342
|
+
inputEnded = false;
|
|
14343
|
+
outputBlocked = false;
|
|
14344
|
+
pendingChunk;
|
|
14345
|
+
pendingWrites = 0;
|
|
14346
|
+
pendingSends = /* @__PURE__ */ new Set();
|
|
14347
|
+
constructor(input = process.stdin, output = process.stdout, options = {}) {
|
|
14348
|
+
this.input = input;
|
|
14349
|
+
this.output = output;
|
|
14350
|
+
this.limitBytes = options.maxBufferSize ?? MAX_STDIO_LINE_BYTES;
|
|
14351
|
+
this.outputLimitBytes = options.maxPendingOutputBytes ?? MAX_STDIO_PENDING_OUTPUT_BYTES;
|
|
14352
|
+
if (!Number.isSafeInteger(this.limitBytes) || this.limitBytes < 1) {
|
|
14353
|
+
throw new RangeError("Stdio line limit must be a positive safe integer");
|
|
14354
|
+
}
|
|
14355
|
+
if (!Number.isSafeInteger(this.outputLimitBytes) || this.outputLimitBytes < 1) {
|
|
14356
|
+
throw new RangeError("Stdio output limit must be a positive safe integer");
|
|
14357
|
+
}
|
|
14358
|
+
}
|
|
14359
|
+
async start() {
|
|
14360
|
+
if (this.started || this.closed)
|
|
14361
|
+
throw new Error("Stdio transport cannot be started again");
|
|
14362
|
+
if (this.input.readableEncoding)
|
|
14363
|
+
throw new Error("Stdio transport requires a byte stream, not a decoded string stream");
|
|
14364
|
+
this.started = true;
|
|
14365
|
+
this.input.on("data", this.onData);
|
|
14366
|
+
this.input.on("error", this.onStreamError);
|
|
14367
|
+
this.input.on("end", this.onEnd);
|
|
14368
|
+
this.input.on("close", this.onInputClose);
|
|
14369
|
+
this.output.on("error", this.onStreamError);
|
|
14370
|
+
this.output.on("close", this.onOutputClose);
|
|
14371
|
+
this.output.on("drain", this.onDrain);
|
|
14372
|
+
}
|
|
14373
|
+
onData = (chunk) => {
|
|
14374
|
+
let offset = 0;
|
|
14375
|
+
while (offset < chunk.length && !this.closed) {
|
|
14376
|
+
if (this.outputBlocked) {
|
|
14377
|
+
this.pendingChunk = chunk.subarray(offset);
|
|
14378
|
+
return;
|
|
14379
|
+
}
|
|
14380
|
+
const newline = chunk.indexOf(10, offset);
|
|
14381
|
+
const end = newline === -1 ? chunk.length : newline;
|
|
14382
|
+
const bytes = end - offset;
|
|
14383
|
+
const previousBytes = this.lineBytes;
|
|
14384
|
+
this.lineBytes += bytes;
|
|
14385
|
+
if (this.lineBytes > this.limitBytes) {
|
|
14386
|
+
this.buffer = void 0;
|
|
14387
|
+
} else if (previousBytes === 0 && newline !== -1) {
|
|
14388
|
+
this.buffer = chunk.subarray(offset, end);
|
|
14389
|
+
} else if (bytes > 0) {
|
|
14390
|
+
if (!this.buffer || this.buffer.length < this.lineBytes) {
|
|
14391
|
+
const capacity = Math.min(this.limitBytes, Math.max(INITIAL_BUFFER_BYTES, this.lineBytes, (this.buffer?.length ?? 0) * 2));
|
|
14392
|
+
const grown = Buffer.allocUnsafe(capacity);
|
|
14393
|
+
this.buffer?.copy(grown, 0, 0, previousBytes);
|
|
14394
|
+
this.buffer = grown;
|
|
14395
|
+
}
|
|
14396
|
+
chunk.copy(this.buffer, previousBytes, offset, end);
|
|
14397
|
+
}
|
|
14398
|
+
offset = end + 1;
|
|
14399
|
+
if (newline !== -1)
|
|
14400
|
+
this.finishLine();
|
|
14401
|
+
}
|
|
14402
|
+
};
|
|
14403
|
+
finishLine() {
|
|
14404
|
+
const bytes = this.lineBytes;
|
|
14405
|
+
const buffer = this.buffer;
|
|
14406
|
+
this.lineBytes = 0;
|
|
14407
|
+
this.buffer = void 0;
|
|
14408
|
+
if (bytes > this.limitBytes) {
|
|
14409
|
+
void this.rejectLine(-32600, "stdio_request_too_large", bytes).catch(() => {
|
|
14410
|
+
});
|
|
14411
|
+
return;
|
|
14412
|
+
}
|
|
14413
|
+
let value;
|
|
14414
|
+
try {
|
|
14415
|
+
value = JSON.parse(this.decoder.decode(buffer?.subarray(0, bytes)));
|
|
14416
|
+
} catch {
|
|
14417
|
+
void this.rejectLine(-32700, "stdio_parse_error", bytes).catch(() => {
|
|
14418
|
+
});
|
|
14419
|
+
return;
|
|
14420
|
+
}
|
|
14421
|
+
let message;
|
|
14422
|
+
try {
|
|
14423
|
+
message = parseJSONRPCMessage(value);
|
|
14424
|
+
} catch {
|
|
14425
|
+
void this.rejectLine(-32600, "stdio_invalid_request", bytes).catch(() => {
|
|
14426
|
+
});
|
|
14427
|
+
return;
|
|
14428
|
+
}
|
|
14429
|
+
try {
|
|
14430
|
+
this.onmessage?.(message);
|
|
14431
|
+
} catch (error) {
|
|
14432
|
+
this.onerror?.(error instanceof Error ? error : new Error(String(error)));
|
|
14433
|
+
}
|
|
14434
|
+
}
|
|
14435
|
+
rejectLine(code, reason, bytes) {
|
|
14436
|
+
const message = `${reason}: received ${bytes} bytes; limit ${this.limitBytes} bytes; stdio_receive; not_executed`;
|
|
14437
|
+
this.onerror?.(new Error(message));
|
|
14438
|
+
return this.writeMessage({
|
|
14439
|
+
jsonrpc: "2.0",
|
|
14440
|
+
id: null,
|
|
14441
|
+
error: {
|
|
14442
|
+
code,
|
|
14443
|
+
message,
|
|
14444
|
+
data: {
|
|
14445
|
+
code: reason,
|
|
14446
|
+
bytes,
|
|
14447
|
+
limitBytes: this.limitBytes,
|
|
14448
|
+
stage: "stdio_receive",
|
|
14449
|
+
transportStage: "stdio_receive",
|
|
14450
|
+
outcome: "not_executed",
|
|
14451
|
+
executionOutcome: "not_executed"
|
|
14452
|
+
}
|
|
14453
|
+
}
|
|
14454
|
+
});
|
|
14455
|
+
}
|
|
14456
|
+
onEnd = () => {
|
|
14457
|
+
if (this.closed)
|
|
14458
|
+
return;
|
|
14459
|
+
this.inputEnded = true;
|
|
14460
|
+
if (this.pendingChunk)
|
|
14461
|
+
return;
|
|
14462
|
+
const bytes = this.lineBytes;
|
|
14463
|
+
this.lineBytes = 0;
|
|
14464
|
+
this.buffer = void 0;
|
|
14465
|
+
if (bytes > 0) {
|
|
14466
|
+
const oversized = bytes > this.limitBytes;
|
|
14467
|
+
void this.rejectLine(oversized ? -32600 : -32700, oversized ? "stdio_request_too_large" : "stdio_truncated_line", bytes).finally(() => this.close()).catch(() => {
|
|
14468
|
+
});
|
|
14469
|
+
} else {
|
|
14470
|
+
void this.close();
|
|
14471
|
+
}
|
|
14472
|
+
};
|
|
14473
|
+
onInputClose = () => {
|
|
14474
|
+
if (!this.input.readableEnded)
|
|
14475
|
+
this.onEnd();
|
|
14476
|
+
};
|
|
14477
|
+
onOutputClose = () => {
|
|
14478
|
+
void this.close();
|
|
14479
|
+
};
|
|
14480
|
+
onStreamError = (error) => {
|
|
14481
|
+
if (this.closed)
|
|
14482
|
+
return;
|
|
14483
|
+
this.onerror?.(error);
|
|
14484
|
+
void this.close();
|
|
14485
|
+
};
|
|
14486
|
+
onDrain = () => {
|
|
14487
|
+
this.outputBlocked = false;
|
|
14488
|
+
for (const send of this.pendingSends)
|
|
14489
|
+
send.resolve();
|
|
14490
|
+
this.pendingSends.clear();
|
|
14491
|
+
const chunk = this.pendingChunk;
|
|
14492
|
+
this.pendingChunk = void 0;
|
|
14493
|
+
if (chunk && !this.closed)
|
|
14494
|
+
this.onData(chunk);
|
|
14495
|
+
if (this.inputEnded && !this.pendingChunk && !this.closed)
|
|
14496
|
+
this.onEnd();
|
|
14497
|
+
if (!this.outputBlocked && !this.closed)
|
|
14498
|
+
this.input.resume();
|
|
14499
|
+
};
|
|
14500
|
+
send(message) {
|
|
14501
|
+
return this.writeMessage(message);
|
|
14502
|
+
}
|
|
14503
|
+
writeMessage(message) {
|
|
14504
|
+
if (this.closed)
|
|
14505
|
+
return Promise.reject(new Error("Stdio transport is closed"));
|
|
14506
|
+
let json;
|
|
14507
|
+
try {
|
|
14508
|
+
json = JSON.stringify(message) + "\n";
|
|
14509
|
+
} catch (error) {
|
|
14510
|
+
return Promise.reject(error);
|
|
14511
|
+
}
|
|
14512
|
+
const bytes = Buffer.byteLength(json);
|
|
14513
|
+
const queuedBytes = this.output.writableLength + bytes;
|
|
14514
|
+
if (queuedBytes > this.outputLimitBytes) {
|
|
14515
|
+
const error = new Error(`Stdio output backpressure capacity exceeded: ${queuedBytes} bytes; limit ${this.outputLimitBytes} bytes`);
|
|
14516
|
+
this.onStreamError(error);
|
|
14517
|
+
return Promise.reject(error);
|
|
14518
|
+
}
|
|
14519
|
+
const { promise, resolve: resolve5, reject } = Promise.withResolvers();
|
|
14520
|
+
const pending = { resolve: resolve5, reject };
|
|
14521
|
+
this.pendingSends.add(pending);
|
|
14522
|
+
this.pendingWrites++;
|
|
14523
|
+
try {
|
|
14524
|
+
const writable = this.output.write(Buffer.from(json), () => {
|
|
14525
|
+
this.pendingWrites--;
|
|
14526
|
+
if (this.closed && this.pendingWrites === 0) {
|
|
14527
|
+
queueMicrotask(() => this.output.off("error", this.onStreamError));
|
|
14528
|
+
}
|
|
14529
|
+
});
|
|
14530
|
+
if (writable) {
|
|
14531
|
+
this.pendingSends.delete(pending);
|
|
14532
|
+
resolve5();
|
|
14533
|
+
} else if (!this.closed) {
|
|
14534
|
+
this.outputBlocked = true;
|
|
14535
|
+
this.input.pause();
|
|
14536
|
+
}
|
|
14537
|
+
} catch (error) {
|
|
14538
|
+
this.pendingWrites--;
|
|
14539
|
+
this.onStreamError(error instanceof Error ? error : new Error(String(error)));
|
|
14540
|
+
}
|
|
14541
|
+
return promise;
|
|
14542
|
+
}
|
|
14543
|
+
async close() {
|
|
14544
|
+
if (this.closed)
|
|
14545
|
+
return;
|
|
14546
|
+
this.closed = true;
|
|
14547
|
+
this.input.off("data", this.onData);
|
|
14548
|
+
this.input.off("error", this.onStreamError);
|
|
14549
|
+
this.input.off("end", this.onEnd);
|
|
14550
|
+
this.input.off("close", this.onInputClose);
|
|
14551
|
+
if (this.pendingWrites === 0)
|
|
14552
|
+
this.output.off("error", this.onStreamError);
|
|
14553
|
+
this.output.off("close", this.onOutputClose);
|
|
14554
|
+
this.output.off("drain", this.onDrain);
|
|
14555
|
+
if (this.input.listenerCount("data") === 0)
|
|
14556
|
+
this.input.pause();
|
|
14557
|
+
this.buffer = void 0;
|
|
14558
|
+
this.pendingChunk = void 0;
|
|
14559
|
+
this.lineBytes = 0;
|
|
14560
|
+
const error = new Error("Stdio transport is closed");
|
|
14561
|
+
for (const send of this.pendingSends)
|
|
14562
|
+
send.reject(error);
|
|
14563
|
+
this.pendingSends.clear();
|
|
14564
|
+
this.onclose?.();
|
|
14565
|
+
}
|
|
14566
|
+
};
|
|
14567
|
+
|
|
13656
14568
|
// ../core/dist/server.js
|
|
13657
14569
|
var RobloxStudioMCPServer = class {
|
|
13658
14570
|
tools;
|
|
@@ -13746,7 +14658,10 @@ var RobloxStudioMCPServer = class {
|
|
|
13746
14658
|
throw new Error(`Unknown tool: ${name}`);
|
|
13747
14659
|
return handler(tools, args, invocation);
|
|
13748
14660
|
}
|
|
13749
|
-
}), {
|
|
14661
|
+
}), {
|
|
14662
|
+
transport: new BoundedStdioTransport(),
|
|
14663
|
+
onerror: (error) => console.error("[mcp:stdio]", error)
|
|
14664
|
+
});
|
|
13750
14665
|
console.error(`${this.config.name} v${this.config.version} running on stdio`);
|
|
13751
14666
|
if (primaryApp) {
|
|
13752
14667
|
primaryApp.setMCPServerActive(true);
|
|
@@ -13904,6 +14819,12 @@ var TOOL_DEFINITIONS = [
|
|
|
13904
14819
|
inputSchema: {
|
|
13905
14820
|
type: "object",
|
|
13906
14821
|
properties: {
|
|
14822
|
+
operation_id: {
|
|
14823
|
+
type: "string",
|
|
14824
|
+
minLength: 1,
|
|
14825
|
+
maxLength: 128,
|
|
14826
|
+
description: "Unique recovery ID; reuse only for identical arguments."
|
|
14827
|
+
},
|
|
13907
14828
|
instancePath: {
|
|
13908
14829
|
type: "string",
|
|
13909
14830
|
description: "Canonical path of the target instance."
|
|
@@ -14079,7 +15000,7 @@ var TOOL_DEFINITIONS = [
|
|
|
14079
15000
|
action: {
|
|
14080
15001
|
type: "string",
|
|
14081
15002
|
enum: ["get", "set", "view"],
|
|
14082
|
-
description: "View frames the
|
|
15003
|
+
description: "View frames a target or the current selection and preserves the camera type."
|
|
14083
15004
|
},
|
|
14084
15005
|
paths: {
|
|
14085
15006
|
type: "array",
|
|
@@ -14095,7 +15016,7 @@ var TOOL_DEFINITIONS = [
|
|
|
14095
15016
|
path: {
|
|
14096
15017
|
type: "string",
|
|
14097
15018
|
minLength: 1,
|
|
14098
|
-
description: "
|
|
15019
|
+
description: "Optional BasePart or Model path for view; omit to frame exactly one selected BasePart or Model."
|
|
14099
15020
|
},
|
|
14100
15021
|
from: {
|
|
14101
15022
|
type: "number",
|
|
@@ -14129,6 +15050,12 @@ var TOOL_DEFINITIONS = [
|
|
|
14129
15050
|
inputSchema: {
|
|
14130
15051
|
type: "object",
|
|
14131
15052
|
properties: {
|
|
15053
|
+
operation_id: {
|
|
15054
|
+
type: "string",
|
|
15055
|
+
minLength: 1,
|
|
15056
|
+
maxLength: 128,
|
|
15057
|
+
description: "Unique recovery ID; reuse only for identical arguments."
|
|
15058
|
+
},
|
|
14132
15059
|
code: {
|
|
14133
15060
|
type: "string",
|
|
14134
15061
|
description: "Luau code to execute."
|
|
@@ -14973,6 +15900,23 @@ var TOOL_DEFINITIONS = [
|
|
|
14973
15900
|
properties: {}
|
|
14974
15901
|
}
|
|
14975
15902
|
},
|
|
15903
|
+
{
|
|
15904
|
+
name: "get_request_status",
|
|
15905
|
+
category: "read",
|
|
15906
|
+
description: "Use to recover a retained Studio operation outcome after a timeout.",
|
|
15907
|
+
inputSchema: {
|
|
15908
|
+
type: "object",
|
|
15909
|
+
properties: {
|
|
15910
|
+
request_id: {
|
|
15911
|
+
type: "string",
|
|
15912
|
+
minLength: 1,
|
|
15913
|
+
maxLength: 128,
|
|
15914
|
+
description: "Caller operation ID or request ID from a server error."
|
|
15915
|
+
}
|
|
15916
|
+
},
|
|
15917
|
+
required: ["request_id"]
|
|
15918
|
+
}
|
|
15919
|
+
},
|
|
14976
15920
|
// === Asset Tools ===
|
|
14977
15921
|
{
|
|
14978
15922
|
name: "search_assets",
|
|
@@ -15551,7 +16495,7 @@ var getAllTools = () => [...TOOL_DEFINITIONS];
|
|
|
15551
16495
|
|
|
15552
16496
|
// ../core/dist/install-plugin-helpers.js
|
|
15553
16497
|
var import_saxes = __toESM(require_saxes(), 1);
|
|
15554
|
-
import { existsSync as existsSync6, lstatSync, mkdirSync as mkdirSync4, readFileSync as readFileSync6, renameSync, rmSync as rmSync2, statSync as statSync3, unlinkSync, writeFileSync as writeFileSync3 } from "fs";
|
|
16498
|
+
import { existsSync as existsSync6, lstatSync, mkdirSync as mkdirSync4, readFileSync as readFileSync6, renameSync, rmSync as rmSync2, rmdirSync, statSync as statSync3, unlinkSync, writeFileSync as writeFileSync3 } from "fs";
|
|
15555
16499
|
import { execSync } from "child_process";
|
|
15556
16500
|
import { randomUUID as randomUUID5 } from "crypto";
|
|
15557
16501
|
import { basename as basename3, join as join5 } from "path";
|
|
@@ -15702,7 +16646,79 @@ Delete ${otherAssetName} manually or use the default CLI installer behavior to r
|
|
|
15702
16646
|
}
|
|
15703
16647
|
var PLUGIN_INSTALL_LOCK_NAME = ".robloxstudio-mcp-plugin-install.lock";
|
|
15704
16648
|
function errorCode(error) {
|
|
15705
|
-
return error.code;
|
|
16649
|
+
return error !== null && typeof error === "object" && "code" in error && typeof error.code === "string" ? error.code : void 0;
|
|
16650
|
+
}
|
|
16651
|
+
function pluginInstallLockError(lockPath, reason) {
|
|
16652
|
+
return new Error(`${reason}: ${lockPath}. If no installer or lock recovery is running, remove that lock directory and retry.`);
|
|
16653
|
+
}
|
|
16654
|
+
function readPluginInstallLockOwner(lockPath) {
|
|
16655
|
+
const ownerPath = join5(lockPath, "owner.json");
|
|
16656
|
+
if (!lstatSync(lockPath).isDirectory() || !lstatSync(ownerPath).isFile()) {
|
|
16657
|
+
throw new Error("Lock and owner metadata must be a directory and regular file");
|
|
16658
|
+
}
|
|
16659
|
+
const owner = JSON.parse(readFileSync6(ownerPath, "utf8"));
|
|
16660
|
+
if (owner === null || typeof owner !== "object" || !("pid" in owner) || typeof owner.pid !== "number" || !Number.isSafeInteger(owner.pid) || owner.pid <= 0 || owner.pid > 2147483647 || !("token" in owner) || typeof owner.token !== "string" || !/^[a-zA-Z0-9-]{1,128}$/.test(owner.token) || !("createdAt" in owner) || typeof owner.createdAt !== "number" || !Number.isFinite(owner.createdAt) || owner.createdAt < 0) {
|
|
16661
|
+
throw new Error("Invalid lock owner metadata");
|
|
16662
|
+
}
|
|
16663
|
+
return { pid: owner.pid, token: owner.token, createdAt: owner.createdAt };
|
|
16664
|
+
}
|
|
16665
|
+
function assertPluginInstallOwnerExited(lockPath, owner) {
|
|
16666
|
+
if (owner.pid !== process.pid) {
|
|
16667
|
+
try {
|
|
16668
|
+
process.kill(owner.pid, 0);
|
|
16669
|
+
} catch (error) {
|
|
16670
|
+
if (errorCode(error) === "ESRCH")
|
|
16671
|
+
return;
|
|
16672
|
+
throw pluginInstallLockError(lockPath, `Cannot confirm that plugin installation owner PID ${owner.pid} has exited (${errorCode(error) ?? error})`);
|
|
16673
|
+
}
|
|
16674
|
+
}
|
|
16675
|
+
throw pluginInstallLockError(lockPath, `Another Studio plugin installation is already in progress (owner PID ${owner.pid})`);
|
|
16676
|
+
}
|
|
16677
|
+
function recoverPluginInstallLock(lockPath, warn) {
|
|
16678
|
+
let owner;
|
|
16679
|
+
try {
|
|
16680
|
+
owner = readPluginInstallLockOwner(lockPath);
|
|
16681
|
+
} catch (error) {
|
|
16682
|
+
throw pluginInstallLockError(lockPath, `Cannot safely read plugin installation lock owner (${error})`);
|
|
16683
|
+
}
|
|
16684
|
+
assertPluginInstallOwnerExited(lockPath, owner);
|
|
16685
|
+
const claimPath = join5(lockPath, `recovery-${owner.token}`);
|
|
16686
|
+
try {
|
|
16687
|
+
mkdirSync4(claimPath);
|
|
16688
|
+
} catch (error) {
|
|
16689
|
+
throw pluginInstallLockError(lockPath, errorCode(error) === "EEXIST" ? "Plugin installation lock recovery is already in progress or was interrupted" : `Could not claim plugin installation lock recovery (${error})`);
|
|
16690
|
+
}
|
|
16691
|
+
const abandonedPath = `${lockPath}.${randomUUID5()}.abandoned`;
|
|
16692
|
+
let moved = false;
|
|
16693
|
+
try {
|
|
16694
|
+
let currentOwner;
|
|
16695
|
+
try {
|
|
16696
|
+
currentOwner = readPluginInstallLockOwner(lockPath);
|
|
16697
|
+
} catch (error) {
|
|
16698
|
+
throw pluginInstallLockError(lockPath, `Cannot safely recheck plugin installation lock owner (${error})`);
|
|
16699
|
+
}
|
|
16700
|
+
if (currentOwner.pid !== owner.pid || currentOwner.token !== owner.token || currentOwner.createdAt !== owner.createdAt) {
|
|
16701
|
+
throw pluginInstallLockError(lockPath, "Plugin installation lock owner changed; retry installation");
|
|
16702
|
+
}
|
|
16703
|
+
assertPluginInstallOwnerExited(lockPath, currentOwner);
|
|
16704
|
+
renameSync(lockPath, abandonedPath);
|
|
16705
|
+
moved = true;
|
|
16706
|
+
} finally {
|
|
16707
|
+
if (!moved) {
|
|
16708
|
+
try {
|
|
16709
|
+
rmdirSync(claimPath);
|
|
16710
|
+
} catch (error) {
|
|
16711
|
+
if (errorCode(error) !== "ENOENT") {
|
|
16712
|
+
warn(`[install-plugin] Could not release lock recovery claim ${claimPath}: ${error}`);
|
|
16713
|
+
}
|
|
16714
|
+
}
|
|
16715
|
+
}
|
|
16716
|
+
}
|
|
16717
|
+
try {
|
|
16718
|
+
rmSync2(abandonedPath, { recursive: true, force: true });
|
|
16719
|
+
} catch (error) {
|
|
16720
|
+
warn(`[install-plugin] Could not clean abandoned install lock ${abandonedPath}: ${error}`);
|
|
16721
|
+
}
|
|
15706
16722
|
}
|
|
15707
16723
|
function acquirePluginInstallLock(pluginsFolder, warn) {
|
|
15708
16724
|
const lockPath = join5(pluginsFolder, PLUGIN_INSTALL_LOCK_NAME);
|
|
@@ -15717,7 +16733,14 @@ function acquirePluginInstallLock(pluginsFolder, warn) {
|
|
|
15717
16733
|
} catch (error) {
|
|
15718
16734
|
if (errorCode(error) !== "EEXIST")
|
|
15719
16735
|
throw error;
|
|
15720
|
-
|
|
16736
|
+
recoverPluginInstallLock(lockPath, warn);
|
|
16737
|
+
try {
|
|
16738
|
+
mkdirSync4(lockPath);
|
|
16739
|
+
} catch (retryError) {
|
|
16740
|
+
if (errorCode(retryError) !== "EEXIST")
|
|
16741
|
+
throw retryError;
|
|
16742
|
+
throw pluginInstallLockError(lockPath, "Another Studio plugin installation is already in progress");
|
|
16743
|
+
}
|
|
15721
16744
|
}
|
|
15722
16745
|
try {
|
|
15723
16746
|
writeFileSync3(ownerPath, `${JSON.stringify(owner)}
|
|
@@ -15735,7 +16758,7 @@ function acquirePluginInstallLock(pluginsFolder, warn) {
|
|
|
15735
16758
|
}
|
|
15736
16759
|
return () => {
|
|
15737
16760
|
try {
|
|
15738
|
-
const currentOwner =
|
|
16761
|
+
const currentOwner = readPluginInstallLockOwner(lockPath);
|
|
15739
16762
|
if (currentOwner.pid === owner.pid && currentOwner.token === owner.token) {
|
|
15740
16763
|
rmSync2(lockPath, { recursive: true, force: true });
|
|
15741
16764
|
}
|