golem-bridge 1.0.1 → 1.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +46 -1
  2. package/cli.js +217 -49
  3. 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 first run, also in Settings):
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.
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
 
@@ -25,7 +28,49 @@ python3 ./.golem/golem.py ping
25
28
  `ping` should return `"ok": true` plus the open place name. Then the agent
26
29
  reads `./.golem/golem.md` for the full tool reference.
27
30
 
31
+ After a Studio restart, relink with the new line:
32
+
33
+ ```sh
34
+ npx golem-bridge reconnect <newChannelId>
35
+ ```
36
+
37
+ Done with a session? Forget it locally (Studio is unaffected):
38
+
39
+ ```sh
40
+ npx golem-bridge disconnect
41
+ ```
42
+
28
43
  ## Files
29
44
 
30
45
  - `cli.js` - source of the `golem-bridge` package
31
46
  - `package.json` - npm manifest
47
+
48
+ ## Notes
49
+
50
+ - Relay: `https://roblox-golem-default-rtdb.firebaseio.com/`. The channel id
51
+ is the secret. Studio mints a fresh one on every start and wipes the old
52
+ channel, so a leaked line dies with the session.
53
+ - The plugin serves `golem.py` and `golem.md` on demand. They are never
54
+ stored on the relay.
55
+
56
+ ## Security
57
+
58
+ Trust model: whoever holds the channel ID can send commands to that Studio
59
+ session and read the results. Treat the setup line like a password. It
60
+ expires on every Studio restart.
61
+
62
+ - The CLI (`cli.js`, about 200 lines) is the only code that runs on the
63
+ agent side. Read it here, or fetch without writing anything:
64
+ `npx golem-bridge connect <channelId> --print`.
65
+ - `connect` only accepts hex channel IDs, talks HTTPS to the relay only
66
+ (never follows redirects), times out stalled requests, caps response
67
+ sizes, validates the payload shape before writing, and prints SHA-256
68
+ hashes of both files. It writes nothing unless every check passes, and
69
+ tells you to review both files before running anything.
70
+ - There are no baked-in Firebase credentials or signing keys: the channel
71
+ ID itself is the capability, and transport runs over HTTPS. Public client
72
+ code cannot hold a secret, so any "signed responses" scheme here would be
73
+ theater rather than security.
74
+ - Socket.dev flags the "URL strings" in this package (the relay address).
75
+ That is informational: the relay address is the product. The setup payload
76
+ is validated as described above before anything is written.
package/cli.js CHANGED
@@ -4,37 +4,99 @@
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}$/;
13
21
 
14
- function usage() {
15
- console.error("Usage: golem-bridge connect <channelId>");
16
- console.error(" <channelId> is shown in the Golem plugin widget inside Roblox Studio.");
17
- process.exit(1);
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
18
27
  }
19
28
 
20
- async function postJson(url, body) {
21
- const res = await fetch(url, {
22
- method: "POST",
23
- headers: { "Content-Type": "application/json" },
24
- body: JSON.stringify(body),
25
- });
26
- if (!res.ok) {
27
- throw new Error(`POST ${url} failed: HTTP ${res.status}`);
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 reconnect <channelId> [--print]
35
+ golem-bridge disconnect
36
+ golem-bridge --help
37
+ golem-bridge --version
38
+
39
+ <channelId> shown in the Golem plugin widget inside Roblox Studio.
40
+ Fresh on every Studio start.
41
+ --print audit mode: fetch and print both files without writing anything.
42
+
43
+ connect link this folder to a Studio session (writes ./.golem/).
44
+ reconnect same, for a rotated token: replaces the old session files.
45
+ Use after a Studio restart, with the new line from the widget.
46
+ disconnect forget this session (removes ./.golem/). Studio is unaffected.
47
+
48
+ connect writes ./.golem/golem.py and ./.golem/golem.md, fetched over HTTPS
49
+ from your own Studio session. Review both files before running anything.`);
50
+ }
51
+
52
+ function fail(message, exitCode) {
53
+ console.error(`golem-bridge error: ${message}`);
54
+ process.exit(exitCode || 1);
55
+ }
56
+
57
+ function validateChannel(channelId) {
58
+ if (typeof channelId !== "string" || !CHANNEL_RE.test(channelId)) {
59
+ fail("bad channel ID (expect 8-64 hex characters — copy the full line from the Studio widget).", 2);
28
60
  }
29
- return res.json();
61
+ return channelId;
30
62
  }
31
63
 
32
- async function getJson(url) {
33
- const res = await fetch(url);
34
- if (!res.ok) {
35
- throw new Error(`GET ${url} failed: HTTP ${res.status}`);
64
+ async function fetchText(url, body) {
65
+ const ctrl = new AbortController();
66
+ const timer = setTimeout(() => ctrl.abort(), FETCH_TIMEOUT_MS);
67
+ try {
68
+ const res = await fetch(url, {
69
+ method: body === undefined ? "GET" : "POST",
70
+ headers: { "Content-Type": "application/json" },
71
+ body,
72
+ signal: ctrl.signal,
73
+ redirect: "error", // never follow redirects: responses must come from the relay itself
74
+ });
75
+ if (!res.ok) {
76
+ throw new Error(`HTTP ${res.status} from the relay`);
77
+ }
78
+ const text = await res.text();
79
+ if (text.length > MAX_BODY_BYTES) {
80
+ throw new Error("relay response too large, refusing to parse it");
81
+ }
82
+ return text;
83
+ } catch (err) {
84
+ if (err && err.name === "AbortError") {
85
+ throw new Error("relay request timed out (30s)");
86
+ }
87
+ throw err;
88
+ } finally {
89
+ clearTimeout(timer);
36
90
  }
37
- const text = await res.text();
91
+ }
92
+
93
+ async function postJson(url, body) {
94
+ const text = await fetchText(url, JSON.stringify(body));
95
+ return JSON.parse(text);
96
+ }
97
+
98
+ async function getJson(url) {
99
+ const text = await fetchText(url);
38
100
  return text === "null" ? null : JSON.parse(text);
39
101
  }
40
102
 
@@ -42,9 +104,20 @@ function sleep(ms) {
42
104
  return new Promise((resolve) => setTimeout(resolve, ms));
43
105
  }
44
106
 
45
- async function fetchSetupResult(channelId) {
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
+
117
+ async function fetchSetupFiles(channelId) {
46
118
  const cmdId = `setup${Date.now()}${Math.floor(Math.random() * 1e6)}`;
47
- await postJson(`${DB_URL}/channels/${channelId}/cmd.json`, {
119
+ const enc = encodeURIComponent(channelId);
120
+ await postJson(`${DB_URL}/channels/${enc}/cmd.json`, {
48
121
  id: cmdId,
49
122
  op: "setup",
50
123
  ts: Math.floor(Date.now() / 1000),
@@ -54,46 +127,112 @@ async function fetchSetupResult(channelId) {
54
127
  await sleep(POLL_INTERVAL_MS);
55
128
  let keys;
56
129
  try {
57
- keys = await getJson(`${DB_URL}/channels/${channelId}/res.json?shallow=true`);
130
+ keys = await getJson(`${DB_URL}/channels/${enc}/res.json?shallow=true`);
58
131
  } catch {
59
132
  continue;
60
133
  }
61
134
  if (!keys) continue;
62
- for (const key of Object.keys(keys).sort()) {
135
+ const sorted = Object.keys(keys).sort();
136
+ for (const key of sorted) {
137
+ if (typeof key !== "string" || key.length > 128) continue;
63
138
  let entry;
64
139
  try {
65
- entry = await getJson(`${DB_URL}/channels/${channelId}/res/${key}.json`);
140
+ entry = await getJson(`${DB_URL}/channels/${enc}/res/${encodeURIComponent(key)}.json`);
66
141
  } catch {
67
142
  continue;
68
143
  }
69
- if (entry && entry.id === cmdId) {
70
- return entry;
144
+ if (!entry || entry.id !== cmdId) continue;
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)");
71
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
+ };
72
170
  }
73
171
  }
74
172
  return null;
75
173
  }
76
174
 
77
- async function connect(channelId) {
78
- if (!channelId || channelId.length < 8) {
79
- usage();
175
+ function sha256(text) {
176
+ return crypto.createHash("sha256").update(text, "utf8").digest("hex");
177
+ }
178
+
179
+ function readSavedChannel() {
180
+ try {
181
+ const src = fs.readFileSync(path.join(process.cwd(), ".golem", "golem.py"), "utf8");
182
+ const m = src.match(/CHANNEL = os\.environ\.get\("AIB_CHANNEL", "([0-9a-fA-F]+)"\)/);
183
+ return m ? m[1] : null;
184
+ } catch {
185
+ return null;
186
+ }
187
+ }
188
+
189
+ function disconnectLocal() {
190
+ const dir = path.join(process.cwd(), ".golem");
191
+ if (!fs.existsSync(dir)) {
192
+ console.log("Not connected (no .golem/ in this folder).");
193
+ return;
80
194
  }
195
+ const old = readSavedChannel();
196
+ fs.rmSync(dir, { recursive: true, force: true });
197
+ console.log(old ? `Disconnected from channel ${old} (removed .golem/).` : "Disconnected (removed .golem/).");
198
+ console.log("Studio is unaffected. To link again: npx golem-bridge connect <channelId>");
199
+ }
81
200
 
82
- console.log(`Contacting Golem plugin on channel ${channelId} ...`);
83
- const entry = await fetchSetupResult(channelId);
201
+ async function reconnect(channelId, printOnly) {
202
+ validateChannel(channelId);
203
+ const old = readSavedChannel();
204
+ if (!printOnly && old && old.toLowerCase() === channelId.toLowerCase()) {
205
+ console.log(`Already linked to channel ${channelId} — nothing to do.`);
206
+ return;
207
+ }
208
+ if (!printOnly && old) {
209
+ console.log(`Replacing session files for channel ${old}.`);
210
+ }
211
+ await connect(channelId, printOnly);
212
+ }
84
213
 
85
- if (!entry) {
86
- console.error("No response from the Studio plugin. Is Roblox Studio open with Golem running?");
87
- process.exit(1);
214
+ async function connect(channelId, printOnly) {
215
+ validateChannel(channelId);
216
+ console.log(`Contacting Golem plugin on channel ${channelId} ...`);
217
+ let files;
218
+ try {
219
+ files = await fetchSetupFiles(channelId);
220
+ } catch (err) {
221
+ fail(err.message, 1);
88
222
  }
89
- if (!entry.ok) {
90
- console.error(`Setup failed: ${entry.error || "unknown error"}`);
91
- process.exit(1);
223
+
224
+ if (!files) {
225
+ fail("no response from the Studio plugin. Is Roblox Studio open with Golem running?", 1);
92
226
  }
93
227
 
94
- let result = entry.result;
95
- if (entry.resultEncoded) {
96
- result = JSON.parse(result);
228
+ if (printOnly) {
229
+ console.log("===== golem.py (not written) =====");
230
+ console.log(files.source);
231
+ console.log("===== golem.md (not written) =====");
232
+ console.log(files.prompt);
233
+ console.log("===== connection note =====");
234
+ console.log(files.instructions);
235
+ return;
97
236
  }
98
237
 
99
238
  const dir = path.join(process.cwd(), ".golem");
@@ -101,27 +240,56 @@ async function connect(channelId) {
101
240
 
102
241
  const pyPath = path.join(dir, "golem.py");
103
242
  const mdPath = path.join(dir, "golem.md");
104
- fs.writeFileSync(pyPath, result.source);
105
- fs.writeFileSync(mdPath, result.prompt);
243
+ fs.writeFileSync(pyPath, files.source);
244
+ fs.writeFileSync(mdPath, files.prompt);
106
245
 
107
246
  console.log("");
108
- console.log(result.instructions || "Connected.");
247
+ console.log(files.instructions);
109
248
  console.log("");
110
- console.log(`Wrote ${path.relative(process.cwd(), pyPath)} and ${path.relative(process.cwd(), mdPath)}`);
249
+ console.log(
250
+ `Wrote ${path.relative(process.cwd(), pyPath)} (${files.source.length} bytes, sha256:${sha256(files.source).slice(0, 16)}...)`
251
+ );
252
+ console.log(
253
+ `Wrote ${path.relative(process.cwd(), mdPath)} (${files.prompt.length} bytes, sha256:${sha256(files.prompt).slice(0, 16)}...)`
254
+ );
255
+ console.log("These files were just downloaded from your Studio session — review them before running anything.");
111
256
  console.log(`Next: read ${path.relative(process.cwd(), mdPath)}, then run:`);
112
257
  console.log(` python3 ${path.relative(process.cwd(), pyPath)} ping`);
113
258
  }
114
259
 
115
260
  async function main() {
116
- const [cmd, arg] = process.argv.slice(2);
117
- if (cmd !== "connect") {
118
- usage();
261
+ const args = process.argv.slice(2);
262
+ if (args[0] === "--help" || args[0] === "-h" || args[0] === "help") {
263
+ printHelp();
264
+ return;
265
+ }
266
+ if (args[0] === "--version" || args[0] === "-V" || args[0] === "version") {
267
+ console.log(VERSION);
268
+ return;
269
+ }
270
+ if (args[0] === "disconnect") {
271
+ disconnectLocal();
272
+ return;
273
+ }
274
+ const isReconnect = args[0] === "reconnect";
275
+ if (args[0] !== "connect" && !isReconnect) {
276
+ printHelp();
277
+ process.exit(2);
278
+ }
279
+ const channelId = args[1];
280
+ const printOnly = args.includes("--print");
281
+ if (!channelId || channelId.startsWith("-")) {
282
+ printHelp();
283
+ process.exit(2);
119
284
  }
120
285
  try {
121
- await connect(arg);
286
+ if (isReconnect) {
287
+ await reconnect(channelId, printOnly);
288
+ } else {
289
+ await connect(channelId, printOnly);
290
+ }
122
291
  } catch (err) {
123
- console.error(`golem-bridge error: ${err.message}`);
124
- process.exit(1);
292
+ fail(err.message, 1);
125
293
  }
126
294
  }
127
295
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "golem-bridge",
3
- "version": "1.0.1",
3
+ "version": "1.0.3",
4
4
  "description": "Connects an AI coding agent to a running Roblox Studio session via the Golem plugin.",
5
5
  "bin": {
6
6
  "golem-bridge": "./cli.js"