golem-bridge 1.0.1 → 1.0.2
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 +36 -1
- package/cli.js +167 -50
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -8,12 +8,15 @@ distributed through the Roblox Creator Store, not from here.
|
|
|
8
8
|
|
|
9
9
|
## How it connects
|
|
10
10
|
|
|
11
|
-
The plugin shows one line in Studio (popup on
|
|
11
|
+
The plugin shows one line in Studio (popup on every start, also in Settings):
|
|
12
12
|
|
|
13
13
|
```
|
|
14
14
|
Connect to my Roblox Studio, Run: npx golem-bridge connect <channelId>
|
|
15
15
|
```
|
|
16
16
|
|
|
17
|
+
The line is a session token: Studio mints a fresh channel on every start
|
|
18
|
+
and wipes the old one. After a restart, re-run connect with the new line.
|
|
19
|
+
|
|
17
20
|
The user sends that line to their AI. It downloads this package, which asks
|
|
18
21
|
the plugin for its two connection files and stores them under `./.golem/`:
|
|
19
22
|
|
|
@@ -29,3 +32,35 @@ reads `./.golem/golem.md` for the full tool reference.
|
|
|
29
32
|
|
|
30
33
|
- `cli.js` - source of the `golem-bridge` package
|
|
31
34
|
- `package.json` - npm manifest
|
|
35
|
+
|
|
36
|
+
## Notes
|
|
37
|
+
|
|
38
|
+
- Relay: `https://roblox-golem-default-rtdb.firebaseio.com/`. The channel id
|
|
39
|
+
is the secret. Studio mints a fresh one on every start and wipes the old
|
|
40
|
+
channel, so a leaked line dies with the session.
|
|
41
|
+
- The plugin serves `golem.py` and `golem.md` on demand. They are never
|
|
42
|
+
stored on the relay.
|
|
43
|
+
|
|
44
|
+
## Security
|
|
45
|
+
|
|
46
|
+
Trust model: whoever holds the channel ID can send commands to that Studio
|
|
47
|
+
session and read the results. Treat the setup line like a password. It
|
|
48
|
+
expires on every Studio restart.
|
|
49
|
+
|
|
50
|
+
- The CLI (`cli.js`, about 200 lines) is the only code that runs on the
|
|
51
|
+
agent side. Read it here, or fetch without writing anything:
|
|
52
|
+
`npx golem-bridge connect <channelId> --print`.
|
|
53
|
+
- `connect` only accepts hex channel IDs, talks HTTPS to the relay only
|
|
54
|
+
(never follows redirects), times out stalled requests, caps response
|
|
55
|
+
sizes, validates the payload shape before writing, and prints SHA-256
|
|
56
|
+
hashes of both files. It writes nothing unless every check passes, and
|
|
57
|
+
tells you to review both files before running anything.
|
|
58
|
+
- There are no baked-in Firebase credentials or signing keys: the channel
|
|
59
|
+
ID itself is the capability, and transport runs over HTTPS. Public client
|
|
60
|
+
code cannot hold a secret, so any "signed responses" scheme here would be
|
|
61
|
+
theater rather than security.
|
|
62
|
+
- Socket.dev flags the "URL strings" in this package (the relay address).
|
|
63
|
+
That is informational: the relay address is the product. The setup payload
|
|
64
|
+
is validated as described above before anything is written.
|
|
65
|
+
npm publish
|
|
66
|
+
```
|
package/cli.js
CHANGED
|
@@ -4,37 +4,92 @@
|
|
|
4
4
|
// golem-bridge: pulls golem.py and golem.md from the Golem Studio plugin
|
|
5
5
|
// over Firebase and writes them to ./.golem/
|
|
6
6
|
|
|
7
|
+
const crypto = require("crypto");
|
|
7
8
|
const fs = require("fs");
|
|
8
9
|
const path = require("path");
|
|
9
10
|
|
|
10
11
|
const DB_URL = "https://roblox-golem-default-rtdb.firebaseio.com";
|
|
11
12
|
const POLL_INTERVAL_MS = 2000;
|
|
12
13
|
const POLL_ATTEMPTS = 45;
|
|
14
|
+
const FETCH_TIMEOUT_MS = 30000;
|
|
15
|
+
const MAX_BODY_BYTES = 2 * 1024 * 1024;
|
|
16
|
+
const MAX_FILE_BYTES = 1024 * 1024;
|
|
17
|
+
// Channel IDs are hex tokens minted by the Studio plugin (16 chars today,
|
|
18
|
+
// tolerated 8-64 for forward compatibility). Anything else is rejected so a
|
|
19
|
+
// malformed ID can never alter the request URL.
|
|
20
|
+
const CHANNEL_RE = /^[0-9a-fA-F]{8,64}$/;
|
|
21
|
+
|
|
22
|
+
let VERSION = "unknown";
|
|
23
|
+
try {
|
|
24
|
+
VERSION = require("./package.json").version || "unknown";
|
|
25
|
+
} catch {
|
|
26
|
+
// running outside the package dir; version is informational only
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function printHelp() {
|
|
30
|
+
console.log(`golem-bridge v${VERSION} — connect an AI agent to Roblox Studio via the Golem plugin.
|
|
31
|
+
|
|
32
|
+
Usage:
|
|
33
|
+
golem-bridge connect <channelId> [--print]
|
|
34
|
+
golem-bridge --help
|
|
35
|
+
golem-bridge --version
|
|
13
36
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
37
|
+
<channelId> shown in the Golem plugin widget inside Roblox Studio.
|
|
38
|
+
Fresh on every Studio start; re-run connect after a restart.
|
|
39
|
+
--print audit mode: fetch and print both files without writing anything.
|
|
40
|
+
|
|
41
|
+
connect writes ./.golem/golem.py and ./.golem/golem.md, fetched over HTTPS
|
|
42
|
+
from your own Studio session. Review both files before running anything.`);
|
|
18
43
|
}
|
|
19
44
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
if (!
|
|
27
|
-
|
|
45
|
+
function fail(message, exitCode) {
|
|
46
|
+
console.error(`golem-bridge error: ${message}`);
|
|
47
|
+
process.exit(exitCode || 1);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function validateChannel(channelId) {
|
|
51
|
+
if (typeof channelId !== "string" || !CHANNEL_RE.test(channelId)) {
|
|
52
|
+
fail("bad channel ID (expect 8-64 hex characters — copy the full line from the Studio widget).", 2);
|
|
28
53
|
}
|
|
29
|
-
return
|
|
54
|
+
return channelId;
|
|
30
55
|
}
|
|
31
56
|
|
|
32
|
-
async function
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
|
|
57
|
+
async function fetchText(url, body) {
|
|
58
|
+
const ctrl = new AbortController();
|
|
59
|
+
const timer = setTimeout(() => ctrl.abort(), FETCH_TIMEOUT_MS);
|
|
60
|
+
try {
|
|
61
|
+
const res = await fetch(url, {
|
|
62
|
+
method: body === undefined ? "GET" : "POST",
|
|
63
|
+
headers: { "Content-Type": "application/json" },
|
|
64
|
+
body,
|
|
65
|
+
signal: ctrl.signal,
|
|
66
|
+
redirect: "error", // never follow redirects: responses must come from the relay itself
|
|
67
|
+
});
|
|
68
|
+
if (!res.ok) {
|
|
69
|
+
throw new Error(`HTTP ${res.status} from the relay`);
|
|
70
|
+
}
|
|
71
|
+
const text = await res.text();
|
|
72
|
+
if (text.length > MAX_BODY_BYTES) {
|
|
73
|
+
throw new Error("relay response too large, refusing to parse it");
|
|
74
|
+
}
|
|
75
|
+
return text;
|
|
76
|
+
} catch (err) {
|
|
77
|
+
if (err && err.name === "AbortError") {
|
|
78
|
+
throw new Error("relay request timed out (30s)");
|
|
79
|
+
}
|
|
80
|
+
throw err;
|
|
81
|
+
} finally {
|
|
82
|
+
clearTimeout(timer);
|
|
36
83
|
}
|
|
37
|
-
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function postJson(url, body) {
|
|
87
|
+
const text = await fetchText(url, JSON.stringify(body));
|
|
88
|
+
return JSON.parse(text);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function getJson(url) {
|
|
92
|
+
const text = await fetchText(url);
|
|
38
93
|
return text === "null" ? null : JSON.parse(text);
|
|
39
94
|
}
|
|
40
95
|
|
|
@@ -42,9 +97,20 @@ function sleep(ms) {
|
|
|
42
97
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
43
98
|
}
|
|
44
99
|
|
|
45
|
-
|
|
100
|
+
function checkFileField(name, value) {
|
|
101
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
102
|
+
throw new Error(`relay sent a bad setup payload (missing ${name})`);
|
|
103
|
+
}
|
|
104
|
+
if (value.length > MAX_FILE_BYTES) {
|
|
105
|
+
throw new Error(`relay sent a bad setup payload (${name} too large)`);
|
|
106
|
+
}
|
|
107
|
+
return value;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function fetchSetupFiles(channelId) {
|
|
46
111
|
const cmdId = `setup${Date.now()}${Math.floor(Math.random() * 1e6)}`;
|
|
47
|
-
|
|
112
|
+
const enc = encodeURIComponent(channelId);
|
|
113
|
+
await postJson(`${DB_URL}/channels/${enc}/cmd.json`, {
|
|
48
114
|
id: cmdId,
|
|
49
115
|
op: "setup",
|
|
50
116
|
ts: Math.floor(Date.now() / 1000),
|
|
@@ -54,46 +120,77 @@ async function fetchSetupResult(channelId) {
|
|
|
54
120
|
await sleep(POLL_INTERVAL_MS);
|
|
55
121
|
let keys;
|
|
56
122
|
try {
|
|
57
|
-
keys = await getJson(`${DB_URL}/channels/${
|
|
123
|
+
keys = await getJson(`${DB_URL}/channels/${enc}/res.json?shallow=true`);
|
|
58
124
|
} catch {
|
|
59
125
|
continue;
|
|
60
126
|
}
|
|
61
127
|
if (!keys) continue;
|
|
62
|
-
|
|
128
|
+
const sorted = Object.keys(keys).sort();
|
|
129
|
+
for (const key of sorted) {
|
|
130
|
+
if (typeof key !== "string" || key.length > 128) continue;
|
|
63
131
|
let entry;
|
|
64
132
|
try {
|
|
65
|
-
entry = await getJson(`${DB_URL}/channels/${
|
|
133
|
+
entry = await getJson(`${DB_URL}/channels/${enc}/res/${encodeURIComponent(key)}.json`);
|
|
66
134
|
} catch {
|
|
67
135
|
continue;
|
|
68
136
|
}
|
|
69
|
-
if (entry
|
|
70
|
-
|
|
137
|
+
if (!entry || entry.id !== cmdId) continue;
|
|
138
|
+
if (entry.ok !== true) {
|
|
139
|
+
throw new Error(`setup failed: ${entry.error || "unknown error"}`);
|
|
140
|
+
}
|
|
141
|
+
let result = entry.result;
|
|
142
|
+
if (entry.resultEncoded) {
|
|
143
|
+
if (typeof result !== "string") {
|
|
144
|
+
throw new Error("relay sent a bad setup payload (bad encoding flag)");
|
|
145
|
+
}
|
|
146
|
+
try {
|
|
147
|
+
result = JSON.parse(result);
|
|
148
|
+
} catch {
|
|
149
|
+
throw new Error("relay sent a bad setup payload (unparseable result)");
|
|
150
|
+
}
|
|
71
151
|
}
|
|
152
|
+
if (!result || typeof result !== "object") {
|
|
153
|
+
throw new Error("relay sent a bad setup payload (result is not an object)");
|
|
154
|
+
}
|
|
155
|
+
return {
|
|
156
|
+
source: checkFileField("golem.py", result.source),
|
|
157
|
+
prompt: checkFileField("golem.md", result.prompt),
|
|
158
|
+
instructions:
|
|
159
|
+
typeof result.instructions === "string" && result.instructions.length > 0
|
|
160
|
+
? result.instructions
|
|
161
|
+
: "Connected.",
|
|
162
|
+
};
|
|
72
163
|
}
|
|
73
164
|
}
|
|
74
165
|
return null;
|
|
75
166
|
}
|
|
76
167
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
}
|
|
168
|
+
function sha256(text) {
|
|
169
|
+
return crypto.createHash("sha256").update(text, "utf8").digest("hex");
|
|
170
|
+
}
|
|
81
171
|
|
|
172
|
+
async function connect(channelId, printOnly) {
|
|
173
|
+
validateChannel(channelId);
|
|
82
174
|
console.log(`Contacting Golem plugin on channel ${channelId} ...`);
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
175
|
+
let files;
|
|
176
|
+
try {
|
|
177
|
+
files = await fetchSetupFiles(channelId);
|
|
178
|
+
} catch (err) {
|
|
179
|
+
fail(err.message, 1);
|
|
88
180
|
}
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
181
|
+
|
|
182
|
+
if (!files) {
|
|
183
|
+
fail("no response from the Studio plugin. Is Roblox Studio open with Golem running?", 1);
|
|
92
184
|
}
|
|
93
185
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
186
|
+
if (printOnly) {
|
|
187
|
+
console.log("===== golem.py (not written) =====");
|
|
188
|
+
console.log(files.source);
|
|
189
|
+
console.log("===== golem.md (not written) =====");
|
|
190
|
+
console.log(files.prompt);
|
|
191
|
+
console.log("===== connection note =====");
|
|
192
|
+
console.log(files.instructions);
|
|
193
|
+
return;
|
|
97
194
|
}
|
|
98
195
|
|
|
99
196
|
const dir = path.join(process.cwd(), ".golem");
|
|
@@ -101,27 +198,47 @@ async function connect(channelId) {
|
|
|
101
198
|
|
|
102
199
|
const pyPath = path.join(dir, "golem.py");
|
|
103
200
|
const mdPath = path.join(dir, "golem.md");
|
|
104
|
-
fs.writeFileSync(pyPath,
|
|
105
|
-
fs.writeFileSync(mdPath,
|
|
201
|
+
fs.writeFileSync(pyPath, files.source);
|
|
202
|
+
fs.writeFileSync(mdPath, files.prompt);
|
|
106
203
|
|
|
107
204
|
console.log("");
|
|
108
|
-
console.log(
|
|
205
|
+
console.log(files.instructions);
|
|
109
206
|
console.log("");
|
|
110
|
-
console.log(
|
|
207
|
+
console.log(
|
|
208
|
+
`Wrote ${path.relative(process.cwd(), pyPath)} (${files.source.length} bytes, sha256:${sha256(files.source).slice(0, 16)}...)`
|
|
209
|
+
);
|
|
210
|
+
console.log(
|
|
211
|
+
`Wrote ${path.relative(process.cwd(), mdPath)} (${files.prompt.length} bytes, sha256:${sha256(files.prompt).slice(0, 16)}...)`
|
|
212
|
+
);
|
|
213
|
+
console.log("These files were just downloaded from your Studio session — review them before running anything.");
|
|
111
214
|
console.log(`Next: read ${path.relative(process.cwd(), mdPath)}, then run:`);
|
|
112
215
|
console.log(` python3 ${path.relative(process.cwd(), pyPath)} ping`);
|
|
113
216
|
}
|
|
114
217
|
|
|
115
218
|
async function main() {
|
|
116
|
-
const
|
|
117
|
-
if (
|
|
118
|
-
|
|
219
|
+
const args = process.argv.slice(2);
|
|
220
|
+
if (args[0] === "--help" || args[0] === "-h" || args[0] === "help") {
|
|
221
|
+
printHelp();
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
if (args[0] === "--version" || args[0] === "-V" || args[0] === "version") {
|
|
225
|
+
console.log(VERSION);
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
if (args[0] !== "connect") {
|
|
229
|
+
printHelp();
|
|
230
|
+
process.exit(2);
|
|
231
|
+
}
|
|
232
|
+
const channelId = args[1];
|
|
233
|
+
const printOnly = args.includes("--print");
|
|
234
|
+
if (!channelId || channelId.startsWith("-")) {
|
|
235
|
+
printHelp();
|
|
236
|
+
process.exit(2);
|
|
119
237
|
}
|
|
120
238
|
try {
|
|
121
|
-
await connect(
|
|
239
|
+
await connect(channelId, printOnly);
|
|
122
240
|
} catch (err) {
|
|
123
|
-
|
|
124
|
-
process.exit(1);
|
|
241
|
+
fail(err.message, 1);
|
|
125
242
|
}
|
|
126
243
|
}
|
|
127
244
|
|