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.
Files changed (3) hide show
  1. package/README.md +36 -1
  2. package/cli.js +167 -50
  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. 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
- 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);
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
- 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}`);
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 res.json();
54
+ return channelId;
30
55
  }
31
56
 
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}`);
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
- const text = await res.text();
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
- async function fetchSetupResult(channelId) {
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
- await postJson(`${DB_URL}/channels/${channelId}/cmd.json`, {
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/${channelId}/res.json?shallow=true`);
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
- for (const key of Object.keys(keys).sort()) {
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/${channelId}/res/${key}.json`);
133
+ entry = await getJson(`${DB_URL}/channels/${enc}/res/${encodeURIComponent(key)}.json`);
66
134
  } catch {
67
135
  continue;
68
136
  }
69
- if (entry && entry.id === cmdId) {
70
- return entry;
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
- async function connect(channelId) {
78
- if (!channelId || channelId.length < 8) {
79
- usage();
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
- const entry = await fetchSetupResult(channelId);
84
-
85
- if (!entry) {
86
- console.error("No response from the Studio plugin. Is Roblox Studio open with Golem running?");
87
- process.exit(1);
175
+ let files;
176
+ try {
177
+ files = await fetchSetupFiles(channelId);
178
+ } catch (err) {
179
+ fail(err.message, 1);
88
180
  }
89
- if (!entry.ok) {
90
- console.error(`Setup failed: ${entry.error || "unknown error"}`);
91
- process.exit(1);
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
- let result = entry.result;
95
- if (entry.resultEncoded) {
96
- result = JSON.parse(result);
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, result.source);
105
- fs.writeFileSync(mdPath, result.prompt);
201
+ fs.writeFileSync(pyPath, files.source);
202
+ fs.writeFileSync(mdPath, files.prompt);
106
203
 
107
204
  console.log("");
108
- console.log(result.instructions || "Connected.");
205
+ console.log(files.instructions);
109
206
  console.log("");
110
- console.log(`Wrote ${path.relative(process.cwd(), pyPath)} and ${path.relative(process.cwd(), mdPath)}`);
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 [cmd, arg] = process.argv.slice(2);
117
- if (cmd !== "connect") {
118
- usage();
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(arg);
239
+ await connect(channelId, printOnly);
122
240
  } catch (err) {
123
- console.error(`golem-bridge error: ${err.message}`);
124
- process.exit(1);
241
+ fail(err.message, 1);
125
242
  }
126
243
  }
127
244
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "golem-bridge",
3
- "version": "1.0.1",
3
+ "version": "1.0.2",
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"