@adiholick/nvstudio-mcp 2.2.2 → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +43 -47
- package/agent-plugin/mcp_config.json +8 -0
- package/agent-plugin/plugin.json +6 -0
- package/agent-plugin/rules/AGENTS.md +87 -0
- package/agent-plugin/skills/nvstudio-assets-terrain-guide/SKILL.md +27 -0
- package/agent-plugin/skills/nvstudio-gui-standards/SKILL.md +419 -0
- package/agent-plugin/skills/nvstudio-mcp-guide/SKILL.md +69 -0
- package/agent-plugin/skills/nvstudio-roblox-sync-guide/SKILL.md +16 -0
- package/dist/build-plugin.js +55 -0
- package/dist/index.js +988 -146
- package/dist/luau-validator.js +99 -34
- package/dist/roblox-bridge.js +390 -72
- package/dist/roblox-docs.js +264 -0
- package/dist/task-queue.js +57 -6
- package/dist/ws-gateway.js +579 -0
- package/package.json +7 -3
- package/public/index.html +634 -109
- package/studio-plugin/Main.server.lua +484 -53
- package/studio-plugin/batch_create_instances.lua +128 -0
- package/studio-plugin/batch_set_properties.lua +69 -0
- package/studio-plugin/get_properties.lua +25 -2
- package/studio-plugin/insert_asset.lua +85 -12
- package/studio-plugin/nvstudio_mcp.rbxmx +1393 -68
- package/studio-plugin/preview_asset.lua +99 -0
- package/studio-plugin/scan_security.lua +81 -0
- package/studio-plugin/security_scanner.lua +346 -0
- package/studio-plugin/set_properties.lua +10 -0
- package/studio-plugin/ui_manager.lua +1 -1
|
@@ -10,6 +10,229 @@
|
|
|
10
10
|
<int64 name="SourceAssetId">-1</int64>
|
|
11
11
|
<BinaryString name="Tags"></BinaryString>
|
|
12
12
|
</Properties>
|
|
13
|
+
<Item class="ModuleScript" referent="RBX_batch_create_instances">
|
|
14
|
+
<Properties>
|
|
15
|
+
<BinaryString name="AttributesSerialize"></BinaryString>
|
|
16
|
+
<SecurityCapabilities name="Capabilities">0</SecurityCapabilities>
|
|
17
|
+
<bool name="DefinesCapabilities">false</bool>
|
|
18
|
+
<bool name="Disabled">false</bool>
|
|
19
|
+
<Content name="LinkedSource"><null></null></Content>
|
|
20
|
+
<string name="Name">batch_create_instances</string>
|
|
21
|
+
<int64 name="SourceAssetId">-1</int64>
|
|
22
|
+
<BinaryString name="Tags"></BinaryString>
|
|
23
|
+
<ProtectedString name="Source"><![CDATA[--!strict
|
|
24
|
+
-- ============================================================================
|
|
25
|
+
-- Skill Module: batch_create_instances
|
|
26
|
+
-- Deskripsi: Pembangun Hierarki Instance Massal & Deklaratif (Tree / Batch List)
|
|
27
|
+
-- ============================================================================
|
|
28
|
+
|
|
29
|
+
local HttpService = game:GetService("HttpService")
|
|
30
|
+
|
|
31
|
+
return function(targetInstance: Instance?, data: any, ctx: any, targetPath: string?)
|
|
32
|
+
local success, config = pcall(function()
|
|
33
|
+
if type(data) == "table" then return data end
|
|
34
|
+
return HttpService:JSONDecode(tostring(data))
|
|
35
|
+
end)
|
|
36
|
+
|
|
37
|
+
if not success or type(config) ~= "table" then
|
|
38
|
+
return { status = "error", error = "Format data JSON tidak valid untuk batch_create_instances." }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
local setPropsFunc = require(script.Parent:WaitForChild("set_properties"))
|
|
42
|
+
local defaultParent = targetInstance or game.Workspace
|
|
43
|
+
|
|
44
|
+
if config.target and ctx and ctx.getInstanceFromPath then
|
|
45
|
+
local resolved = ctx.getInstanceFromPath(config.target)
|
|
46
|
+
if resolved then defaultParent = resolved end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
local createdList = {}
|
|
50
|
+
local errors = {}
|
|
51
|
+
local createdCount = 0
|
|
52
|
+
|
|
53
|
+
local function applyProps(inst: Instance, props: any)
|
|
54
|
+
if not props or type(props) ~= "table" or not next(props) then return end
|
|
55
|
+
local propJson = HttpService:JSONEncode(props)
|
|
56
|
+
local res = setPropsFunc(inst, propJson, ctx, inst:GetFullName())
|
|
57
|
+
if res and res.status == "error" then
|
|
58
|
+
table.insert(errors, string.format("[%s] Error properti: %s", inst.Name, tostring(res.result or res.error)))
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
-- 1. MODE HIERARCHICAL TREE (Pohon Bersarang)
|
|
63
|
+
local function buildTreeNode(parentInst: Instance, node: any): Instance?
|
|
64
|
+
local className = node.className or node.ClassName
|
|
65
|
+
if not className or type(className) ~= "string" then
|
|
66
|
+
table.insert(errors, "Node pohon tidak memiliki ClassName yang valid.")
|
|
67
|
+
return nil
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
local ok, inst = pcall(function()
|
|
71
|
+
local newObj = Instance.new(className)
|
|
72
|
+
if node.name or node.Name then
|
|
73
|
+
newObj.Name = tostring(node.name or node.Name)
|
|
74
|
+
end
|
|
75
|
+
newObj.Parent = parentInst
|
|
76
|
+
return newObj
|
|
77
|
+
end)
|
|
78
|
+
|
|
79
|
+
if not ok or not inst then
|
|
80
|
+
table.insert(errors, string.format("Gagal membuat instance '%s': %s", className, tostring(inst)))
|
|
81
|
+
return nil
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
createdCount = createdCount + 1
|
|
85
|
+
table.insert(createdList, inst:GetFullName())
|
|
86
|
+
|
|
87
|
+
-- Terapkan properti
|
|
88
|
+
applyProps(inst, node.properties or node.Properties)
|
|
89
|
+
|
|
90
|
+
-- Rekursif untuk seluruh child
|
|
91
|
+
local children = node.children or node.Children
|
|
92
|
+
if children and type(children) == "table" then
|
|
93
|
+
for _, childNode in ipairs(children) do
|
|
94
|
+
buildTreeNode(inst, childNode)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
return inst
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
-- Eksekusi Mode Tree jika disediakan
|
|
102
|
+
if config.tree and type(config.tree) == "table" then
|
|
103
|
+
buildTreeNode(defaultParent, config.tree)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
-- 2. MODE FLAT ARRAY (Daftar Instance Terpisah)
|
|
107
|
+
local instancesArray = config.instances or config.items
|
|
108
|
+
if instancesArray and type(instancesArray) == "table" then
|
|
109
|
+
for idx, item in ipairs(instancesArray) do
|
|
110
|
+
local parentInst = defaultParent
|
|
111
|
+
if item.parent and ctx and ctx.getInstanceFromPath then
|
|
112
|
+
local resParent = ctx.getInstanceFromPath(item.parent)
|
|
113
|
+
if resParent then parentInst = resParent end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
local className = item.className or item.ClassName
|
|
117
|
+
if className then
|
|
118
|
+
local ok, inst = pcall(function()
|
|
119
|
+
local newObj = Instance.new(className)
|
|
120
|
+
if item.name or item.Name then
|
|
121
|
+
newObj.Name = tostring(item.name or item.Name)
|
|
122
|
+
end
|
|
123
|
+
newObj.Parent = parentInst
|
|
124
|
+
return newObj
|
|
125
|
+
end)
|
|
126
|
+
|
|
127
|
+
if ok and inst then
|
|
128
|
+
createdCount = createdCount + 1
|
|
129
|
+
table.insert(createdList, inst:GetFullName())
|
|
130
|
+
applyProps(inst, item.properties or item.Properties)
|
|
131
|
+
else
|
|
132
|
+
table.insert(errors, string.format("Item #%d (%s) gagal dibuat: %s", idx, tostring(className), tostring(inst)))
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
local status = (createdCount > 0 or #errors == 0) and "success" or "error"
|
|
139
|
+
return {
|
|
140
|
+
status = status,
|
|
141
|
+
result = {
|
|
142
|
+
totalCreated = createdCount,
|
|
143
|
+
created = createdList,
|
|
144
|
+
hasErrors = (#errors > 0),
|
|
145
|
+
errors = errors,
|
|
146
|
+
message = string.format("Berhasil membuat %d instance secara batch.", createdCount)
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
]]></ProtectedString>
|
|
152
|
+
</Properties>
|
|
153
|
+
</Item>
|
|
154
|
+
<Item class="ModuleScript" referent="RBX_batch_set_properties">
|
|
155
|
+
<Properties>
|
|
156
|
+
<BinaryString name="AttributesSerialize"></BinaryString>
|
|
157
|
+
<SecurityCapabilities name="Capabilities">0</SecurityCapabilities>
|
|
158
|
+
<bool name="DefinesCapabilities">false</bool>
|
|
159
|
+
<bool name="Disabled">false</bool>
|
|
160
|
+
<Content name="LinkedSource"><null></null></Content>
|
|
161
|
+
<string name="Name">batch_set_properties</string>
|
|
162
|
+
<int64 name="SourceAssetId">-1</int64>
|
|
163
|
+
<BinaryString name="Tags"></BinaryString>
|
|
164
|
+
<ProtectedString name="Source"><![CDATA[--!strict
|
|
165
|
+
-- ============================================================================
|
|
166
|
+
-- Skill Module: batch_set_properties
|
|
167
|
+
-- Deskripsi: Modifikasi Properti Massal untuk Beberapa Instance Sekaligus
|
|
168
|
+
-- ============================================================================
|
|
169
|
+
|
|
170
|
+
local HttpService = game:GetService("HttpService")
|
|
171
|
+
|
|
172
|
+
return function(targetInstance: Instance?, data: any, ctx: any, targetPath: string?)
|
|
173
|
+
local success, config = pcall(function()
|
|
174
|
+
if type(data) == "table" then return data end
|
|
175
|
+
return HttpService:JSONDecode(tostring(data))
|
|
176
|
+
end)
|
|
177
|
+
|
|
178
|
+
if not success or type(config) ~= "table" then
|
|
179
|
+
return { status = "error", error = "Format data JSON tidak valid untuk batch_set_properties." }
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
local setPropsFunc = require(script.Parent:WaitForChild("set_properties"))
|
|
183
|
+
local updates = config.updates or config
|
|
184
|
+
if type(updates) ~= "table" then
|
|
185
|
+
return { status = "error", error = "Parameter 'updates' harus berupa array pembaruan properti." }
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
local updatedCount = 0
|
|
189
|
+
local successfulTargets = {}
|
|
190
|
+
local errors = {}
|
|
191
|
+
|
|
192
|
+
for idx, updateItem in ipairs(updates) do
|
|
193
|
+
local inst = targetInstance
|
|
194
|
+
local instPath = updateItem.target or updateItem.path
|
|
195
|
+
if instPath and ctx and ctx.getInstanceFromPath then
|
|
196
|
+
local resolved = ctx.getInstanceFromPath(instPath)
|
|
197
|
+
if resolved then
|
|
198
|
+
inst = resolved
|
|
199
|
+
else
|
|
200
|
+
table.insert(errors, string.format("Item #%d: Target path '%s' tidak ditemukan.", idx, tostring(instPath)))
|
|
201
|
+
continue
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
local props = updateItem.properties or updateItem.props
|
|
206
|
+
if inst and props and type(props) == "table" then
|
|
207
|
+
local propJson = HttpService:JSONEncode(props)
|
|
208
|
+
local res = setPropsFunc(inst, propJson, ctx, inst:GetFullName())
|
|
209
|
+
if res and res.status == "error" then
|
|
210
|
+
table.insert(errors, string.format("[%s]: %s", inst.Name, tostring(res.result or res.error)))
|
|
211
|
+
else
|
|
212
|
+
updatedCount = updatedCount + 1
|
|
213
|
+
table.insert(successfulTargets, inst:GetFullName())
|
|
214
|
+
end
|
|
215
|
+
else
|
|
216
|
+
table.insert(errors, string.format("Item #%d: Instance atau properties tidak valid.", idx))
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
local status = (updatedCount > 0 or #errors == 0) and "success" or "error"
|
|
221
|
+
return {
|
|
222
|
+
status = status,
|
|
223
|
+
result = {
|
|
224
|
+
totalUpdated = updatedCount,
|
|
225
|
+
updatedInstances = successfulTargets,
|
|
226
|
+
hasErrors = (#errors > 0),
|
|
227
|
+
errors = errors,
|
|
228
|
+
message = string.format("Berhasil memperbarui properti untuk %d instance.", updatedCount)
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
]]></ProtectedString>
|
|
234
|
+
</Properties>
|
|
235
|
+
</Item>
|
|
13
236
|
<Item class="ModuleScript" referent="RBX_create_instance">
|
|
14
237
|
<Properties>
|
|
15
238
|
<BinaryString name="AttributesSerialize"></BinaryString>
|
|
@@ -407,9 +630,32 @@ end
|
|
|
407
630
|
tryProp(prop)
|
|
408
631
|
end
|
|
409
632
|
|
|
410
|
-
-- Attributes & Tags
|
|
633
|
+
-- Attributes & Tags (sanitasi tipe non-JSON agar tidak throw error di JSONEncode)
|
|
411
634
|
pcall(function()
|
|
412
|
-
|
|
635
|
+
local rawAttrs = targetInstance:GetAttributes()
|
|
636
|
+
local safeAttrs = {}
|
|
637
|
+
for k, val in pairs(rawAttrs) do
|
|
638
|
+
if typeof(val) == "Vector3" then
|
|
639
|
+
safeAttrs[k] = {math.round(val.X*100)/100, math.round(val.Y*100)/100, math.round(val.Z*100)/100}
|
|
640
|
+
elseif typeof(val) == "Color3" then
|
|
641
|
+
safeAttrs[k] = {math.round(val.R * 255), math.round(val.G * 255), math.round(val.B * 255)}
|
|
642
|
+
elseif typeof(val) == "CFrame" then
|
|
643
|
+
safeAttrs[k] = tostring(val)
|
|
644
|
+
elseif typeof(val) == "EnumItem" then
|
|
645
|
+
safeAttrs[k] = val.Name
|
|
646
|
+
elseif typeof(val) == "UDim2" then
|
|
647
|
+
safeAttrs[k] = {val.X.Scale, val.X.Offset, val.Y.Scale, val.Y.Offset}
|
|
648
|
+
elseif typeof(val) == "Instance" then
|
|
649
|
+
safeAttrs[k] = val:GetFullName()
|
|
650
|
+
elseif typeof(val) == "DateTime" then
|
|
651
|
+
safeAttrs[k] = val:ToIsoDate()
|
|
652
|
+
elseif typeof(val) == "string" or typeof(val) == "number" or typeof(val) == "boolean" then
|
|
653
|
+
safeAttrs[k] = val
|
|
654
|
+
else
|
|
655
|
+
safeAttrs[k] = tostring(val)
|
|
656
|
+
end
|
|
657
|
+
end
|
|
658
|
+
props.Attributes = safeAttrs
|
|
413
659
|
end)
|
|
414
660
|
pcall(function()
|
|
415
661
|
props.Tags = CollectionService:GetTags(targetInstance)
|
|
@@ -450,28 +696,101 @@ end
|
|
|
450
696
|
<string name="Name">insert_asset</string>
|
|
451
697
|
<int64 name="SourceAssetId">-1</int64>
|
|
452
698
|
<BinaryString name="Tags"></BinaryString>
|
|
453
|
-
<ProtectedString name="Source"><![CDATA[
|
|
454
|
-
|
|
455
|
-
|
|
699
|
+
<ProtectedString name="Source"><![CDATA[--!strict
|
|
700
|
+
-- ============================================================================
|
|
701
|
+
-- Skill Module: insert_asset (Upgraded with Asset Security Scanner & Script Stripper)
|
|
702
|
+
-- ============================================================================
|
|
703
|
+
|
|
704
|
+
local HttpService = game:GetService("HttpService")
|
|
705
|
+
local InsertService = game:GetService("InsertService")
|
|
706
|
+
|
|
707
|
+
local SecurityScanner = require(script.Parent:WaitForChild("security_scanner"))
|
|
708
|
+
|
|
709
|
+
return function(targetInstance: Instance?, data: any, ctx: any, targetPath: string?)
|
|
710
|
+
local assetIdStr = ""
|
|
711
|
+
local stripScripts = true -- Default TRUE demi keamanan mutlak
|
|
712
|
+
local scanSecurity = true
|
|
713
|
+
|
|
714
|
+
if typeof(data) == "string" then
|
|
715
|
+
local success, parsed = pcall(function()
|
|
716
|
+
return HttpService:JSONDecode(data)
|
|
717
|
+
end)
|
|
718
|
+
if success and typeof(parsed) == "table" then
|
|
719
|
+
assetIdStr = tostring(parsed.asset_id or parsed.assetId or "")
|
|
720
|
+
if parsed.strip_scripts ~= nil then stripScripts = parsed.strip_scripts end
|
|
721
|
+
if parsed.scan_security ~= nil then scanSecurity = parsed.scan_security end
|
|
722
|
+
else
|
|
723
|
+
assetIdStr = data
|
|
724
|
+
end
|
|
725
|
+
elseif typeof(data) == "table" then
|
|
726
|
+
assetIdStr = tostring(data.asset_id or data.assetId or "")
|
|
727
|
+
if data.strip_scripts ~= nil then stripScripts = data.strip_scripts end
|
|
728
|
+
if data.scan_security ~= nil then scanSecurity = data.scan_security end
|
|
729
|
+
end
|
|
730
|
+
|
|
731
|
+
local rawId = string.gsub(assetIdStr, "rbxassetid://", "")
|
|
732
|
+
rawId = string.gsub(rawId, "%s+", "")
|
|
456
733
|
local assetId = tonumber(rawId)
|
|
457
|
-
|
|
734
|
+
|
|
735
|
+
if not assetId or assetId <= 0 then
|
|
458
736
|
return { status = "error", error = "Asset ID harus berupa angka yang valid." }
|
|
459
737
|
end
|
|
460
|
-
|
|
738
|
+
|
|
461
739
|
local success, model = pcall(function()
|
|
462
740
|
return InsertService:LoadAsset(assetId)
|
|
463
741
|
end)
|
|
464
|
-
|
|
465
|
-
if success
|
|
466
|
-
|
|
467
|
-
|
|
742
|
+
|
|
743
|
+
if not success or not model then
|
|
744
|
+
return {
|
|
745
|
+
status = "error",
|
|
746
|
+
error = "Gagal memuat aset. Pastikan Asset ID benar dan model berstatus publik di Creator Store."
|
|
747
|
+
}
|
|
748
|
+
end
|
|
749
|
+
|
|
750
|
+
local securityNotes = {}
|
|
751
|
+
local removedScripts = {}
|
|
752
|
+
|
|
753
|
+
-- 1. Scan keamanan jika diaktifkan
|
|
754
|
+
if scanSecurity then
|
|
755
|
+
local scanResult = SecurityScanner.scanInstance(model, true)
|
|
756
|
+
if scanResult.riskLevel == "DANGEROUS" then
|
|
757
|
+
table.insert(securityNotes, string.format("⚠️ PERINGATAN: Aset ini memiliki %d ancaman BERBAHAYA (Backdoor/Obfuscation/Webhook)!", scanResult.highThreats))
|
|
758
|
+
end
|
|
759
|
+
end
|
|
760
|
+
|
|
761
|
+
-- 2. Sanitasi: Hapus skrip jika strip_scripts = true
|
|
762
|
+
if stripScripts then
|
|
763
|
+
removedScripts = SecurityScanner.stripScripts(model)
|
|
764
|
+
if #removedScripts > 0 then
|
|
765
|
+
table.insert(securityNotes, string.format("🛡️ Keamanan: %d skrip bawaan telah dihapus secara otomatis demi keamanan game Anda.", #removedScripts))
|
|
468
766
|
else
|
|
469
|
-
|
|
767
|
+
table.insert(securityNotes, "🛡️ Keamanan: Aset bersih, tidak ditemukan skrip berbahaya.")
|
|
470
768
|
end
|
|
471
|
-
return { status = "success", result = "Aset berhasil dimasukkan ke " .. model.Parent.Name .. "." }
|
|
472
769
|
else
|
|
473
|
-
|
|
770
|
+
table.insert(securityNotes, "ℹ️ Catatan: strip_scripts = false, skrip bawaan model dipertahankan.")
|
|
474
771
|
end
|
|
772
|
+
|
|
773
|
+
-- 3. Masukkan model ke target
|
|
774
|
+
local parentTarget = targetInstance
|
|
775
|
+
if not parentTarget or parentTarget == game or parentTarget.ClassName == "DataModel" then
|
|
776
|
+
parentTarget = game.Workspace
|
|
777
|
+
end
|
|
778
|
+
|
|
779
|
+
model.Parent = parentTarget
|
|
780
|
+
|
|
781
|
+
return {
|
|
782
|
+
status = "success",
|
|
783
|
+
result = {
|
|
784
|
+
message = string.format("Aset '%s' (ID: %d) berhasil dimasukkan ke %s.", model.Name, assetId, parentTarget.Name),
|
|
785
|
+
assetName = model.Name,
|
|
786
|
+
assetId = assetId,
|
|
787
|
+
parent = parentTarget:GetFullName(),
|
|
788
|
+
stripScriptsApplied = stripScripts,
|
|
789
|
+
removedScriptsCount = #removedScripts,
|
|
790
|
+
removedScripts = removedScripts,
|
|
791
|
+
securityNotes = table.concat(securityNotes, "\n"),
|
|
792
|
+
}
|
|
793
|
+
}
|
|
475
794
|
end
|
|
476
795
|
]]></ProtectedString>
|
|
477
796
|
</Properties>
|
|
@@ -488,10 +807,10 @@ end
|
|
|
488
807
|
<BinaryString name="Tags"></BinaryString>
|
|
489
808
|
<token name="RunContext">0</token>
|
|
490
809
|
<ProtectedString name="Source"><![CDATA[-- ══════════════════════════════════════════════════════════════════════
|
|
491
|
-
-- NVStudio MCP Plugin v2.
|
|
492
|
-
-- Arsitektur:
|
|
493
|
-
-- Plugin auto-connect saat dimuat,
|
|
494
|
-
--
|
|
810
|
+
-- NVStudio MCP Plugin v2.3.0
|
|
811
|
+
-- Arsitektur: Dual-Mode WebSocket Duplex (Port 3055/ws) + Fallback Long-Polling
|
|
812
|
+
-- Plugin auto-connect saat dimuat via WebSocket native, dengan fallback otomatis
|
|
813
|
+
-- ke HTTP stream jika WebSocket tidak tersedia di environment Roblox Studio ini.
|
|
495
814
|
-- ══════════════════════════════════════════════════════════════════════
|
|
496
815
|
|
|
497
816
|
local HttpService = game:GetService("HttpService")
|
|
@@ -502,6 +821,8 @@ local BASE_URL = "http://localhost:3055"
|
|
|
502
821
|
local STREAM_URL = BASE_URL .. "/api/stream" -- SSE long-poll (blocking GET)
|
|
503
822
|
local RESPONSE_URL = BASE_URL .. "/api/response"
|
|
504
823
|
local PING_URL = BASE_URL .. "/api/ping"
|
|
824
|
+
local WS_URL = "ws://localhost:3055/ws"
|
|
825
|
+
local WS_URL_IP = "ws://127.0.0.1:3055/ws"
|
|
505
826
|
|
|
506
827
|
-- ID unik per sesi Studio
|
|
507
828
|
local STUDIO_ID = HttpService:GenerateGUID(false)
|
|
@@ -523,18 +844,55 @@ local ctx = {
|
|
|
523
844
|
getInstanceFromPath = nil -- akan di-bind di bawah
|
|
524
845
|
}
|
|
525
846
|
local MAX_LOGS = 50
|
|
526
|
-
local isRunning = false -- apakah loop aktif
|
|
847
|
+
local isRunning = false -- apakah loop/koneksi aktif
|
|
527
848
|
local isManualDisconnect = false
|
|
528
849
|
local isClickDebounce = false
|
|
529
850
|
local mcpAgentActive = false -- apakah Antigravity sedang konek ke MCP
|
|
530
851
|
local serverReachable = false -- apakah server Node.js reachable
|
|
852
|
+
local activeWsWrapper = nil
|
|
853
|
+
local currentTransport = "none" -- "websocket" | "longpoll" | "none"
|
|
531
854
|
|
|
532
|
-
-- Reconnect config
|
|
855
|
+
-- Reconnect config untuk Long-Polling
|
|
533
856
|
local RECONNECT_DELAY_INITIAL = 1.5
|
|
534
857
|
local RECONNECT_DELAY_MAX = 1.5
|
|
535
858
|
local reconnectDelay = RECONNECT_DELAY_INITIAL
|
|
536
859
|
local currentLoopId = nil
|
|
537
860
|
|
|
861
|
+
-- Task deduplication state (Bug G)
|
|
862
|
+
local runningTasks = {} -- taskId -> true
|
|
863
|
+
local completedTasks = {} -- taskId -> os.clock()
|
|
864
|
+
|
|
865
|
+
local function shouldProcessTask(taskId)
|
|
866
|
+
if not taskId or taskId == "" then return false end
|
|
867
|
+
if runningTasks[taskId] then
|
|
868
|
+
return false
|
|
869
|
+
end
|
|
870
|
+
local completedAt = completedTasks[taskId]
|
|
871
|
+
if completedAt and (os.clock() - completedAt < 60) then
|
|
872
|
+
return false
|
|
873
|
+
end
|
|
874
|
+
runningTasks[taskId] = true
|
|
875
|
+
return true
|
|
876
|
+
end
|
|
877
|
+
|
|
878
|
+
local function markTaskDone(taskId)
|
|
879
|
+
if not taskId then return end
|
|
880
|
+
runningTasks[taskId] = nil
|
|
881
|
+
completedTasks[taskId] = os.clock()
|
|
882
|
+
|
|
883
|
+
-- Bersihkan entri kedaluwarsa (>60 detik)
|
|
884
|
+
local now = os.clock()
|
|
885
|
+
for id, t in pairs(completedTasks) do
|
|
886
|
+
if now - t > 60 then
|
|
887
|
+
completedTasks[id] = nil
|
|
888
|
+
end
|
|
889
|
+
end
|
|
890
|
+
end
|
|
891
|
+
|
|
892
|
+
-- Forward declaration
|
|
893
|
+
local startConnectionManager = nil
|
|
894
|
+
local startLongPollingLoop = nil
|
|
895
|
+
|
|
538
896
|
-- ── Skills (ModuleScript) ─────────────────────────────────────────────────────
|
|
539
897
|
local skills = {}
|
|
540
898
|
for _, module in ipairs(script.Parent:GetChildren()) do
|
|
@@ -577,12 +935,17 @@ if uiModule then
|
|
|
577
935
|
reconnectDelay = RECONNECT_DELAY_INITIAL
|
|
578
936
|
ui:setStatus("waiting")
|
|
579
937
|
ui:addLog("[System] Menghubungkan ulang ke Server MCP...", Color3.fromRGB(255, 220, 100))
|
|
580
|
-
task.spawn(
|
|
938
|
+
task.spawn(startConnectionManager)
|
|
581
939
|
else
|
|
582
940
|
isManualDisconnect = true
|
|
583
941
|
isRunning = false
|
|
584
942
|
serverReachable = false
|
|
585
943
|
mcpAgentActive = false
|
|
944
|
+
if activeWsWrapper then
|
|
945
|
+
activeWsWrapper:Close()
|
|
946
|
+
activeWsWrapper = nil
|
|
947
|
+
end
|
|
948
|
+
currentTransport = "none"
|
|
586
949
|
ui:setStatus("disconnected")
|
|
587
950
|
ui:addLog("[System] Koneksi dihentikan oleh pengguna.", Color3.fromRGB(255, 100, 100))
|
|
588
951
|
end
|
|
@@ -689,31 +1052,325 @@ local function processTask(taskData)
|
|
|
689
1052
|
return result
|
|
690
1053
|
end
|
|
691
1054
|
|
|
692
|
-
-- ──
|
|
693
|
-
local function
|
|
1055
|
+
-- ── Helper: Sanitize result sebelum JSONEncode (Bug H) ─────────────────────────
|
|
1056
|
+
local function sanitizeForJson(val, depth)
|
|
1057
|
+
depth = depth or 0
|
|
1058
|
+
if depth > 5 then return tostring(val) end
|
|
1059
|
+
local t = typeof(val)
|
|
1060
|
+
if t == "string" or t == "number" or t == "boolean" then
|
|
1061
|
+
return val
|
|
1062
|
+
elseif t == "Instance" then
|
|
1063
|
+
return val:GetFullName()
|
|
1064
|
+
elseif t == "Vector3" then
|
|
1065
|
+
return { X = val.X, Y = val.Y, Z = val.Z }
|
|
1066
|
+
elseif t == "CFrame" then
|
|
1067
|
+
return { Position = { X = val.Position.X, Y = val.Position.Y, Z = val.Position.Z } }
|
|
1068
|
+
elseif t == "Color3" then
|
|
1069
|
+
return { R = val.R, G = val.G, B = val.B }
|
|
1070
|
+
elseif t == "table" then
|
|
1071
|
+
local sanitized = {}
|
|
1072
|
+
for k, v in pairs(val) do
|
|
1073
|
+
sanitized[tostring(k)] = sanitizeForJson(v, depth + 1)
|
|
1074
|
+
end
|
|
1075
|
+
return sanitized
|
|
1076
|
+
else
|
|
1077
|
+
return tostring(val)
|
|
1078
|
+
end
|
|
1079
|
+
end
|
|
1080
|
+
|
|
1081
|
+
-- ── Kirim Respons ke Server (HTTP Fallback) (Bug H) ───────────────────────────
|
|
1082
|
+
local function sendResponse(taskId, resultData, attempt)
|
|
694
1083
|
task.spawn(function()
|
|
695
|
-
local
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
1084
|
+
local payload = {
|
|
1085
|
+
id = taskId,
|
|
1086
|
+
taskId = taskId,
|
|
1087
|
+
attempt = attempt,
|
|
1088
|
+
status = resultData.status or "success",
|
|
1089
|
+
result = sanitizeForJson(resultData.result),
|
|
1090
|
+
error = resultData.error and tostring(resultData.error) or nil,
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
local encOk, json = pcall(function()
|
|
1094
|
+
return HttpService:JSONEncode(payload)
|
|
1095
|
+
end)
|
|
1096
|
+
|
|
1097
|
+
if not encOk then
|
|
1098
|
+
payload.result = tostring(payload.result)
|
|
1099
|
+
pcall(function() json = HttpService:JSONEncode(payload) end)
|
|
1100
|
+
end
|
|
1101
|
+
|
|
1102
|
+
if not json then
|
|
1103
|
+
json = string.format('{"id":"%s","taskId":"%s","status":"error","error":"Gagal encode payload JSON"}', taskId, taskId)
|
|
1104
|
+
end
|
|
1105
|
+
|
|
1106
|
+
local postOk = pcall(function()
|
|
1107
|
+
HttpService:PostAsync(
|
|
1108
|
+
RESPONSE_URL .. "/" .. taskId,
|
|
1109
|
+
json,
|
|
1110
|
+
Enum.HttpContentType.ApplicationJson
|
|
1111
|
+
)
|
|
1112
|
+
end)
|
|
1113
|
+
|
|
1114
|
+
if not postOk then
|
|
1115
|
+
-- Backoff sejenak sebelum fallback ke endpoint legacy (Bug H)
|
|
1116
|
+
task.wait(0.2)
|
|
1117
|
+
local fallbackOk = pcall(function()
|
|
705
1118
|
HttpService:PostAsync(RESPONSE_URL, json, Enum.HttpContentType.ApplicationJson)
|
|
1119
|
+
end)
|
|
1120
|
+
if not fallbackOk and ui then
|
|
1121
|
+
ui:addLog("[System] Gagal kirim response HTTP ke server: " .. tostring(taskId), Color3.fromRGB(255, 100, 100))
|
|
706
1122
|
end
|
|
707
|
-
end)
|
|
708
|
-
if not ok and ui then
|
|
709
|
-
ui:addLog("[System] Gagal kirim response: " .. tostring(err), Color3.fromRGB(255, 100, 100))
|
|
710
1123
|
end
|
|
711
1124
|
end)
|
|
712
1125
|
end
|
|
713
1126
|
|
|
714
|
-
-- ──
|
|
1127
|
+
-- ── WebSocket API Abstraction ────────────────────────────────────────────────
|
|
1128
|
+
local function detectWebSocketCapability()
|
|
1129
|
+
-- 1. Check HttpService:CreateWebStreamClient (Roblox Studio native API)
|
|
1130
|
+
local hasWebStream = false
|
|
1131
|
+
pcall(function()
|
|
1132
|
+
hasWebStream = (typeof(HttpService.CreateWebStreamClient) == "function")
|
|
1133
|
+
and (Enum.WebStreamClientType and Enum.WebStreamClientType.WebSocket ~= nil)
|
|
1134
|
+
end)
|
|
1135
|
+
if hasWebStream then
|
|
1136
|
+
return "WebStreamClient"
|
|
1137
|
+
end
|
|
1138
|
+
|
|
1139
|
+
-- 2. Check global WebSocket API jika tersedia
|
|
1140
|
+
local wsGlobal = rawget(_G, "WebSocket") or rawget(_G, "websocket")
|
|
1141
|
+
if not wsGlobal then
|
|
1142
|
+
pcall(function() wsGlobal = WebSocket end)
|
|
1143
|
+
end
|
|
1144
|
+
if wsGlobal and typeof(wsGlobal.connect) == "function" then
|
|
1145
|
+
return "WebSocketGlobal"
|
|
1146
|
+
end
|
|
1147
|
+
|
|
1148
|
+
return nil
|
|
1149
|
+
end
|
|
1150
|
+
|
|
1151
|
+
local function createWsClient(url)
|
|
1152
|
+
local cap = detectWebSocketCapability()
|
|
1153
|
+
if not cap then
|
|
1154
|
+
return nil, "WebSocket API not available"
|
|
1155
|
+
end
|
|
1156
|
+
|
|
1157
|
+
if cap == "WebStreamClient" then
|
|
1158
|
+
local ok, client = pcall(function()
|
|
1159
|
+
return HttpService:CreateWebStreamClient(Enum.WebStreamClientType.WebSocket, {
|
|
1160
|
+
Url = url
|
|
1161
|
+
})
|
|
1162
|
+
end)
|
|
1163
|
+
if not ok or not client then
|
|
1164
|
+
return nil, tostring(client or "CreateWebStreamClient failed")
|
|
1165
|
+
end
|
|
1166
|
+
|
|
1167
|
+
local wrapper = {
|
|
1168
|
+
raw = client,
|
|
1169
|
+
cap = "WebStreamClient",
|
|
1170
|
+
closed = false,
|
|
1171
|
+
connections = {},
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
function wrapper:Send(data)
|
|
1175
|
+
if self.closed then return false, "Closed" end
|
|
1176
|
+
return pcall(function()
|
|
1177
|
+
self.raw:Send(data)
|
|
1178
|
+
end)
|
|
1179
|
+
end
|
|
1180
|
+
|
|
1181
|
+
function wrapper:Close()
|
|
1182
|
+
if self.closed then return end
|
|
1183
|
+
self.closed = true
|
|
1184
|
+
pcall(function() self.raw:Close() end)
|
|
1185
|
+
for _, conn in ipairs(self.connections) do
|
|
1186
|
+
pcall(function() conn:Disconnect() end)
|
|
1187
|
+
end
|
|
1188
|
+
table.clear(self.connections)
|
|
1189
|
+
end
|
|
1190
|
+
|
|
1191
|
+
function wrapper:BindEvents(onMessage, onClose)
|
|
1192
|
+
if self.raw.MessageReceived and typeof(self.raw.MessageReceived.Connect) == "function" then
|
|
1193
|
+
local ok, conn = pcall(function()
|
|
1194
|
+
return self.raw.MessageReceived:Connect(onMessage)
|
|
1195
|
+
end)
|
|
1196
|
+
if ok and conn then table.insert(self.connections, conn) end
|
|
1197
|
+
end
|
|
1198
|
+
|
|
1199
|
+
if self.raw.Closed and typeof(self.raw.Closed.Connect) == "function" then
|
|
1200
|
+
local ok, conn = pcall(function()
|
|
1201
|
+
return self.raw.Closed:Connect(onClose)
|
|
1202
|
+
end)
|
|
1203
|
+
if ok and conn then table.insert(self.connections, conn) end
|
|
1204
|
+
end
|
|
1205
|
+
end
|
|
1206
|
+
|
|
1207
|
+
return wrapper, nil
|
|
1208
|
+
|
|
1209
|
+
elseif cap == "WebSocketGlobal" then
|
|
1210
|
+
local wsGlobal = rawget(_G, "WebSocket") or rawget(_G, "websocket") or WebSocket
|
|
1211
|
+
local ok, client = pcall(function()
|
|
1212
|
+
return wsGlobal.connect(url)
|
|
1213
|
+
end)
|
|
1214
|
+
if not ok or not client then
|
|
1215
|
+
return nil, tostring(client or "WebSocket.connect failed")
|
|
1216
|
+
end
|
|
1217
|
+
|
|
1218
|
+
local wrapper = {
|
|
1219
|
+
raw = client,
|
|
1220
|
+
cap = "WebSocketGlobal",
|
|
1221
|
+
closed = false,
|
|
1222
|
+
connections = {},
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
function wrapper:Send(data)
|
|
1226
|
+
if self.closed then return false, "Closed" end
|
|
1227
|
+
return pcall(function()
|
|
1228
|
+
if typeof(self.raw.Send) == "function" then
|
|
1229
|
+
self.raw:Send(data)
|
|
1230
|
+
elseif typeof(self.raw.send) == "function" then
|
|
1231
|
+
self.raw:send(data)
|
|
1232
|
+
end
|
|
1233
|
+
end)
|
|
1234
|
+
end
|
|
1235
|
+
|
|
1236
|
+
function wrapper:Close()
|
|
1237
|
+
if self.closed then return end
|
|
1238
|
+
self.closed = true
|
|
1239
|
+
pcall(function()
|
|
1240
|
+
if typeof(self.raw.Close) == "function" then
|
|
1241
|
+
self.raw:Close()
|
|
1242
|
+
elseif typeof(self.raw.close) == "function" then
|
|
1243
|
+
self.raw:close()
|
|
1244
|
+
end
|
|
1245
|
+
end)
|
|
1246
|
+
for _, conn in ipairs(self.connections) do
|
|
1247
|
+
pcall(function() conn:Disconnect() end)
|
|
1248
|
+
end
|
|
1249
|
+
table.clear(self.connections)
|
|
1250
|
+
end
|
|
1251
|
+
|
|
1252
|
+
function wrapper:BindEvents(onMessage, onClose)
|
|
1253
|
+
if self.raw.MessageReceived and typeof(self.raw.MessageReceived.Connect) == "function" then
|
|
1254
|
+
local ok, conn = pcall(function() return self.raw.MessageReceived:Connect(onMessage) end)
|
|
1255
|
+
if ok and conn then table.insert(self.connections, conn) end
|
|
1256
|
+
elseif self.raw.OnMessage then
|
|
1257
|
+
if typeof(self.raw.OnMessage) == "RBXScriptSignal" or (typeof(self.raw.OnMessage) == "userdata" and self.raw.OnMessage.Connect) then
|
|
1258
|
+
local ok, conn = pcall(function() return self.raw.OnMessage:Connect(onMessage) end)
|
|
1259
|
+
if ok and conn then table.insert(self.connections, conn) end
|
|
1260
|
+
else
|
|
1261
|
+
pcall(function() self.raw.OnMessage = onMessage end)
|
|
1262
|
+
end
|
|
1263
|
+
end
|
|
1264
|
+
|
|
1265
|
+
if self.raw.Closed and typeof(self.raw.Closed.Connect) == "function" then
|
|
1266
|
+
local ok, conn = pcall(function() return self.raw.Closed:Connect(onClose) end)
|
|
1267
|
+
if ok and conn then table.insert(self.connections, conn) end
|
|
1268
|
+
elseif self.raw.OnClose then
|
|
1269
|
+
if typeof(self.raw.OnClose) == "RBXScriptSignal" or (typeof(self.raw.OnClose) == "userdata" and self.raw.OnClose.Connect) then
|
|
1270
|
+
local ok, conn = pcall(function() return self.raw.OnClose:Connect(onClose) end)
|
|
1271
|
+
if ok and conn then table.insert(self.connections, conn) end
|
|
1272
|
+
else
|
|
1273
|
+
pcall(function() self.raw.OnClose = onClose end)
|
|
1274
|
+
end
|
|
1275
|
+
end
|
|
1276
|
+
end
|
|
1277
|
+
|
|
1278
|
+
return wrapper, nil
|
|
1279
|
+
end
|
|
1280
|
+
|
|
1281
|
+
return nil, "Unsupported"
|
|
1282
|
+
end
|
|
1283
|
+
|
|
1284
|
+
-- ── WebSocket Message Dispatcher ─────────────────────────────────────────────
|
|
1285
|
+
local function handleWsMessage(wrapper, rawMessage)
|
|
1286
|
+
if type(rawMessage) ~= "string" or rawMessage == "" then return end
|
|
1287
|
+
local ok, decoded = pcall(function() return HttpService:JSONDecode(rawMessage) end)
|
|
1288
|
+
if not ok or type(decoded) ~= "table" then return end
|
|
1289
|
+
|
|
1290
|
+
local kind = decoded.kind
|
|
1291
|
+
|
|
1292
|
+
-- Heartbeat ping -> balas pong
|
|
1293
|
+
if kind == "ping" then
|
|
1294
|
+
pcall(function()
|
|
1295
|
+
wrapper:Send(HttpService:JSONEncode({ kind = "pong", ts = os.time() }))
|
|
1296
|
+
end)
|
|
1297
|
+
return
|
|
1298
|
+
end
|
|
1299
|
+
|
|
1300
|
+
if kind == "pong" then
|
|
1301
|
+
return
|
|
1302
|
+
end
|
|
1303
|
+
|
|
1304
|
+
-- Update status koneksi MCP / Studio
|
|
1305
|
+
if kind == "status" then
|
|
1306
|
+
local newActive = decoded.mcpConnected == true
|
|
1307
|
+
local changed = (mcpAgentActive ~= newActive)
|
|
1308
|
+
mcpAgentActive = newActive
|
|
1309
|
+
if ui then
|
|
1310
|
+
if mcpAgentActive then
|
|
1311
|
+
ui:setStatus("connected")
|
|
1312
|
+
else
|
|
1313
|
+
ui:setStatus("server_connected")
|
|
1314
|
+
end
|
|
1315
|
+
if newActive and changed then
|
|
1316
|
+
ui:addLog("[System] ✅ IDE Agent (Antigravity) aktif ⚡", Color3.fromRGB(100, 255, 100))
|
|
1317
|
+
elseif not newActive and changed then
|
|
1318
|
+
ui:addLog("[System] ⚠️ IDE Agent terputus.", Color3.fromRGB(255, 150, 50))
|
|
1319
|
+
end
|
|
1320
|
+
end
|
|
1321
|
+
return
|
|
1322
|
+
end
|
|
1323
|
+
|
|
1324
|
+
-- Eksekusi perintah AI
|
|
1325
|
+
if kind == "request" then
|
|
1326
|
+
local taskId = decoded.taskId or decoded.requestId
|
|
1327
|
+
local attempt = decoded.attempt
|
|
1328
|
+
local command = decoded.command
|
|
1329
|
+
local target = decoded.target
|
|
1330
|
+
local data = decoded.data
|
|
1331
|
+
|
|
1332
|
+
if not taskId or not command then return end
|
|
1333
|
+
if not shouldProcessTask(taskId) then return end -- Bug G: Dedup!
|
|
1334
|
+
|
|
1335
|
+
task.spawn(function()
|
|
1336
|
+
local resultData = processTask({
|
|
1337
|
+
id = taskId,
|
|
1338
|
+
command = command,
|
|
1339
|
+
target = target or "",
|
|
1340
|
+
data = data,
|
|
1341
|
+
})
|
|
1342
|
+
markTaskDone(taskId) -- Bug G: Tandai selesai
|
|
1343
|
+
if resultData then
|
|
1344
|
+
-- Push respons instan kembali via WebSocket (Zero-Delay)
|
|
1345
|
+
local responseEnvelope = {
|
|
1346
|
+
kind = "response",
|
|
1347
|
+
taskId = taskId,
|
|
1348
|
+
id = taskId,
|
|
1349
|
+
attempt = attempt,
|
|
1350
|
+
status = resultData.status,
|
|
1351
|
+
result = sanitizeForJson(resultData.result),
|
|
1352
|
+
error = resultData.error and tostring(resultData.error) or nil,
|
|
1353
|
+
}
|
|
1354
|
+
local encOk, encoded = pcall(function()
|
|
1355
|
+
return HttpService:JSONEncode(responseEnvelope)
|
|
1356
|
+
end)
|
|
1357
|
+
local sent = false
|
|
1358
|
+
if encOk then
|
|
1359
|
+
local sendOk, _ = wrapper:Send(encoded)
|
|
1360
|
+
sent = (sendOk == true)
|
|
1361
|
+
end
|
|
1362
|
+
if not sent then
|
|
1363
|
+
-- Jika serialisasi atau pengiriman via WS gagal, fallback ke HTTP POST
|
|
1364
|
+
sendResponse(taskId, resultData, attempt)
|
|
1365
|
+
end
|
|
1366
|
+
end
|
|
1367
|
+
end)
|
|
1368
|
+
return
|
|
1369
|
+
end
|
|
1370
|
+
end
|
|
1371
|
+
|
|
1372
|
+
-- ── Proses satu event dari SSE stream (Long-Polling fallback) ────────────────
|
|
715
1373
|
local function handleSSELine(line)
|
|
716
|
-
-- Format SSE: "data: {...json...}"
|
|
717
1374
|
local payload = line
|
|
718
1375
|
if line:sub(1, 6) == "event:" then return end
|
|
719
1376
|
if line:sub(1, 5) == "data:" then
|
|
@@ -726,12 +1383,10 @@ local function handleSSELine(line)
|
|
|
726
1383
|
|
|
727
1384
|
local kind = decoded.kind
|
|
728
1385
|
|
|
729
|
-
-- heartbeat: server masih hidup
|
|
730
1386
|
if kind == "heartbeat" then
|
|
731
1387
|
return
|
|
732
1388
|
end
|
|
733
1389
|
|
|
734
|
-
-- status: update mcpConnected
|
|
735
1390
|
if kind == "status" then
|
|
736
1391
|
local newActive = decoded.mcpConnected == true
|
|
737
1392
|
local changed = (mcpAgentActive ~= newActive)
|
|
@@ -752,14 +1407,15 @@ local function handleSSELine(line)
|
|
|
752
1407
|
return
|
|
753
1408
|
end
|
|
754
1409
|
|
|
755
|
-
-- request: ada task dari AI agent
|
|
756
1410
|
if kind == "request" then
|
|
757
|
-
local taskId = decoded.requestId
|
|
1411
|
+
local taskId = decoded.requestId or decoded.taskId
|
|
1412
|
+
local attempt = decoded.attempt
|
|
758
1413
|
local command = decoded.command
|
|
759
1414
|
local target = decoded.target
|
|
760
1415
|
local data = decoded.data
|
|
761
1416
|
|
|
762
1417
|
if not taskId or not command then return end
|
|
1418
|
+
if not shouldProcessTask(taskId) then return end -- Bug G: Dedup!
|
|
763
1419
|
|
|
764
1420
|
task.spawn(function()
|
|
765
1421
|
local resultData = processTask({
|
|
@@ -768,39 +1424,34 @@ local function handleSSELine(line)
|
|
|
768
1424
|
target = target or "",
|
|
769
1425
|
data = data,
|
|
770
1426
|
})
|
|
1427
|
+
markTaskDone(taskId) -- Bug G: Tandai selesai
|
|
771
1428
|
if resultData then
|
|
772
|
-
sendResponse(taskId, resultData)
|
|
1429
|
+
sendResponse(taskId, resultData, attempt)
|
|
773
1430
|
end
|
|
774
1431
|
end)
|
|
775
1432
|
return
|
|
776
1433
|
end
|
|
777
1434
|
end
|
|
778
1435
|
|
|
779
|
-
-- ── Loop
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
-- Plugin reconnect langsung untuk sesi berikutnya.
|
|
784
|
-
function startConnectionLoop()
|
|
785
|
-
if isRunning then return end
|
|
786
|
-
isRunning = true
|
|
787
|
-
isManualDisconnect = false
|
|
1436
|
+
-- ── Fallback Loop: Long-Polling (/api/stream) ─────────────────────────────────
|
|
1437
|
+
function startLongPollingLoop()
|
|
1438
|
+
if not isRunning or isManualDisconnect then return end
|
|
1439
|
+
currentTransport = "longpoll"
|
|
788
1440
|
reconnectDelay = RECONNECT_DELAY_INITIAL
|
|
789
1441
|
|
|
790
1442
|
local loopId = HttpService:GenerateGUID(false)
|
|
791
1443
|
currentLoopId = loopId
|
|
792
1444
|
|
|
793
1445
|
if ui then
|
|
794
|
-
ui:addLog("[System]
|
|
1446
|
+
ui:addLog("[System] Mode Long-Polling aktif (Port 3055)...", Color3.fromRGB(200, 200, 200))
|
|
795
1447
|
end
|
|
796
1448
|
|
|
797
1449
|
local consecutiveErrors = 0
|
|
798
1450
|
|
|
799
|
-
while isRunning and not isManualDisconnect and currentLoopId == loopId do
|
|
800
|
-
-- Stream / polling snapshot dari server
|
|
1451
|
+
while isRunning and not isManualDisconnect and currentLoopId == loopId and currentTransport == "longpoll" do
|
|
801
1452
|
local streamOk, streamRaw = pcall(function()
|
|
802
1453
|
return HttpService:GetAsync(
|
|
803
|
-
STREAM_URL .. "?studioId=" .. HttpService:UrlEncode(STUDIO_ID),
|
|
1454
|
+
STREAM_URL .. "?studioId=" .. HttpService:UrlEncode(STUDIO_ID) .. "&placeId=" .. tostring(game.PlaceId),
|
|
804
1455
|
false
|
|
805
1456
|
)
|
|
806
1457
|
end)
|
|
@@ -813,28 +1464,26 @@ function startConnectionLoop()
|
|
|
813
1464
|
serverReachable = true
|
|
814
1465
|
if ui then
|
|
815
1466
|
ui:setStatus(mcpAgentActive and "connected" or "server_connected")
|
|
816
|
-
ui:addLog("[System] ✅ Server MCP terhubung
|
|
1467
|
+
ui:addLog("[System] ✅ Server MCP terhubung via Long-Polling.", Color3.fromRGB(100, 255, 150))
|
|
817
1468
|
end
|
|
818
1469
|
end
|
|
819
1470
|
|
|
820
|
-
-- Parse baris-baris SSE
|
|
821
1471
|
for line in (streamRaw .. "\n"):gmatch("([^\n]*)\n") do
|
|
822
1472
|
line = line:gsub("\r", "")
|
|
823
1473
|
if line ~= "" then
|
|
824
1474
|
handleSSELine(line)
|
|
825
1475
|
end
|
|
826
1476
|
end
|
|
827
|
-
task.wait(
|
|
1477
|
+
task.wait()
|
|
828
1478
|
else
|
|
829
1479
|
consecutiveErrors = consecutiveErrors + 1
|
|
830
|
-
-- Toleransi 2x gagal sebelum menandai server unreachable
|
|
831
1480
|
if consecutiveErrors >= 2 then
|
|
832
1481
|
if serverReachable then
|
|
833
1482
|
serverReachable = false
|
|
834
1483
|
mcpAgentActive = false
|
|
835
1484
|
if ui then
|
|
836
1485
|
ui:setStatus("waiting")
|
|
837
|
-
ui:addLog("[System] ⚠️ Server MCP tidak dapat dijangkau.
|
|
1486
|
+
ui:addLog("[System] ⚠️ Server MCP tidak dapat dijangkau. Menghubungkan ulang...", Color3.fromRGB(255, 150, 50))
|
|
838
1487
|
end
|
|
839
1488
|
end
|
|
840
1489
|
end
|
|
@@ -844,24 +1493,125 @@ function startConnectionLoop()
|
|
|
844
1493
|
end
|
|
845
1494
|
end
|
|
846
1495
|
|
|
847
|
-
isRunning
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
1496
|
+
if currentTransport == "longpoll" and (not isRunning or isManualDisconnect) then
|
|
1497
|
+
isRunning = false
|
|
1498
|
+
serverReachable = false
|
|
1499
|
+
mcpAgentActive = false
|
|
1500
|
+
if ui then ui:setStatus("disconnected") end
|
|
1501
|
+
end
|
|
1502
|
+
end
|
|
1503
|
+
|
|
1504
|
+
-- ── Connection Manager: WebSocket Utama + Fallback Long-Polling ──────────────
|
|
1505
|
+
function startConnectionManager()
|
|
1506
|
+
if isRunning then return end
|
|
1507
|
+
isRunning = true
|
|
1508
|
+
isManualDisconnect = false
|
|
1509
|
+
|
|
1510
|
+
local cap = detectWebSocketCapability()
|
|
1511
|
+
if cap then
|
|
1512
|
+
task.spawn(function()
|
|
1513
|
+
local wsFailures = 0
|
|
1514
|
+
local backoff = 0.5
|
|
1515
|
+
|
|
1516
|
+
while isRunning and not isManualDisconnect and wsFailures < 3 do
|
|
1517
|
+
currentTransport = "websocket"
|
|
1518
|
+
if ui then
|
|
1519
|
+
ui:setStatus("waiting")
|
|
1520
|
+
ui:addLog("[System] Menghubungkan via WebSocket (Port 3055)...", Color3.fromRGB(200, 200, 255))
|
|
1521
|
+
end
|
|
1522
|
+
|
|
1523
|
+
local wrapper, err = createWsClient(WS_URL)
|
|
1524
|
+
if not wrapper then
|
|
1525
|
+
-- Coba fallback ke IP 127.0.0.1
|
|
1526
|
+
wrapper, err = createWsClient(WS_URL_IP)
|
|
1527
|
+
end
|
|
1528
|
+
|
|
1529
|
+
if wrapper then
|
|
1530
|
+
activeWsWrapper = wrapper
|
|
1531
|
+
serverReachable = true
|
|
1532
|
+
if ui then
|
|
1533
|
+
ui:setStatus(mcpAgentActive and "connected" or "server_connected")
|
|
1534
|
+
ui:addLog("[System] ⚡ WebSocket Terhubung! (Ultra Low-Latency <1ms)", Color3.fromRGB(100, 255, 150))
|
|
1535
|
+
end
|
|
1536
|
+
|
|
1537
|
+
-- Kirim pesan registrasi awal
|
|
1538
|
+
local regOk = wrapper:Send(HttpService:JSONEncode({
|
|
1539
|
+
kind = "register",
|
|
1540
|
+
studioId = STUDIO_ID,
|
|
1541
|
+
placeId = game.PlaceId,
|
|
1542
|
+
pluginsVersion = 2,
|
|
1543
|
+
}))
|
|
1544
|
+
|
|
1545
|
+
if regOk then
|
|
1546
|
+
wsFailures = 0
|
|
1547
|
+
backoff = 0.5
|
|
1548
|
+
|
|
1549
|
+
local closedSignal = Instance.new("BindableEvent")
|
|
1550
|
+
wrapper:BindEvents(
|
|
1551
|
+
function(msg)
|
|
1552
|
+
handleWsMessage(wrapper, msg)
|
|
1553
|
+
end,
|
|
1554
|
+
function()
|
|
1555
|
+
closedSignal:Fire()
|
|
1556
|
+
end
|
|
1557
|
+
)
|
|
1558
|
+
|
|
1559
|
+
closedSignal.Event:Wait()
|
|
1560
|
+
closedSignal:Destroy()
|
|
1561
|
+
end
|
|
1562
|
+
|
|
1563
|
+
if activeWsWrapper == wrapper then
|
|
1564
|
+
activeWsWrapper = nil
|
|
1565
|
+
end
|
|
1566
|
+
wrapper:Close()
|
|
1567
|
+
end
|
|
1568
|
+
|
|
1569
|
+
if isManualDisconnect or not isRunning then break end
|
|
1570
|
+
|
|
1571
|
+
wsFailures = wsFailures + 1
|
|
1572
|
+
if wsFailures < 3 then
|
|
1573
|
+
if ui then
|
|
1574
|
+
ui:setStatus("waiting")
|
|
1575
|
+
ui:addLog("[System] ⚠️ WebSocket terputus. Reconnect dalam " .. string.format("%.1f", backoff) .. "s...", Color3.fromRGB(255, 180, 50))
|
|
1576
|
+
end
|
|
1577
|
+
task.wait(backoff)
|
|
1578
|
+
backoff = math.min(backoff * 2, 5.0)
|
|
1579
|
+
end
|
|
1580
|
+
end
|
|
1581
|
+
|
|
1582
|
+
-- Jika WS gagal 3x berturut-turut dan plugin masih aktif -> Beralih ke Long-Polling!
|
|
1583
|
+
if isRunning and not isManualDisconnect then
|
|
1584
|
+
if ui then
|
|
1585
|
+
ui:addLog("[System] ℹ️ Mengalihkan ke Mode Fallback Long-Polling...", Color3.fromRGB(255, 200, 100))
|
|
1586
|
+
end
|
|
1587
|
+
startLongPollingLoop()
|
|
1588
|
+
end
|
|
1589
|
+
end)
|
|
1590
|
+
else
|
|
1591
|
+
-- WebSocket tidak didukung di environment Studio ini -> langsung Long-Polling
|
|
1592
|
+
if ui then
|
|
1593
|
+
ui:addLog("[System] ℹ️ WebSocket tidak terdeteksi. Menggunakan Long-Polling.", Color3.fromRGB(200, 200, 200))
|
|
1594
|
+
end
|
|
1595
|
+
startLongPollingLoop()
|
|
1596
|
+
end
|
|
851
1597
|
end
|
|
852
1598
|
|
|
853
1599
|
-- ── Plugin Unloading ──────────────────────────────────────────────────────────
|
|
854
1600
|
plugin.Unloading:Connect(function()
|
|
855
1601
|
isManualDisconnect = true
|
|
856
1602
|
isRunning = false
|
|
1603
|
+
if activeWsWrapper then
|
|
1604
|
+
activeWsWrapper:Close()
|
|
1605
|
+
activeWsWrapper = nil
|
|
1606
|
+
end
|
|
857
1607
|
end)
|
|
858
1608
|
|
|
859
1609
|
-- ══════════════════════════════════════════════════════════════════════
|
|
860
|
-
-- STARTUP: Langsung mulai connection
|
|
1610
|
+
-- STARTUP: Langsung mulai connection manager
|
|
861
1611
|
-- ══════════════════════════════════════════════════════════════════════
|
|
862
1612
|
task.spawn(function()
|
|
863
1613
|
task.wait(0.5) -- Tunggu UI siap
|
|
864
|
-
|
|
1614
|
+
startConnectionManager()
|
|
865
1615
|
end)
|
|
866
1616
|
]]></ProtectedString>
|
|
867
1617
|
</Properties>
|
|
@@ -993,6 +1743,118 @@ return function(targetInstance, data, ctx, targetPath)
|
|
|
993
1743
|
affected_lines = affectedLines
|
|
994
1744
|
}
|
|
995
1745
|
end
|
|
1746
|
+
]]></ProtectedString>
|
|
1747
|
+
</Properties>
|
|
1748
|
+
</Item>
|
|
1749
|
+
<Item class="ModuleScript" referent="RBX_preview_asset">
|
|
1750
|
+
<Properties>
|
|
1751
|
+
<BinaryString name="AttributesSerialize"></BinaryString>
|
|
1752
|
+
<SecurityCapabilities name="Capabilities">0</SecurityCapabilities>
|
|
1753
|
+
<bool name="DefinesCapabilities">false</bool>
|
|
1754
|
+
<bool name="Disabled">false</bool>
|
|
1755
|
+
<Content name="LinkedSource"><null></null></Content>
|
|
1756
|
+
<string name="Name">preview_asset</string>
|
|
1757
|
+
<int64 name="SourceAssetId">-1</int64>
|
|
1758
|
+
<BinaryString name="Tags"></BinaryString>
|
|
1759
|
+
<ProtectedString name="Source"><![CDATA[--!strict
|
|
1760
|
+
-- ============================================================================
|
|
1761
|
+
-- Skill Module: preview_asset
|
|
1762
|
+
-- Deskripsi: Pratinjau & Pemindaian Keamanan Aset Creator Store Secara Terisolasi
|
|
1763
|
+
-- ============================================================================
|
|
1764
|
+
|
|
1765
|
+
local HttpService = game:GetService("HttpService")
|
|
1766
|
+
local InsertService = game:GetService("InsertService")
|
|
1767
|
+
|
|
1768
|
+
local SecurityScanner = require(script.Parent:WaitForChild("security_scanner"))
|
|
1769
|
+
|
|
1770
|
+
return function(targetInstance: Instance?, data: any, ctx: any, targetPath: string?)
|
|
1771
|
+
local assetIdStr = ""
|
|
1772
|
+
local deepScan = true
|
|
1773
|
+
|
|
1774
|
+
if typeof(data) == "string" then
|
|
1775
|
+
local success, parsed = pcall(function()
|
|
1776
|
+
return HttpService:JSONDecode(data)
|
|
1777
|
+
end)
|
|
1778
|
+
if success and typeof(parsed) == "table" then
|
|
1779
|
+
assetIdStr = tostring(parsed.asset_id or parsed.assetId or "")
|
|
1780
|
+
if parsed.deep_scan ~= nil then deepScan = parsed.deep_scan end
|
|
1781
|
+
else
|
|
1782
|
+
assetIdStr = data
|
|
1783
|
+
end
|
|
1784
|
+
elseif typeof(data) == "table" then
|
|
1785
|
+
assetIdStr = tostring(data.asset_id or data.assetId or "")
|
|
1786
|
+
if data.deep_scan ~= nil then deepScan = data.deep_scan end
|
|
1787
|
+
end
|
|
1788
|
+
|
|
1789
|
+
local cleanIdStr = string.gsub(assetIdStr, "rbxassetid://", "")
|
|
1790
|
+
cleanIdStr = string.gsub(cleanIdStr, "%s+", "")
|
|
1791
|
+
local assetId = tonumber(cleanIdStr)
|
|
1792
|
+
|
|
1793
|
+
if not assetId or assetId <= 0 then
|
|
1794
|
+
return { status = "error", error = "Asset ID tidak valid. Berikan Asset ID angka (contoh: 123456789)." }
|
|
1795
|
+
end
|
|
1796
|
+
|
|
1797
|
+
-- Muat Aset secara terisolasi (unparented / memory only)
|
|
1798
|
+
local ok, model = pcall(function()
|
|
1799
|
+
return InsertService:LoadAsset(assetId)
|
|
1800
|
+
end)
|
|
1801
|
+
|
|
1802
|
+
if not ok or not model then
|
|
1803
|
+
return {
|
|
1804
|
+
status = "error",
|
|
1805
|
+
error = string.format("Gagal memuat aset ID %d. Pastikan Asset ID publik dan terdaftar di Creator Store.", assetId)
|
|
1806
|
+
}
|
|
1807
|
+
end
|
|
1808
|
+
|
|
1809
|
+
local scanResult
|
|
1810
|
+
local scanOk, err = pcall(function()
|
|
1811
|
+
scanResult = SecurityScanner.scanInstance(model, deepScan)
|
|
1812
|
+
end)
|
|
1813
|
+
|
|
1814
|
+
local rootName = model.Name
|
|
1815
|
+
local childrenOverview = {}
|
|
1816
|
+
for _, child in ipairs(model:GetChildren()) do
|
|
1817
|
+
table.insert(childrenOverview, {
|
|
1818
|
+
name = child.Name,
|
|
1819
|
+
className = child.ClassName,
|
|
1820
|
+
})
|
|
1821
|
+
end
|
|
1822
|
+
|
|
1823
|
+
-- Hancurkan model sementara dari memori (Cleanup Mutlak)
|
|
1824
|
+
pcall(function()
|
|
1825
|
+
model:Destroy()
|
|
1826
|
+
end)
|
|
1827
|
+
|
|
1828
|
+
if not scanOk or not scanResult then
|
|
1829
|
+
return {
|
|
1830
|
+
status = "error",
|
|
1831
|
+
error = "Gagal memindai aset: " .. tostring(err)
|
|
1832
|
+
}
|
|
1833
|
+
end
|
|
1834
|
+
|
|
1835
|
+
local recommendation = "Aman untuk dimasukkan langsung menggunakan insert_asset."
|
|
1836
|
+
if scanResult.riskLevel == "DANGEROUS" then
|
|
1837
|
+
recommendation = "PERINGATAN: Aset ini memiliki ancaman BERBAHAYA! Wajib gunakan 'insert_asset' dengan strip_scripts = true, atau hindari aset ini."
|
|
1838
|
+
elseif scanResult.riskLevel == "WARNING" then
|
|
1839
|
+
recommendation = "Perhatian: Aset mengandung skrip atau peringatan moderat. Periksa kembali atau gunakan strip_scripts = true jika skrip tidak diperlukan."
|
|
1840
|
+
end
|
|
1841
|
+
|
|
1842
|
+
return {
|
|
1843
|
+
status = "success",
|
|
1844
|
+
result = {
|
|
1845
|
+
assetId = assetId,
|
|
1846
|
+
assetName = rootName,
|
|
1847
|
+
riskLevel = scanResult.riskLevel,
|
|
1848
|
+
recommendation = recommendation,
|
|
1849
|
+
stats = scanResult.stats,
|
|
1850
|
+
highThreatsCount = scanResult.highThreats,
|
|
1851
|
+
mediumThreatsCount = scanResult.mediumThreats,
|
|
1852
|
+
threats = scanResult.threats,
|
|
1853
|
+
topLevelChildren = childrenOverview,
|
|
1854
|
+
}
|
|
1855
|
+
}
|
|
1856
|
+
end
|
|
1857
|
+
|
|
996
1858
|
]]></ProtectedString>
|
|
997
1859
|
</Properties>
|
|
998
1860
|
</Item>
|
|
@@ -1025,6 +1887,100 @@ end
|
|
|
1025
1887
|
return { status = "error", error = "Target BUKAN Script (ClassName: " .. targetInstance.ClassName .. ")." }
|
|
1026
1888
|
end
|
|
1027
1889
|
end
|
|
1890
|
+
]]></ProtectedString>
|
|
1891
|
+
</Properties>
|
|
1892
|
+
</Item>
|
|
1893
|
+
<Item class="ModuleScript" referent="RBX_scan_security">
|
|
1894
|
+
<Properties>
|
|
1895
|
+
<BinaryString name="AttributesSerialize"></BinaryString>
|
|
1896
|
+
<SecurityCapabilities name="Capabilities">0</SecurityCapabilities>
|
|
1897
|
+
<bool name="DefinesCapabilities">false</bool>
|
|
1898
|
+
<bool name="Disabled">false</bool>
|
|
1899
|
+
<Content name="LinkedSource"><null></null></Content>
|
|
1900
|
+
<string name="Name">scan_security</string>
|
|
1901
|
+
<int64 name="SourceAssetId">-1</int64>
|
|
1902
|
+
<BinaryString name="Tags"></BinaryString>
|
|
1903
|
+
<ProtectedString name="Source"><![CDATA[--!strict
|
|
1904
|
+
-- ============================================================================
|
|
1905
|
+
-- Skill Module: scan_security
|
|
1906
|
+
-- Deskripsi: Audit Keamanan Instance/Model yang Sudah Ada di Tempat Kerja Studio
|
|
1907
|
+
-- ============================================================================
|
|
1908
|
+
|
|
1909
|
+
local HttpService = game:GetService("HttpService")
|
|
1910
|
+
local SecurityScanner = require(script.Parent:WaitForChild("security_scanner"))
|
|
1911
|
+
|
|
1912
|
+
return function(targetInstance: Instance?, data: any, ctx: any, targetPath: string?)
|
|
1913
|
+
local target = targetInstance or game.Workspace
|
|
1914
|
+
local deepScan = true
|
|
1915
|
+
local cleanThreats = false
|
|
1916
|
+
|
|
1917
|
+
if typeof(data) == "string" and #data > 0 then
|
|
1918
|
+
local success, parsed = pcall(function()
|
|
1919
|
+
return HttpService:JSONDecode(data)
|
|
1920
|
+
end)
|
|
1921
|
+
if success and typeof(parsed) == "table" then
|
|
1922
|
+
if parsed.deep_scan ~= nil then deepScan = parsed.deep_scan end
|
|
1923
|
+
if parsed.clean_threats ~= nil then cleanThreats = parsed.clean_threats end
|
|
1924
|
+
end
|
|
1925
|
+
elseif typeof(data) == "table" then
|
|
1926
|
+
if data.deep_scan ~= nil then deepScan = data.deep_scan end
|
|
1927
|
+
if data.clean_threats ~= nil then cleanThreats = data.clean_threats end
|
|
1928
|
+
end
|
|
1929
|
+
|
|
1930
|
+
local scanResult
|
|
1931
|
+
local ok, err = pcall(function()
|
|
1932
|
+
scanResult = SecurityScanner.scanInstance(target, deepScan)
|
|
1933
|
+
end)
|
|
1934
|
+
|
|
1935
|
+
if not ok or not scanResult then
|
|
1936
|
+
return {
|
|
1937
|
+
status = "error",
|
|
1938
|
+
error = "Gagal memindai target: " .. tostring(err)
|
|
1939
|
+
}
|
|
1940
|
+
end
|
|
1941
|
+
|
|
1942
|
+
local cleanedScripts = {}
|
|
1943
|
+
if cleanThreats and #scanResult.threats > 0 then
|
|
1944
|
+
-- Hapus skrip yang terdeteksi bahaya (HIGH severity)
|
|
1945
|
+
local scriptsToDestroy = {}
|
|
1946
|
+
for _, threat in ipairs(scanResult.threats) do
|
|
1947
|
+
if threat.severity == "HIGH" and threat.scriptPath then
|
|
1948
|
+
scriptsToDestroy[threat.scriptPath] = true
|
|
1949
|
+
end
|
|
1950
|
+
end
|
|
1951
|
+
|
|
1952
|
+
local function purge(instance: Instance)
|
|
1953
|
+
for _, desc in ipairs(instance:GetDescendants()) do
|
|
1954
|
+
if desc:IsA("LuaSourceContainer") and scriptsToDestroy[desc:GetFullName()] then
|
|
1955
|
+
table.insert(cleanedScripts, {
|
|
1956
|
+
name = desc.Name,
|
|
1957
|
+
className = desc.ClassName,
|
|
1958
|
+
path = desc:GetFullName(),
|
|
1959
|
+
})
|
|
1960
|
+
pcall(function() desc:Destroy() end)
|
|
1961
|
+
end
|
|
1962
|
+
end
|
|
1963
|
+
end
|
|
1964
|
+
|
|
1965
|
+
purge(target)
|
|
1966
|
+
end
|
|
1967
|
+
|
|
1968
|
+
return {
|
|
1969
|
+
status = "success",
|
|
1970
|
+
result = {
|
|
1971
|
+
target = target:GetFullName(),
|
|
1972
|
+
riskLevel = scanResult.riskLevel,
|
|
1973
|
+
stats = scanResult.stats,
|
|
1974
|
+
highThreatsCount = scanResult.highThreats,
|
|
1975
|
+
mediumThreatsCount = scanResult.mediumThreats,
|
|
1976
|
+
threats = scanResult.threats,
|
|
1977
|
+
cleanedThreatsCount = #cleanedScripts,
|
|
1978
|
+
cleanedScripts = cleanedScripts,
|
|
1979
|
+
isCleaned = cleanThreats,
|
|
1980
|
+
}
|
|
1981
|
+
}
|
|
1982
|
+
end
|
|
1983
|
+
|
|
1028
1984
|
]]></ProtectedString>
|
|
1029
1985
|
</Properties>
|
|
1030
1986
|
</Item>
|
|
@@ -1170,6 +2126,365 @@ end
|
|
|
1170
2126
|
return { status = "success", result = "Tidak ditemukan instance dengan nama: " .. searchName }
|
|
1171
2127
|
end
|
|
1172
2128
|
end
|
|
2129
|
+
]]></ProtectedString>
|
|
2130
|
+
</Properties>
|
|
2131
|
+
</Item>
|
|
2132
|
+
<Item class="ModuleScript" referent="RBX_security_scanner">
|
|
2133
|
+
<Properties>
|
|
2134
|
+
<BinaryString name="AttributesSerialize"></BinaryString>
|
|
2135
|
+
<SecurityCapabilities name="Capabilities">0</SecurityCapabilities>
|
|
2136
|
+
<bool name="DefinesCapabilities">false</bool>
|
|
2137
|
+
<bool name="Disabled">false</bool>
|
|
2138
|
+
<Content name="LinkedSource"><null></null></Content>
|
|
2139
|
+
<string name="Name">security_scanner</string>
|
|
2140
|
+
<int64 name="SourceAssetId">-1</int64>
|
|
2141
|
+
<BinaryString name="Tags"></BinaryString>
|
|
2142
|
+
<ProtectedString name="Source"><![CDATA[--!strict
|
|
2143
|
+
-- ============================================================================
|
|
2144
|
+
-- Module: security_scanner
|
|
2145
|
+
-- Deskripsi: Engine Heuristik Pemindaian Keamanan & Backdoor Detection
|
|
2146
|
+
-- Standar: Business-Grade, Zero-Execution Isolation, Comprehensive Threat Rules
|
|
2147
|
+
-- ============================================================================
|
|
2148
|
+
|
|
2149
|
+
local SecurityScanner = {}
|
|
2150
|
+
|
|
2151
|
+
-- Daftar nama skrip yang sering digunakan oleh virus/backdoor klasik Roblox
|
|
2152
|
+
local SUSPICIOUS_SCRIPT_NAMES = {
|
|
2153
|
+
"[Vv]accine",
|
|
2154
|
+
"[Aa]nti[Ll]ag",
|
|
2155
|
+
"[Ff]ix[Ll]ag",
|
|
2156
|
+
"[Ii]nfection",
|
|
2157
|
+
"[Ff]ire",
|
|
2158
|
+
"[Cc]lean",
|
|
2159
|
+
"[Vv]irus",
|
|
2160
|
+
"4[Dd]",
|
|
2161
|
+
"4[Dd]_[Pp]lugin",
|
|
2162
|
+
"[Ss]pread",
|
|
2163
|
+
"[Ll]ag[Ff]ixer",
|
|
2164
|
+
"[Dd]up",
|
|
2165
|
+
}
|
|
2166
|
+
|
|
2167
|
+
-- Aturan Pola Heuristik Kode Berbahaya
|
|
2168
|
+
local THREAT_PATTERNS = {
|
|
2169
|
+
{
|
|
2170
|
+
id = "BACKDOOR_REQUIRE",
|
|
2171
|
+
pattern = "require%s*%(%s*%d+%s*%)",
|
|
2172
|
+
severity = "HIGH",
|
|
2173
|
+
name = "Backdoor Numeric Require",
|
|
2174
|
+
description = "Memanggil modul eksternal pihak ketiga via Asset ID numerik (indikasi backdoor/remote loader).",
|
|
2175
|
+
},
|
|
2176
|
+
{
|
|
2177
|
+
id = "LOADSTRING_EXEC",
|
|
2178
|
+
pattern = "loadstring%s*%(",
|
|
2179
|
+
severity = "HIGH",
|
|
2180
|
+
name = "Dynamic Code Execution (loadstring)",
|
|
2181
|
+
description = "Mengeksekusi string kode dinamis yang tidak dapat diaudit.",
|
|
2182
|
+
},
|
|
2183
|
+
{
|
|
2184
|
+
id = "GETFENV_SETFENV",
|
|
2185
|
+
pattern = "[gs]etfenv%s*%(",
|
|
2186
|
+
severity = "HIGH",
|
|
2187
|
+
name = "Environment Manipulation (getfenv/setfenv)",
|
|
2188
|
+
description = "Memanipulasi environment global Luau untuk mengelabui analisis statis.",
|
|
2189
|
+
},
|
|
2190
|
+
{
|
|
2191
|
+
id = "DISCORD_WEBHOOK",
|
|
2192
|
+
pattern = "discord[a-z]*%.com/api/webhooks",
|
|
2193
|
+
severity = "HIGH",
|
|
2194
|
+
name = "Discord Webhook Exfiltration",
|
|
2195
|
+
description = "Mencoba mengirimkan data game atau informasi pemain ke webhook eksternal Discord.",
|
|
2196
|
+
},
|
|
2197
|
+
{
|
|
2198
|
+
id = "IP_LOGGER",
|
|
2199
|
+
pattern = "webhook%.site|grabify%.link|iplogger|blasze",
|
|
2200
|
+
severity = "HIGH",
|
|
2201
|
+
name = "External IP/Data Logger",
|
|
2202
|
+
description = "Menghubungi domain pencatat IP atau data capture pihak ketiga.",
|
|
2203
|
+
},
|
|
2204
|
+
{
|
|
2205
|
+
id = "FORCED_PURCHASE",
|
|
2206
|
+
pattern = "MarketplaceService%s*:%s*Prompt[A-Za-z]*Purchase",
|
|
2207
|
+
severity = "MEDIUM",
|
|
2208
|
+
name = "Forced Robux/Product Purchase",
|
|
2209
|
+
description = "Memicu dialog pembelian paksa Gamepass atau Developer Product.",
|
|
2210
|
+
},
|
|
2211
|
+
{
|
|
2212
|
+
id = "FORCED_TELEPORT",
|
|
2213
|
+
pattern = "TeleportService%s*:%s*Teleport",
|
|
2214
|
+
severity = "MEDIUM",
|
|
2215
|
+
name = "Forced Game Teleportation",
|
|
2216
|
+
description = "Memindahkan pemain secara paksa ke game/place lain.",
|
|
2217
|
+
},
|
|
2218
|
+
{
|
|
2219
|
+
id = "CHAR_TABLE_OBFUSCATION",
|
|
2220
|
+
pattern = "string%.char%s*%(%s*%d+%s*,%s*%d+%s*,%s*%d+",
|
|
2221
|
+
severity = "HIGH",
|
|
2222
|
+
name = "Obfuscated string.char Array",
|
|
2223
|
+
description = "Menyusun kode rahasia tersembunyi menggunakan deretan kode karakter ASCII.",
|
|
2224
|
+
},
|
|
2225
|
+
}
|
|
2226
|
+
|
|
2227
|
+
-- Helper: Pengecekan nama script mencurigakan
|
|
2228
|
+
local function checkSuspiciousName(name: string): boolean
|
|
2229
|
+
for _, pattern in ipairs(SUSPICIOUS_SCRIPT_NAMES) do
|
|
2230
|
+
if string.find(name, pattern) then
|
|
2231
|
+
return true
|
|
2232
|
+
end
|
|
2233
|
+
end
|
|
2234
|
+
return false
|
|
2235
|
+
end
|
|
2236
|
+
|
|
2237
|
+
-- Helper: Deteksi obfuscation byte escape (\108\111\97\100) atau hex (\x6c\x6f)
|
|
2238
|
+
local function checkByteObfuscation(source: string): (boolean, string?)
|
|
2239
|
+
local byteCount = 0
|
|
2240
|
+
for _ in string.gmatch(source, "\\[0-9][0-9][0-9]") do
|
|
2241
|
+
byteCount = byteCount + 1
|
|
2242
|
+
if byteCount >= 4 then
|
|
2243
|
+
return true, "Terdeteksi deretan escape byte ASCII (\\ddd) berulang."
|
|
2244
|
+
end
|
|
2245
|
+
end
|
|
2246
|
+
|
|
2247
|
+
local hexCount = 0
|
|
2248
|
+
for _ in string.gmatch(source, "\\x[0-9a-fA-F][0-9a-fA-F]") do
|
|
2249
|
+
hexCount = hexCount + 1
|
|
2250
|
+
if hexCount >= 4 then
|
|
2251
|
+
return true, "Terdeteksi deretan escape hex (\\xHH) berulang."
|
|
2252
|
+
end
|
|
2253
|
+
end
|
|
2254
|
+
|
|
2255
|
+
return false, nil
|
|
2256
|
+
end
|
|
2257
|
+
|
|
2258
|
+
-- Helper: Deteksi kode yang sengaja disembunyikan dengan spasi/baris kosong berlebih
|
|
2259
|
+
local function checkOffscreenHiding(source: string): (boolean, string?)
|
|
2260
|
+
-- Cek spasi horizontal masif (> 80 spasi berturut-turut)
|
|
2261
|
+
if string.find(source, " " .. string.rep(" ", 80)) then
|
|
2262
|
+
return true, "Kode sengaja didorong ke kanan dengan lebih dari 80 spasi agar tersembunyi dari layar Studio."
|
|
2263
|
+
end
|
|
2264
|
+
|
|
2265
|
+
-- Cek baris kosong masif di awal skrip (> 15 baris kosong)
|
|
2266
|
+
local leadingNewlines = 0
|
|
2267
|
+
for line in string.gmatch(source, "([^\r\n]*)\r?\n") do
|
|
2268
|
+
if string.gsub(line, "%s+", "") == "" then
|
|
2269
|
+
leadingNewlines = leadingNewlines + 1
|
|
2270
|
+
if leadingNewlines >= 15 then
|
|
2271
|
+
return true, "Terdapat lebih dari 15 baris kosong di awal skrip untuk menyembunyikan payload di baris bawah."
|
|
2272
|
+
end
|
|
2273
|
+
else
|
|
2274
|
+
break
|
|
2275
|
+
end
|
|
2276
|
+
end
|
|
2277
|
+
|
|
2278
|
+
return false, nil
|
|
2279
|
+
end
|
|
2280
|
+
|
|
2281
|
+
-- Analisis Source Code dari sebuah Script
|
|
2282
|
+
local function scanScriptSource(scriptInstance: Instance, source: string): { any }
|
|
2283
|
+
local threats = {}
|
|
2284
|
+
local lines = string.split(source, "\n")
|
|
2285
|
+
|
|
2286
|
+
-- 1. Scan pola heuristik baris per baris
|
|
2287
|
+
for lineIdx, line in ipairs(lines) do
|
|
2288
|
+
-- Skip baris komentar murni
|
|
2289
|
+
local trimmed = string.match(line, "^%s*(.-)%s*$") or ""
|
|
2290
|
+
if not string.sub(trimmed, 1, 2) == "--" then
|
|
2291
|
+
for _, rule in ipairs(THREAT_PATTERNS) do
|
|
2292
|
+
local s, e = string.find(line, rule.pattern)
|
|
2293
|
+
if s then
|
|
2294
|
+
local snippet = string.sub(line, math.max(1, s - 30), math.min(#line, e + 30))
|
|
2295
|
+
table.insert(threats, {
|
|
2296
|
+
severity = rule.severity,
|
|
2297
|
+
ruleId = rule.id,
|
|
2298
|
+
name = rule.name,
|
|
2299
|
+
scriptName = scriptInstance.Name,
|
|
2300
|
+
scriptPath = scriptInstance:GetFullName(),
|
|
2301
|
+
scriptClass = scriptInstance.ClassName,
|
|
2302
|
+
line = lineIdx,
|
|
2303
|
+
snippet = string.match(snippet, "^%s*(.-)%s*$") or snippet,
|
|
2304
|
+
description = rule.description,
|
|
2305
|
+
})
|
|
2306
|
+
end
|
|
2307
|
+
end
|
|
2308
|
+
end
|
|
2309
|
+
end
|
|
2310
|
+
|
|
2311
|
+
-- 2. Cek Obfuscation
|
|
2312
|
+
local isObf, obfReason = checkByteObfuscation(source)
|
|
2313
|
+
if isObf then
|
|
2314
|
+
table.insert(threats, {
|
|
2315
|
+
severity = "HIGH",
|
|
2316
|
+
ruleId = "BYTE_OBFUSCATION",
|
|
2317
|
+
name = "Byte / Hex Obfuscation Payload",
|
|
2318
|
+
scriptName = scriptInstance.Name,
|
|
2319
|
+
scriptPath = scriptInstance:GetFullName(),
|
|
2320
|
+
scriptClass = scriptInstance.ClassName,
|
|
2321
|
+
line = 1,
|
|
2322
|
+
snippet = string.sub(source, 1, 60) .. "...",
|
|
2323
|
+
description = obfReason or "Payload kode disamarkan menggunakan escape bytes.",
|
|
2324
|
+
})
|
|
2325
|
+
end
|
|
2326
|
+
|
|
2327
|
+
-- 3. Cek Off-screen Code Hiding
|
|
2328
|
+
local isHidden, hideReason = checkOffscreenHiding(source)
|
|
2329
|
+
if isHidden then
|
|
2330
|
+
table.insert(threats, {
|
|
2331
|
+
severity = "HIGH",
|
|
2332
|
+
ruleId = "OFFSCREEN_HIDING",
|
|
2333
|
+
name = "Off-Screen Hidden Code",
|
|
2334
|
+
scriptName = scriptInstance.Name,
|
|
2335
|
+
scriptPath = scriptInstance:GetFullName(),
|
|
2336
|
+
scriptClass = scriptInstance.ClassName,
|
|
2337
|
+
line = 1,
|
|
2338
|
+
snippet = "[Whitespace Padding Terdeteksi]",
|
|
2339
|
+
description = hideReason or "Kode disembunyikan menggunakan whitespace ekstrem.",
|
|
2340
|
+
})
|
|
2341
|
+
end
|
|
2342
|
+
|
|
2343
|
+
return threats
|
|
2344
|
+
end
|
|
2345
|
+
|
|
2346
|
+
-- ============================================================================
|
|
2347
|
+
-- PUBLIC API
|
|
2348
|
+
-- ============================================================================
|
|
2349
|
+
|
|
2350
|
+
-- Memindai Instance secara rekursif
|
|
2351
|
+
function SecurityScanner.scanInstance(rootInstance: Instance, deepScan: boolean?)
|
|
2352
|
+
local shouldDeepScan = (deepScan ~= false)
|
|
2353
|
+
local allThreats = {}
|
|
2354
|
+
local stats = {
|
|
2355
|
+
totalInstances = 1,
|
|
2356
|
+
models = 0,
|
|
2357
|
+
parts = 0,
|
|
2358
|
+
meshParts = 0,
|
|
2359
|
+
textures = 0,
|
|
2360
|
+
scripts = {
|
|
2361
|
+
total = 0,
|
|
2362
|
+
server = 0,
|
|
2363
|
+
localScript = 0,
|
|
2364
|
+
module = 0,
|
|
2365
|
+
},
|
|
2366
|
+
}
|
|
2367
|
+
|
|
2368
|
+
local function inspectItem(item: Instance)
|
|
2369
|
+
stats.totalInstances = stats.totalInstances + 1
|
|
2370
|
+
|
|
2371
|
+
if item:IsA("Model") then
|
|
2372
|
+
stats.models = stats.models + 1
|
|
2373
|
+
elseif item:IsA("MeshPart") then
|
|
2374
|
+
stats.meshParts = stats.meshParts + 1
|
|
2375
|
+
stats.parts = stats.parts + 1
|
|
2376
|
+
elseif item:IsA("BasePart") then
|
|
2377
|
+
stats.parts = stats.parts + 1
|
|
2378
|
+
elseif item:IsA("Texture") or item:IsA("Decal") or item:IsA("SurfaceAppearance") then
|
|
2379
|
+
stats.textures = stats.textures + 1
|
|
2380
|
+
end
|
|
2381
|
+
|
|
2382
|
+
if item:IsA("LuaSourceContainer") then
|
|
2383
|
+
stats.scripts.total = stats.scripts.total + 1
|
|
2384
|
+
if item:IsA("Script") then
|
|
2385
|
+
stats.scripts.server = stats.scripts.server + 1
|
|
2386
|
+
elseif item:IsA("LocalScript") then
|
|
2387
|
+
stats.scripts.localScript = stats.scripts.localScript + 1
|
|
2388
|
+
elseif item:IsA("ModuleScript") then
|
|
2389
|
+
stats.scripts.module = stats.scripts.module + 1
|
|
2390
|
+
end
|
|
2391
|
+
|
|
2392
|
+
-- Cek nama mencurigakan
|
|
2393
|
+
if checkSuspiciousName(item.Name) then
|
|
2394
|
+
table.insert(allThreats, {
|
|
2395
|
+
severity = "HIGH",
|
|
2396
|
+
ruleId = "SUSPICIOUS_SCRIPT_NAME",
|
|
2397
|
+
name = "Known Malicious Script Name Pattern",
|
|
2398
|
+
scriptName = item.Name,
|
|
2399
|
+
scriptPath = item:GetFullName(),
|
|
2400
|
+
scriptClass = item.ClassName,
|
|
2401
|
+
line = 0,
|
|
2402
|
+
snippet = item.Name,
|
|
2403
|
+
description = string.format("Nama skrip '%s' cocok dengan pola nama virus/backdoor klasik.", item.Name),
|
|
2404
|
+
})
|
|
2405
|
+
end
|
|
2406
|
+
|
|
2407
|
+
-- Deep scan isi source code
|
|
2408
|
+
if shouldDeepScan then
|
|
2409
|
+
local success, source = pcall(function()
|
|
2410
|
+
return (item :: any).Source
|
|
2411
|
+
end)
|
|
2412
|
+
if success and typeof(source) == "string" and #source > 0 then
|
|
2413
|
+
local scriptThreats = scanScriptSource(item, source)
|
|
2414
|
+
for _, t in ipairs(scriptThreats) do
|
|
2415
|
+
table.insert(allThreats, t)
|
|
2416
|
+
end
|
|
2417
|
+
end
|
|
2418
|
+
end
|
|
2419
|
+
end
|
|
2420
|
+
end
|
|
2421
|
+
|
|
2422
|
+
inspectItem(rootInstance)
|
|
2423
|
+
for _, desc in ipairs(rootInstance:GetDescendants()) do
|
|
2424
|
+
inspectItem(desc)
|
|
2425
|
+
end
|
|
2426
|
+
|
|
2427
|
+
-- Hitung Level Risiko
|
|
2428
|
+
local riskLevel = "SAFE"
|
|
2429
|
+
local highCount = 0
|
|
2430
|
+
local medCount = 0
|
|
2431
|
+
|
|
2432
|
+
for _, t in ipairs(allThreats) do
|
|
2433
|
+
if t.severity == "HIGH" then
|
|
2434
|
+
highCount = highCount + 1
|
|
2435
|
+
elseif t.severity == "MEDIUM" then
|
|
2436
|
+
medCount = medCount + 1
|
|
2437
|
+
end
|
|
2438
|
+
end
|
|
2439
|
+
|
|
2440
|
+
if highCount > 0 then
|
|
2441
|
+
riskLevel = "DANGEROUS"
|
|
2442
|
+
elseif medCount > 0 or stats.scripts.total > 0 then
|
|
2443
|
+
riskLevel = "WARNING"
|
|
2444
|
+
end
|
|
2445
|
+
|
|
2446
|
+
return {
|
|
2447
|
+
riskLevel = riskLevel,
|
|
2448
|
+
threats = allThreats,
|
|
2449
|
+
stats = stats,
|
|
2450
|
+
highThreats = highCount,
|
|
2451
|
+
mediumThreats = medCount,
|
|
2452
|
+
}
|
|
2453
|
+
end
|
|
2454
|
+
|
|
2455
|
+
-- Menghapus seluruh skrip dari sebuah instance (Sanitasi Total)
|
|
2456
|
+
function SecurityScanner.stripScripts(rootInstance: Instance): { any }
|
|
2457
|
+
local removed = {}
|
|
2458
|
+
|
|
2459
|
+
for _, desc in ipairs(rootInstance:GetDescendants()) do
|
|
2460
|
+
if desc:IsA("LuaSourceContainer") then
|
|
2461
|
+
table.insert(removed, {
|
|
2462
|
+
name = desc.Name,
|
|
2463
|
+
className = desc.ClassName,
|
|
2464
|
+
path = desc:GetFullName(),
|
|
2465
|
+
})
|
|
2466
|
+
pcall(function()
|
|
2467
|
+
desc:Destroy()
|
|
2468
|
+
end)
|
|
2469
|
+
end
|
|
2470
|
+
end
|
|
2471
|
+
|
|
2472
|
+
if rootInstance:IsA("LuaSourceContainer") then
|
|
2473
|
+
table.insert(removed, {
|
|
2474
|
+
name = rootInstance.Name,
|
|
2475
|
+
className = rootInstance.ClassName,
|
|
2476
|
+
path = rootInstance:GetFullName(),
|
|
2477
|
+
})
|
|
2478
|
+
pcall(function()
|
|
2479
|
+
rootInstance:Destroy()
|
|
2480
|
+
end)
|
|
2481
|
+
end
|
|
2482
|
+
|
|
2483
|
+
return removed
|
|
2484
|
+
end
|
|
2485
|
+
|
|
2486
|
+
return SecurityScanner
|
|
2487
|
+
|
|
1173
2488
|
]]></ProtectedString>
|
|
1174
2489
|
</Properties>
|
|
1175
2490
|
</Item>
|
|
@@ -1197,6 +2512,10 @@ end
|
|
|
1197
2512
|
local currentVal = targetInstance[prop]
|
|
1198
2513
|
if typeof(currentVal) == "Vector3" and type(val) == "table" and #val == 3 then
|
|
1199
2514
|
targetInstance[prop] = Vector3.new(val[1], val[2], val[3])
|
|
2515
|
+
elseif typeof(currentVal) == "Vector2" and type(val) == "table" and #val >= 2 then
|
|
2516
|
+
targetInstance[prop] = Vector2.new(val[1], val[2])
|
|
2517
|
+
elseif typeof(currentVal) == "UDim" and type(val) == "table" and #val >= 2 then
|
|
2518
|
+
targetInstance[prop] = UDim.new(val[1], val[2])
|
|
1200
2519
|
elseif typeof(currentVal) == "Color3" and type(val) == "table" and #val == 3 then
|
|
1201
2520
|
local r, g, b = val[1], val[2], val[3]
|
|
1202
2521
|
if r > 1 or g > 1 or b > 1 then
|
|
@@ -1206,6 +2525,12 @@ end
|
|
|
1206
2525
|
end
|
|
1207
2526
|
elseif typeof(currentVal) == "UDim2" and type(val) == "table" and #val == 4 then
|
|
1208
2527
|
targetInstance[prop] = UDim2.new(val[1], val[2], val[3], val[4])
|
|
2528
|
+
elseif typeof(currentVal) == "NumberRange" and type(val) == "table" then
|
|
2529
|
+
targetInstance[prop] = NumberRange.new(val[1], val[2] or val[1])
|
|
2530
|
+
elseif typeof(currentVal) == "Rect" and type(val) == "table" and #val >= 4 then
|
|
2531
|
+
targetInstance[prop] = Rect.new(val[1], val[2], val[3], val[4])
|
|
2532
|
+
elseif typeof(currentVal) == "BrickColor" and type(val) == "string" then
|
|
2533
|
+
targetInstance[prop] = BrickColor.new(val)
|
|
1209
2534
|
elseif typeof(currentVal) == "CFrame" and type(val) == "table" then
|
|
1210
2535
|
targetInstance[prop] = CFrame.new(unpack(val))
|
|
1211
2536
|
elseif typeof(currentVal) == "EnumItem" and type(val) == "string" then
|
|
@@ -1366,7 +2691,7 @@ function UIManager.init(pluginInstance, studioId)
|
|
|
1366
2691
|
|
|
1367
2692
|
-- Version
|
|
1368
2693
|
local versionLabel = Instance.new("TextLabel")
|
|
1369
|
-
versionLabel.Text = "v2.
|
|
2694
|
+
versionLabel.Text = "v2.3.0"
|
|
1370
2695
|
versionLabel.Font = Enum.Font.Gotham
|
|
1371
2696
|
versionLabel.TextSize = 11
|
|
1372
2697
|
versionLabel.TextColor3 = Color3.fromRGB(120, 120, 130)
|