golem-bridge 1.0.3 → 2.0.1
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 +27 -15
- package/cli.js +117 -69
- package/golem-helper.py +1093 -0
- package/golem-tools.md +683 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -17,16 +17,17 @@ Connect to my Roblox Studio, Run: npx golem-bridge connect <channelId>
|
|
|
17
17
|
The line is a session token: Studio mints a fresh channel on every start
|
|
18
18
|
and wipes the old one.
|
|
19
19
|
|
|
20
|
-
The user sends that line to their AI. It downloads this package, which
|
|
21
|
-
|
|
20
|
+
The user sends that line to their AI. It downloads this package, which
|
|
21
|
+
checks Studio is alive, then stamps the channel into local copies of
|
|
22
|
+
the two connection files under `./.golem/`:
|
|
22
23
|
|
|
23
24
|
```sh
|
|
24
25
|
npx golem-bridge connect <channelId>
|
|
25
|
-
python3 ./.golem/golem.py ping
|
|
26
|
+
python3 ./.golem/golem-helper.py ping
|
|
26
27
|
```
|
|
27
28
|
|
|
28
29
|
`ping` should return `"ok": true` plus the open place name. Then the agent
|
|
29
|
-
reads `./.golem/golem.md` for the full tool reference.
|
|
30
|
+
reads `./.golem/golem-tools.md` for the full tool reference.
|
|
30
31
|
|
|
31
32
|
After a Studio restart, relink with the new line:
|
|
32
33
|
|
|
@@ -43,6 +44,8 @@ npx golem-bridge disconnect
|
|
|
43
44
|
## Files
|
|
44
45
|
|
|
45
46
|
- `cli.js` - source of the `golem-bridge` package
|
|
47
|
+
- `golem-helper.py` - the Studio helper, stamped with the channel at connect
|
|
48
|
+
- `golem-tools.md` - the agent manual
|
|
46
49
|
- `package.json` - npm manifest
|
|
47
50
|
|
|
48
51
|
## Notes
|
|
@@ -50,8 +53,12 @@ npx golem-bridge disconnect
|
|
|
50
53
|
- Relay: `https://roblox-golem-default-rtdb.firebaseio.com/`. The channel id
|
|
51
54
|
is the secret. Studio mints a fresh one on every start and wipes the old
|
|
52
55
|
channel, so a leaked line dies with the session.
|
|
53
|
-
-
|
|
54
|
-
|
|
56
|
+
- `golem-helper.py` and `golem-tools.md` ship in this package. The relay carries
|
|
57
|
+
small JSON commands and results only.
|
|
58
|
+
|
|
59
|
+
- Leaked a line mid-session? Settings > END SESSION AND ROTATE CHANNEL
|
|
60
|
+
in the plugin kills it on the spot and issues a new one. (Every Studio
|
|
61
|
+
restart already rotates automatically.)
|
|
55
62
|
|
|
56
63
|
## Security
|
|
57
64
|
|
|
@@ -59,18 +66,23 @@ Trust model: whoever holds the channel ID can send commands to that Studio
|
|
|
59
66
|
session and read the results. Treat the setup line like a password. It
|
|
60
67
|
expires on every Studio restart.
|
|
61
68
|
|
|
62
|
-
-
|
|
63
|
-
|
|
64
|
-
|
|
69
|
+
- `connect` verifies
|
|
70
|
+
Studio is alive over HTTPS and stamps your channel ID into local copies.
|
|
71
|
+
Read all three files here before running anything, or fetch with
|
|
72
|
+
`--print` to inspect without writing.
|
|
73
|
+
- `connect` asks before it writes: it lists the files first, then waits
|
|
74
|
+
for y. Pass `--yes` to skip the question (scripts), `--print` to look
|
|
75
|
+
without writing.
|
|
65
76
|
- `connect` only accepts hex channel IDs, talks HTTPS to the relay only
|
|
66
|
-
(never follows redirects), times out stalled requests, caps response
|
|
67
|
-
sizes
|
|
68
|
-
hashes of both files. It writes nothing unless every check passes, and
|
|
69
|
-
tells you to review both files before running anything.
|
|
77
|
+
(never follows redirects), times out stalled requests, and caps response
|
|
78
|
+
sizes. The only data it acts on is a small `ping` reply.
|
|
70
79
|
- There are no baked-in Firebase credentials or signing keys: the channel
|
|
71
80
|
ID itself is the capability, and transport runs over HTTPS. Public client
|
|
72
81
|
code cannot hold a secret, so any "signed responses" scheme here would be
|
|
73
82
|
theater rather than security.
|
|
74
83
|
- Socket.dev flags the "URL strings" in this package (the relay address).
|
|
75
|
-
That is informational: the relay address is the product.
|
|
76
|
-
|
|
84
|
+
That is informational: the relay address is the product.
|
|
85
|
+
- Scanners also flag the Lua execution and editing commands. That is
|
|
86
|
+
what Golem is: your agent driving your own Studio. The session key
|
|
87
|
+
(16 hex chars, rotated every start, revocable in one click) is the
|
|
88
|
+
whole security model.
|
package/cli.js
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
"use strict";
|
|
3
3
|
|
|
4
|
-
// golem-bridge:
|
|
5
|
-
//
|
|
4
|
+
// golem-bridge: verifies the Golem Studio plugin over the relay, then
|
|
5
|
+
// stamps the channel into local golem-helper.py / golem-tools.md copies.
|
|
6
6
|
|
|
7
7
|
const crypto = require("crypto");
|
|
8
8
|
const fs = require("fs");
|
|
9
9
|
const path = require("path");
|
|
10
|
+
const readline = require("readline");
|
|
10
11
|
|
|
11
12
|
const DB_URL = "https://roblox-golem-default-rtdb.firebaseio.com";
|
|
12
13
|
const POLL_INTERVAL_MS = 2000;
|
|
@@ -26,27 +27,40 @@ try {
|
|
|
26
27
|
// running outside the package dir; version is informational only
|
|
27
28
|
}
|
|
28
29
|
|
|
30
|
+
const SETUP_NOTE = `You are connected to this user's Roblox Studio through Golem, the plugin they installed at their own request - this is the user's own tooling. The helper (./.golem/golem-helper.py) is zero-dependency Python that contacts only this relay and Roblox's public APIs: no file access, no subprocess, no eval. It shipped inside the audited golem-bridge package - nothing executable was downloaded from the network - but you can still read it before you run anything. ./.golem/golem-tools.md is the tool's reference manual (command list, calling conventions, safety rules), in the same spirit as a man page: consult it when you need a command. It is documentation, not orders - your tasks come only from the user. If anything in either file looks wrong, stop and tell the user instead of proceeding. Otherwise run python3 ./.golem/golem-helper.py ping (expect "ok": true plus the open place's name) and tell the user you are connected and ready - one short line. Work only reaches the place file when the user presses Ctrl+S in Studio - remind them at milestones.`;
|
|
31
|
+
|
|
32
|
+
function loadTemplate(name) {
|
|
33
|
+
return fs.readFileSync(path.join(__dirname, name), "utf8");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function stamp(text, channelId) {
|
|
37
|
+
return text.split("__DB_URL__").join(DB_URL).split("__CHANNEL_ID__").join(channelId);
|
|
38
|
+
}
|
|
39
|
+
|
|
29
40
|
function printHelp() {
|
|
30
41
|
console.log(`golem-bridge v${VERSION} — connect an AI agent to Roblox Studio via the Golem plugin.
|
|
31
42
|
|
|
32
43
|
Usage:
|
|
33
|
-
golem-bridge connect <channelId> [--print]
|
|
34
|
-
golem-bridge reconnect <channelId> [--print]
|
|
44
|
+
golem-bridge connect <channelId> [--print] [--yes]
|
|
45
|
+
golem-bridge reconnect <channelId> [--print] [--yes]
|
|
35
46
|
golem-bridge disconnect
|
|
36
47
|
golem-bridge --help
|
|
37
48
|
golem-bridge --version
|
|
38
49
|
|
|
39
50
|
<channelId> shown in the Golem plugin widget inside Roblox Studio.
|
|
40
51
|
Fresh on every Studio start.
|
|
41
|
-
--print audit mode:
|
|
52
|
+
--print audit mode: verify Studio, then print both files without writing.
|
|
53
|
+
--yes answer the install question with yes (for scripts).
|
|
42
54
|
|
|
43
55
|
connect link this folder to a Studio session (writes ./.golem/).
|
|
44
56
|
reconnect same, for a rotated token: replaces the old session files.
|
|
45
57
|
Use after a Studio restart, with the new line from the widget.
|
|
46
58
|
disconnect forget this session (removes ./.golem/). Studio is unaffected.
|
|
47
59
|
|
|
48
|
-
connect
|
|
49
|
-
|
|
60
|
+
connect verifies Studio is alive over HTTPS, then stamps your channel ID
|
|
61
|
+
into local copies of the bundled golem-helper.py and golem-tools.md. No code is ever
|
|
62
|
+
downloaded from the network. It lists the files and asks before writing anything; review first with
|
|
63
|
+
--print, or read them in this package before running anything.`);
|
|
50
64
|
}
|
|
51
65
|
|
|
52
66
|
function fail(message, exitCode) {
|
|
@@ -61,6 +75,25 @@ function validateChannel(channelId) {
|
|
|
61
75
|
return channelId;
|
|
62
76
|
}
|
|
63
77
|
|
|
78
|
+
function askYes(question) {
|
|
79
|
+
return new Promise((resolve) => {
|
|
80
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
81
|
+
let done = false;
|
|
82
|
+
const finish = (value) => {
|
|
83
|
+
if (done) return;
|
|
84
|
+
done = true;
|
|
85
|
+
try {
|
|
86
|
+
rl.close();
|
|
87
|
+
} catch {
|
|
88
|
+
// already closed (EOF on stdin)
|
|
89
|
+
}
|
|
90
|
+
resolve(value);
|
|
91
|
+
};
|
|
92
|
+
rl.question(question + " ", (answer) => finish(/^\s*y(es)?\s*$/i.test(answer || "")));
|
|
93
|
+
rl.on("close", () => finish(false));
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
64
97
|
async function fetchText(url, body) {
|
|
65
98
|
const ctrl = new AbortController();
|
|
66
99
|
const timer = setTimeout(() => ctrl.abort(), FETCH_TIMEOUT_MS);
|
|
@@ -104,26 +137,17 @@ function sleep(ms) {
|
|
|
104
137
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
105
138
|
}
|
|
106
139
|
|
|
107
|
-
function checkFileField(name, value) {
|
|
108
|
-
if (typeof value !== "string" || value.length === 0) {
|
|
109
|
-
throw new Error(`relay sent a bad setup payload (missing ${name})`);
|
|
110
|
-
}
|
|
111
|
-
if (value.length > MAX_FILE_BYTES) {
|
|
112
|
-
throw new Error(`relay sent a bad setup payload (${name} too large)`);
|
|
113
|
-
}
|
|
114
|
-
return value;
|
|
115
|
-
}
|
|
116
140
|
|
|
117
|
-
async function
|
|
118
|
-
const cmdId =
|
|
141
|
+
async function relayCall(channelId, op, args, attempts) {
|
|
142
|
+
const cmdId = `${op}${Date.now()}${Math.floor(Math.random() * 1e6)}`;
|
|
119
143
|
const enc = encodeURIComponent(channelId);
|
|
120
144
|
await postJson(`${DB_URL}/channels/${enc}/cmd.json`, {
|
|
121
145
|
id: cmdId,
|
|
122
|
-
op
|
|
146
|
+
op,
|
|
147
|
+
args: args || {},
|
|
123
148
|
ts: Math.floor(Date.now() / 1000),
|
|
124
149
|
});
|
|
125
|
-
|
|
126
|
-
for (let attempt = 0; attempt < POLL_ATTEMPTS; attempt++) {
|
|
150
|
+
for (let i = 0; i < attempts; i++) {
|
|
127
151
|
await sleep(POLL_INTERVAL_MS);
|
|
128
152
|
let keys;
|
|
129
153
|
try {
|
|
@@ -132,8 +156,7 @@ async function fetchSetupFiles(channelId) {
|
|
|
132
156
|
continue;
|
|
133
157
|
}
|
|
134
158
|
if (!keys) continue;
|
|
135
|
-
const
|
|
136
|
-
for (const key of sorted) {
|
|
159
|
+
for (const key of Object.keys(keys).sort()) {
|
|
137
160
|
if (typeof key !== "string" || key.length > 128) continue;
|
|
138
161
|
let entry;
|
|
139
162
|
try {
|
|
@@ -141,32 +164,7 @@ async function fetchSetupFiles(channelId) {
|
|
|
141
164
|
} catch {
|
|
142
165
|
continue;
|
|
143
166
|
}
|
|
144
|
-
if (
|
|
145
|
-
if (entry.ok !== true) {
|
|
146
|
-
throw new Error(`setup failed: ${entry.error || "unknown error"}`);
|
|
147
|
-
}
|
|
148
|
-
let result = entry.result;
|
|
149
|
-
if (entry.resultEncoded) {
|
|
150
|
-
if (typeof result !== "string") {
|
|
151
|
-
throw new Error("relay sent a bad setup payload (bad encoding flag)");
|
|
152
|
-
}
|
|
153
|
-
try {
|
|
154
|
-
result = JSON.parse(result);
|
|
155
|
-
} catch {
|
|
156
|
-
throw new Error("relay sent a bad setup payload (unparseable result)");
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
if (!result || typeof result !== "object") {
|
|
160
|
-
throw new Error("relay sent a bad setup payload (result is not an object)");
|
|
161
|
-
}
|
|
162
|
-
return {
|
|
163
|
-
source: checkFileField("golem.py", result.source),
|
|
164
|
-
prompt: checkFileField("golem.md", result.prompt),
|
|
165
|
-
instructions:
|
|
166
|
-
typeof result.instructions === "string" && result.instructions.length > 0
|
|
167
|
-
? result.instructions
|
|
168
|
-
: "Connected.",
|
|
169
|
-
};
|
|
167
|
+
if (entry && entry.id === cmdId) return entry;
|
|
170
168
|
}
|
|
171
169
|
}
|
|
172
170
|
return null;
|
|
@@ -177,12 +175,26 @@ function sha256(text) {
|
|
|
177
175
|
}
|
|
178
176
|
|
|
179
177
|
function readSavedChannel() {
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
178
|
+
// New name first, pre-2.0.1 name as fallback (stale files are removed on connect).
|
|
179
|
+
for (const name of ["golem-helper.py", "golem.py"]) {
|
|
180
|
+
try {
|
|
181
|
+
const src = fs.readFileSync(path.join(process.cwd(), ".golem", name), "utf8");
|
|
182
|
+
const m = src.match(/CHANNEL = os\.environ\.get\("AIB_CHANNEL", "([0-9a-fA-F]+)"\)/);
|
|
183
|
+
if (m) return m[1];
|
|
184
|
+
} catch {
|
|
185
|
+
// missing or unreadable - try the next name
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function removeStaleHelpers(dir) {
|
|
192
|
+
for (const stale of ["golem.py", "golem.md"]) {
|
|
193
|
+
try {
|
|
194
|
+
fs.rmSync(path.join(dir, stale), { force: true });
|
|
195
|
+
} catch {
|
|
196
|
+
// cleanup must never block a connect
|
|
197
|
+
}
|
|
186
198
|
}
|
|
187
199
|
}
|
|
188
200
|
|
|
@@ -198,7 +210,7 @@ function disconnectLocal() {
|
|
|
198
210
|
console.log("Studio is unaffected. To link again: npx golem-bridge connect <channelId>");
|
|
199
211
|
}
|
|
200
212
|
|
|
201
|
-
async function reconnect(channelId, printOnly) {
|
|
213
|
+
async function reconnect(channelId, printOnly, autoYes) {
|
|
202
214
|
validateChannel(channelId);
|
|
203
215
|
const old = readSavedChannel();
|
|
204
216
|
if (!printOnly && old && old.toLowerCase() === channelId.toLowerCase()) {
|
|
@@ -208,27 +220,47 @@ async function reconnect(channelId, printOnly) {
|
|
|
208
220
|
if (!printOnly && old) {
|
|
209
221
|
console.log(`Replacing session files for channel ${old}.`);
|
|
210
222
|
}
|
|
211
|
-
await connect(channelId, printOnly);
|
|
223
|
+
await connect(channelId, printOnly, autoYes);
|
|
212
224
|
}
|
|
213
225
|
|
|
214
|
-
async function connect(channelId, printOnly) {
|
|
226
|
+
async function connect(channelId, printOnly, autoYes) {
|
|
215
227
|
validateChannel(channelId);
|
|
216
228
|
console.log(`Contacting Golem plugin on channel ${channelId} ...`);
|
|
217
|
-
let
|
|
229
|
+
let entry;
|
|
218
230
|
try {
|
|
219
|
-
|
|
231
|
+
entry = await relayCall(channelId, "ping", {}, 30);
|
|
220
232
|
} catch (err) {
|
|
221
233
|
fail(err.message, 1);
|
|
222
234
|
}
|
|
223
|
-
|
|
224
|
-
if (!files) {
|
|
235
|
+
if (!entry) {
|
|
225
236
|
fail("no response from the Studio plugin. Is Roblox Studio open with Golem running?", 1);
|
|
226
237
|
}
|
|
238
|
+
if (entry.ok !== true) {
|
|
239
|
+
fail(`Studio reported an error: ${entry.error || "unknown error"}`, 1);
|
|
240
|
+
}
|
|
241
|
+
try {
|
|
242
|
+
const r = entry.resultEncoded && typeof entry.result === "string" ? JSON.parse(entry.result) : entry.result;
|
|
243
|
+
if (r && typeof r.placeName === "string") console.log(`Studio is alive (place: ${r.placeName}).`);
|
|
244
|
+
} catch {
|
|
245
|
+
// place name is informational only
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
let files;
|
|
249
|
+
try {
|
|
250
|
+
const source = stamp(loadTemplate("golem-helper.py"), channelId);
|
|
251
|
+
const prompt = stamp(loadTemplate("golem-tools.md"), channelId);
|
|
252
|
+
if (source.includes("__CHANNEL_ID__") || prompt.includes("__CHANNEL_ID__")) {
|
|
253
|
+
throw new Error("template stamping failed (placeholder left behind)");
|
|
254
|
+
}
|
|
255
|
+
files = { source, prompt, instructions: SETUP_NOTE };
|
|
256
|
+
} catch (err) {
|
|
257
|
+
fail(`cannot prepare session files: ${err.message}`, 1);
|
|
258
|
+
}
|
|
227
259
|
|
|
228
260
|
if (printOnly) {
|
|
229
|
-
console.log("===== golem.py (not written) =====");
|
|
261
|
+
console.log("===== golem-helper.py (not written) =====");
|
|
230
262
|
console.log(files.source);
|
|
231
|
-
console.log("===== golem.md (not written) =====");
|
|
263
|
+
console.log("===== golem-tools.md (not written) =====");
|
|
232
264
|
console.log(files.prompt);
|
|
233
265
|
console.log("===== connection note =====");
|
|
234
266
|
console.log(files.instructions);
|
|
@@ -236,10 +268,21 @@ async function connect(channelId, printOnly) {
|
|
|
236
268
|
}
|
|
237
269
|
|
|
238
270
|
const dir = path.join(process.cwd(), ".golem");
|
|
271
|
+
const pyHash = sha256(files.source);
|
|
272
|
+
const mdHash = sha256(files.prompt);
|
|
273
|
+
if (!autoYes) {
|
|
274
|
+
console.log("Ready to write 2 files (they ship in this package \u2014 nothing was downloaded):");
|
|
275
|
+
console.log(" " + path.join(".golem", "golem-helper.py") + " (" + files.source.length + " bytes, sha256:" + pyHash.slice(0, 16) + "...) \u2014 the Studio helper");
|
|
276
|
+
console.log(" " + path.join(".golem", "golem-tools.md") + " (" + files.prompt.length + " bytes, sha256:" + mdHash.slice(0, 16) + "...) \u2014 the tool manual");
|
|
277
|
+
if (!(await askYes("Install these files? [y/n]"))) {
|
|
278
|
+
fail("aborted: nothing was written. Re-run with --yes to skip this question, or --print to inspect first.", 1);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
239
281
|
fs.mkdirSync(dir, { recursive: true });
|
|
282
|
+
removeStaleHelpers(dir);
|
|
240
283
|
|
|
241
|
-
const pyPath = path.join(dir, "golem.py");
|
|
242
|
-
const mdPath = path.join(dir, "golem.md");
|
|
284
|
+
const pyPath = path.join(dir, "golem-helper.py");
|
|
285
|
+
const mdPath = path.join(dir, "golem-tools.md");
|
|
243
286
|
fs.writeFileSync(pyPath, files.source);
|
|
244
287
|
fs.writeFileSync(mdPath, files.prompt);
|
|
245
288
|
|
|
@@ -278,19 +321,24 @@ async function main() {
|
|
|
278
321
|
}
|
|
279
322
|
const channelId = args[1];
|
|
280
323
|
const printOnly = args.includes("--print");
|
|
324
|
+
const autoYes = args.includes("--yes") || args.includes("-y");
|
|
281
325
|
if (!channelId || channelId.startsWith("-")) {
|
|
282
326
|
printHelp();
|
|
283
327
|
process.exit(2);
|
|
284
328
|
}
|
|
285
329
|
try {
|
|
286
330
|
if (isReconnect) {
|
|
287
|
-
await reconnect(channelId, printOnly);
|
|
331
|
+
await reconnect(channelId, printOnly, autoYes);
|
|
288
332
|
} else {
|
|
289
|
-
await connect(channelId, printOnly);
|
|
333
|
+
await connect(channelId, printOnly, autoYes);
|
|
290
334
|
}
|
|
291
335
|
} catch (err) {
|
|
292
336
|
fail(err.message, 1);
|
|
293
337
|
}
|
|
294
338
|
}
|
|
295
339
|
|
|
296
|
-
main
|
|
340
|
+
if (require.main === module) {
|
|
341
|
+
main();
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
module.exports = { askYes, readSavedChannel, removeStaleHelpers };
|