roforge-cli 0.3.5 → 0.3.7
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/dist/RoForgeBridge.rbxm +0 -0
- package/package.json +1 -1
- package/src/bridge/server.js +8 -1
- package/src/config.js +14 -3
- package/src/plugins.js +533 -0
- package/src/session.js +21 -2
- package/src/tools/index.js +32 -2
- package/src/tools/studio.js +37 -0
- package/src/tui/tui.js +6 -0
package/dist/RoForgeBridge.rbxm
CHANGED
|
Binary file
|
package/package.json
CHANGED
package/src/bridge/server.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
// POST /v1/bridge/jobs/:id/result → {ok}
|
|
8
8
|
// Auth: Authorization: Bearer <bridge token> (loopback-only by design).
|
|
9
9
|
import http from "node:http";
|
|
10
|
+
import { timingSafeEqual } from "node:crypto";
|
|
10
11
|
import { json } from "./wire.js";
|
|
11
12
|
|
|
12
13
|
export class BridgeServer {
|
|
@@ -59,7 +60,13 @@ export class BridgeServer {
|
|
|
59
60
|
_authorized(req) {
|
|
60
61
|
const h = String(req.headers["authorization"] || "");
|
|
61
62
|
const m = /^Bearer\s+(.+)$/i.exec(h);
|
|
62
|
-
|
|
63
|
+
if (!m) return false;
|
|
64
|
+
// Constant-time compare (defence in depth; the server is loopback-only).
|
|
65
|
+
const given = m[1].trim();
|
|
66
|
+
const a = Buffer.from(given);
|
|
67
|
+
const b = Buffer.from(this.token);
|
|
68
|
+
if (a.length !== b.length) return false;
|
|
69
|
+
return timingSafeEqual(a, b);
|
|
63
70
|
}
|
|
64
71
|
|
|
65
72
|
_handle(req, res) {
|
package/src/config.js
CHANGED
|
@@ -133,14 +133,25 @@ export function loadFileConfig() {
|
|
|
133
133
|
export function saveFileConfig(patch) {
|
|
134
134
|
const current = loadFileConfig();
|
|
135
135
|
const next = deepMerge(current, patch);
|
|
136
|
-
fs.mkdirSync(configDir(), { recursive: true });
|
|
137
|
-
fs.writeFileSync(configFile(), JSON.stringify(next, null, 2));
|
|
136
|
+
fs.mkdirSync(configDir(), { recursive: true, mode: 0o700 });
|
|
137
|
+
fs.writeFileSync(configFile(), JSON.stringify(next, null, 2), { mode: 0o600 });
|
|
138
|
+
try {
|
|
139
|
+
fs.chmodSync(configFile(), 0o600); // writeFileSync mode is ignored on existing files
|
|
140
|
+
} catch {
|
|
141
|
+
/* best effort */
|
|
142
|
+
}
|
|
138
143
|
return next;
|
|
139
144
|
}
|
|
140
145
|
|
|
146
|
+
const POLLUTION_KEYS = new Set(["__proto__", "constructor", "prototype"]);
|
|
141
147
|
function deepMerge(a, b) {
|
|
142
|
-
const out =
|
|
148
|
+
const out = Object.create(null);
|
|
149
|
+
for (const [k, v] of Object.entries(a)) {
|
|
150
|
+
if (POLLUTION_KEYS.has(k)) continue;
|
|
151
|
+
out[k] = v;
|
|
152
|
+
}
|
|
143
153
|
for (const [k, v] of Object.entries(b)) {
|
|
154
|
+
if (POLLUTION_KEYS.has(k)) continue;
|
|
144
155
|
out[k] = v && typeof v === "object" && !Array.isArray(v) && a[k] && typeof a[k] === "object" ? deepMerge(a[k], v) : v;
|
|
145
156
|
}
|
|
146
157
|
return out;
|
package/src/plugins.js
ADDED
|
@@ -0,0 +1,533 @@
|
|
|
1
|
+
// RoForge CLI — strict declarative plugin system.
|
|
2
|
+
//
|
|
3
|
+
// A plugin is a JSON file. That is the whole trust model:
|
|
4
|
+
//
|
|
5
|
+
// • NO code fields. Not a script, not a handler, not an eval, not a
|
|
6
|
+
// template engine. The validator is a strict allowlist — any key that is
|
|
7
|
+
// not explicitly permitted (at any depth) rejects the plugin. A plugin
|
|
8
|
+
// therefore cannot contain malicious intent by construction.
|
|
9
|
+
// • Actions are limited to four: http (public https only, no
|
|
10
|
+
// localhost/private addresses), command (allowlisted binary, argv array,
|
|
11
|
+
// no shell), read-file (relative path inside the project root, size-capped),
|
|
12
|
+
// transform (placeholder fill from validated args only).
|
|
13
|
+
// • The CLI never passes its own API keys or environment to plugins. There
|
|
14
|
+
// is no variable expansion at all — the only template syntax is
|
|
15
|
+
// {{argname}} and only for args declared in the tool's input_schema.
|
|
16
|
+
// • Mutations are approval-gated: http POST/PUT and command tools always
|
|
17
|
+
// require approval.
|
|
18
|
+
// • Every output is size-capped (per-tool, hard ceiling 64 KB).
|
|
19
|
+
//
|
|
20
|
+
// Placement: <project>/plugins/*.json (project-local) and
|
|
21
|
+
// ~/.roforge/plugins/*.json (user-wide). ROFORGE_PLUGINS_DIR overrides for tests.
|
|
22
|
+
|
|
23
|
+
import fs from "node:fs";
|
|
24
|
+
import path from "node:path";
|
|
25
|
+
import { execFile } from "node:child_process";
|
|
26
|
+
|
|
27
|
+
export const HARD_OUTPUT_LIMIT = 65536;
|
|
28
|
+
const DEFAULT_OUTPUT_LIMIT = 8000;
|
|
29
|
+
const DEFAULT_HTTP_TIMEOUT_MS = 15000;
|
|
30
|
+
const DEFAULT_READ_BYTES = 256 * 1024;
|
|
31
|
+
const MAX_READ_BYTES = 1024 * 1024;
|
|
32
|
+
const MAX_TOOLS = 50;
|
|
33
|
+
const MAX_MANIFEST_BYTES = 256 * 1024;
|
|
34
|
+
const NAME_RE = /^[a-z0-9][a-z0-9_-]{0,47}$/;
|
|
35
|
+
|
|
36
|
+
// Fields that are never allowed at the top level, even if a future allowlist
|
|
37
|
+
// regressed — these are the classic "hidden code" shapes.
|
|
38
|
+
const BANNED_TOP_KEYS = /^(code|script|eval|exec|shell|js|javascript|python|lua|luau|handler|entrypoint|main)$/i;
|
|
39
|
+
|
|
40
|
+
const ACTION_TYPES = new Set(["http", "command", "read-file", "transform"]);
|
|
41
|
+
const HTTP_METHODS = new Set(["GET", "POST", "PUT"]);
|
|
42
|
+
const FORBIDDEN_ARG_CHARS = /[;|&`$<>\n\r]/;
|
|
43
|
+
const PRIVATE_HOST_RE =
|
|
44
|
+
/^(localhost|127\.[0-9.]+|0\.0\.0\.0|10\.[0-9.]+|192\.168\.[0-9.]+|172\.(1[6-9]|2[0-9]|3[01])\.[0-9.]+|169\.254\.[0-9.]+|metadata\.google\.internal|.*\.local|.*\.internal)$/i;
|
|
45
|
+
|
|
46
|
+
// Fail-closed host check. new URL() normalizes IPv4 numerics (2130706433 →
|
|
47
|
+
// 127.0.0.1) but keeps IPv6 bracketed, so bracketed/unknown IPv6 is rejected
|
|
48
|
+
// outright — public dev endpoints use domain names, not raw IPv6.
|
|
49
|
+
function isPublicHost(hostIn) {
|
|
50
|
+
let host = String(hostIn || "").toLowerCase();
|
|
51
|
+
if (host.startsWith("[") && host.endsWith("]")) host = host.slice(1, -1);
|
|
52
|
+
if (host.includes(":")) {
|
|
53
|
+
if (host === "" || host === "::" || host === "::1") return false;
|
|
54
|
+
if (/^fe[89ab]/.test(host)) return false; // link-local fe80::/10
|
|
55
|
+
if (/^f[cd]/.test(host)) return false; // ULA fc00::/7
|
|
56
|
+
const mapped = /^::ffff:(.+)$/.exec(host); // IPv4-mapped ::ffff:a.b.c.d
|
|
57
|
+
if (mapped) return isPublicHost(mapped[1]);
|
|
58
|
+
return false; // any other IPv6: fail closed
|
|
59
|
+
}
|
|
60
|
+
if (!host || host.length > 253) return false;
|
|
61
|
+
if (PRIVATE_HOST_RE.test(host)) return false;
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function isObj(v) {
|
|
66
|
+
return v !== null && typeof v === "object" && !Array.isArray(v);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function err(msg, extra = {}) {
|
|
70
|
+
return { ok: false, error: msg, ...extra };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function checkName(name, what) {
|
|
74
|
+
if (typeof name !== "string" || !NAME_RE.test(name)) {
|
|
75
|
+
return err(`${what} name must match ${NAME_RE} (got ${JSON.stringify(name)})`);
|
|
76
|
+
}
|
|
77
|
+
if (name.startsWith("forge_")) {
|
|
78
|
+
return err(`${what} name "${name}" is reserved for Studio bridge tools`);
|
|
79
|
+
}
|
|
80
|
+
return { ok: true, value: name };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function checkDesc(desc) {
|
|
84
|
+
if (typeof desc !== "string" || desc.length === 0 || desc.length > 300) {
|
|
85
|
+
return err("description must be a non-empty string (max 300 chars)");
|
|
86
|
+
}
|
|
87
|
+
return { ok: true, value: desc };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// input_schema: JSON-schema object shape, strict, no $ref, no patterns
|
|
91
|
+
// beyond simple strings. Returns the property names for template checks.
|
|
92
|
+
function checkInputSchema(schema) {
|
|
93
|
+
if (!isObj(schema)) return err("input_schema must be an object");
|
|
94
|
+
const allowed = new Set(["type", "properties", "required", "additionalProperties", "description"]);
|
|
95
|
+
for (const k of Object.keys(schema)) {
|
|
96
|
+
if (!allowed.has(k)) return err(`input_schema: unknown key "${k}"`);
|
|
97
|
+
}
|
|
98
|
+
if (schema.type !== "object") return err('input_schema.type must be "object"');
|
|
99
|
+
if (schema.additionalProperties !== false) {
|
|
100
|
+
return err('input_schema must set "additionalProperties": false (strict)');
|
|
101
|
+
}
|
|
102
|
+
const props = schema.properties || {};
|
|
103
|
+
const propNames = new Set();
|
|
104
|
+
for (const [pname, pdef] of Object.entries(props)) {
|
|
105
|
+
if (!NAME_RE.test(pname)) return err(`input_schema property "${pname}" has an invalid name`);
|
|
106
|
+
if (!isObj(pdef)) return err(`input_schema property "${pname}" must be an object`);
|
|
107
|
+
const pAllowed = new Set(["type", "description", "minimum", "maximum", "minLength", "maxLength", "items"]);
|
|
108
|
+
for (const k of Object.keys(pdef)) {
|
|
109
|
+
if (!pAllowed.has(k)) return err(`input_schema property "${pname}": unknown key "${k}"`);
|
|
110
|
+
}
|
|
111
|
+
if (!["string", "integer", "number", "boolean", "array"].includes(pdef.type)) {
|
|
112
|
+
return err(`input_schema property "${pname}": unsupported type "${pdef.type}"`);
|
|
113
|
+
}
|
|
114
|
+
if (pdef.type === "array" && !isObj(pdef.items)) {
|
|
115
|
+
return err(`input_schema property "${pname}": array needs "items"`);
|
|
116
|
+
}
|
|
117
|
+
propNames.add(pname);
|
|
118
|
+
}
|
|
119
|
+
const required = schema.required || [];
|
|
120
|
+
if (!Array.isArray(required)) return err('input_schema.required must be an array');
|
|
121
|
+
for (const r of required) {
|
|
122
|
+
if (!propNames.has(r)) return err(`input_schema.required references unknown property "${r}"`);
|
|
123
|
+
}
|
|
124
|
+
return { ok: true, value: propNames };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Template strings: only {{argname}} placeholders, and only names that are
|
|
128
|
+
// declared in the input schema. Everything else is a literal.
|
|
129
|
+
function checkTemplate(str, propNames, what) {
|
|
130
|
+
if (typeof str !== "string") return err(`${what} must be a string`);
|
|
131
|
+
if (str.length > 20000) return err(`${what} too long`);
|
|
132
|
+
const refs = [...str.matchAll(/{{\s*([a-zA-Z0-9_][a-zA-Z0-9_-]*)\s*}}/g)];
|
|
133
|
+
for (const m of refs) {
|
|
134
|
+
if (!propNames.has(m[1])) {
|
|
135
|
+
return err(`${what} references undeclared arg "${m[1]}" (only input_schema properties may be used)`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
// Any other {{...}} (env-ish, nested, malformed) is rejected.
|
|
139
|
+
const stripped = str.replace(/{{\s*[a-zA-Z0-9_][a-zA-Z0-9_-]*\s*}}/g, "");
|
|
140
|
+
if (stripped.includes("{{") || stripped.includes("}}")) {
|
|
141
|
+
return err(`${what} contains an unsupported {{...}} expression (only declared args may be referenced)`);
|
|
142
|
+
}
|
|
143
|
+
return { ok: true, value: str };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function fillTemplate(str, args) {
|
|
147
|
+
return str.replace(/{{\s*([a-zA-Z0-9_][a-zA-Z0-9_-]*)\s*}}/g, (_, name) => {
|
|
148
|
+
const v = args[name];
|
|
149
|
+
if (v === undefined || v === null) return "";
|
|
150
|
+
return encodeURIComponent(String(v));
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
// Raw fill (command argv): no URL-encoding.
|
|
154
|
+
function fillTemplateRaw(str, args) {
|
|
155
|
+
return str.replace(/{{\s*([a-zA-Z0-9_][a-zA-Z0-9_-]*)\s*}}/g, (_, name) => {
|
|
156
|
+
const v = args[name];
|
|
157
|
+
if (v === undefined || v === null) return "";
|
|
158
|
+
return String(v);
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function checkOutputMax(v) {
|
|
163
|
+
if (v === undefined) return { ok: true, value: DEFAULT_OUTPUT_LIMIT };
|
|
164
|
+
if (typeof v !== "number" || !Number.isInteger(v) || v < 1 || v > HARD_OUTPUT_LIMIT) {
|
|
165
|
+
return err(`output_max_chars must be an integer 1..${HARD_OUTPUT_LIMIT}`);
|
|
166
|
+
}
|
|
167
|
+
return { ok: true, value: v };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ---- action validators (strict allowlists) ----
|
|
171
|
+
|
|
172
|
+
function checkHttpAction(a, propNames) {
|
|
173
|
+
const allowed = new Set(["type", "method", "url", "headers", "body", "query", "timeout_ms", "output_max_chars"]);
|
|
174
|
+
for (const k of Object.keys(a)) if (!allowed.has(k)) return err(`http action: unknown key "${k}"`);
|
|
175
|
+
if (!HTTP_METHODS.has(a.method)) return err('http action.method must be GET, POST, or PUT');
|
|
176
|
+
if (typeof a.url !== "string" || a.url.length > 2000) return err("http action.url must be a string");
|
|
177
|
+
let u;
|
|
178
|
+
try {
|
|
179
|
+
u = new URL(a.url);
|
|
180
|
+
} catch {
|
|
181
|
+
return err(`http action.url is not a valid URL: ${a.url}`);
|
|
182
|
+
}
|
|
183
|
+
if (u.protocol !== "https:") return err("http action.url must be https (public web only)");
|
|
184
|
+
if (!isPublicHost(u.hostname)) return err(`http action.url host "${u.hostname}" is not public (localhost/private ranges are forbidden)`);
|
|
185
|
+
if (u.port !== "" && u.port !== "443") return err("http action.url may only use the default https port");
|
|
186
|
+
|
|
187
|
+
let query = {};
|
|
188
|
+
if (a.query !== undefined) {
|
|
189
|
+
if (!isObj(a.query)) return err("http action.query must be an object");
|
|
190
|
+
const QUERY_KEY_RE = /^[A-Za-z0-9._-]{1,64}$/; // API param names (e.g. universeIds)
|
|
191
|
+
for (const [k, v] of Object.entries(a.query)) {
|
|
192
|
+
if (!QUERY_KEY_RE.test(k)) return err(`http action.query key "${k}" invalid`);
|
|
193
|
+
const t = checkTemplate(String(v), propNames, `http action.query.${k}`);
|
|
194
|
+
if (!t.ok) return t;
|
|
195
|
+
query[k] = t.value;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
const headers = a.headers !== undefined ? (isObj(a.headers) ? a.headers : err("http action.headers must be an object")) : {};
|
|
199
|
+
if (isObj(a.headers)) {
|
|
200
|
+
for (const [k, v] of Object.entries(a.headers)) {
|
|
201
|
+
if (typeof k !== "string" || /[\r\n]/.test(k)) return err(`http header key is invalid: ${JSON.stringify(k)}`);
|
|
202
|
+
if (typeof v !== "string" || v.length > 500) return err(`http header "${k}" must be a short string`);
|
|
203
|
+
if (/[\r\n\u0000]/.test(v)) return err(`http header "${k}" value contains forbidden control characters`);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
let body;
|
|
207
|
+
if (a.body !== undefined) {
|
|
208
|
+
if (a.method === "GET") return err("http action.body is not allowed with GET");
|
|
209
|
+
const t = checkTemplate(String(a.body), propNames, "http action.body");
|
|
210
|
+
if (!t.ok) return t;
|
|
211
|
+
body = t.value;
|
|
212
|
+
}
|
|
213
|
+
const to =
|
|
214
|
+
a.timeout_ms !== undefined
|
|
215
|
+
? typeof a.timeout_ms === "number" && a.timeout_ms >= 100 && a.timeout_ms <= 60000
|
|
216
|
+
? a.timeout_ms
|
|
217
|
+
: err("http action.timeout_ms must be 100..60000")
|
|
218
|
+
: DEFAULT_HTTP_TIMEOUT_MS;
|
|
219
|
+
if (!Number.isInteger(to)) return to;
|
|
220
|
+
const out = checkOutputMax(a.output_max_chars);
|
|
221
|
+
if (!out.ok) return out;
|
|
222
|
+
return { ok: true, value: { kind: "http", method: a.method, url: a.url, query, headers, body, timeoutMs: to, outputMax: out.value } };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function checkCommandAction(a, propNames, allowedCommands) {
|
|
226
|
+
const allowed = new Set(["type", "command", "args", "output_max_chars"]);
|
|
227
|
+
for (const k of Object.keys(a)) if (!allowed.has(k)) return err(`command action: unknown key "${k}"`);
|
|
228
|
+
if (!allowedCommands.includes(a.command)) {
|
|
229
|
+
return err(`command action: "${a.command}" is not in the allowlist (${allowedCommands.join(", ")})`);
|
|
230
|
+
}
|
|
231
|
+
const args = [];
|
|
232
|
+
if (a.args !== undefined) {
|
|
233
|
+
if (!Array.isArray(a.args) || a.args.length > 32) return err("command action.args must be an array (max 32)");
|
|
234
|
+
for (const v of a.args) {
|
|
235
|
+
if (typeof v !== "string" || v.length > 200) return err("command action.args entries must be strings (max 200)");
|
|
236
|
+
if (FORBIDDEN_ARG_CHARS.test(v)) return err(`command arg "${v}" contains shell metacharacters (not allowed)`);
|
|
237
|
+
const t = checkTemplate(v, propNames, "command arg");
|
|
238
|
+
if (!t.ok) return t;
|
|
239
|
+
args.push(t.value);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
const out = checkOutputMax(a.output_max_chars);
|
|
243
|
+
if (!out.ok) return out;
|
|
244
|
+
return { ok: true, value: { kind: "command", command: a.command, args, outputMax: out.value } };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function checkReadFileAction(a) {
|
|
248
|
+
const allowed = new Set(["type", "path", "max_bytes"]);
|
|
249
|
+
for (const k of Object.keys(a)) if (!allowed.has(k)) return err(`read-file action: unknown key "${k}"`);
|
|
250
|
+
if (typeof a.path !== "string" || a.path.length === 0 || a.path.length > 500) return err("read-file action.path must be a string");
|
|
251
|
+
if (path.isAbsolute(a.path) || a.path.startsWith("~")) return err("read-file action.path must be relative to the project root");
|
|
252
|
+
// Both separator styles: manifests are validated cross-platform, and a
|
|
253
|
+
// backslash path is traversal on Windows.
|
|
254
|
+
if (a.path.split(/[\\/]+/).includes("..")) {
|
|
255
|
+
return err("read-file action.path may not traverse above the project root");
|
|
256
|
+
}
|
|
257
|
+
const norm = path.normalize(a.path);
|
|
258
|
+
const mb = a.max_bytes !== undefined
|
|
259
|
+
? typeof a.max_bytes === "number" && Number.isInteger(a.max_bytes) && a.max_bytes >= 1 && a.max_bytes <= MAX_READ_BYTES
|
|
260
|
+
? a.max_bytes
|
|
261
|
+
: err(`read-file action.max_bytes must be 1..${MAX_READ_BYTES}`)
|
|
262
|
+
: DEFAULT_READ_BYTES;
|
|
263
|
+
if (!Number.isInteger(mb)) return mb;
|
|
264
|
+
return { ok: true, value: { kind: "read-file", path: norm, maxBytes: mb, outputMax: Math.min(DEFAULT_OUTPUT_LIMIT, mb) } };
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function checkTransformAction(a, propNames) {
|
|
268
|
+
const allowed = new Set(["type", "template", "output_max_chars"]);
|
|
269
|
+
for (const k of Object.keys(a)) if (!allowed.has(k)) return err(`transform action: unknown key "${k}"`);
|
|
270
|
+
const t = checkTemplate(a.template, propNames, "transform action.template");
|
|
271
|
+
if (!t.ok) return t;
|
|
272
|
+
const out = checkOutputMax(a.output_max_chars);
|
|
273
|
+
if (!out.ok) return out;
|
|
274
|
+
return { ok: true, value: { kind: "transform", template: t.value, outputMax: out.value } };
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// ---- manifest validation ----
|
|
278
|
+
|
|
279
|
+
export function validateManifest(obj, allowedCommands = ALLOWED_COMMANDS) {
|
|
280
|
+
if (!isObj(obj)) return err("manifest must be a JSON object");
|
|
281
|
+
for (const k of Object.keys(obj)) {
|
|
282
|
+
if (BANNED_TOP_KEYS.test(k)) {
|
|
283
|
+
return err(`plugin manifest contains a code field ("${k}") — plugins are declarative only`);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
const allowed = new Set(["name", "version", "description", "tools"]);
|
|
287
|
+
for (const k of Object.keys(obj)) if (!allowed.has(k)) return err(`unknown manifest key "${k}"`);
|
|
288
|
+
const name = checkName(obj.name, "plugin");
|
|
289
|
+
if (!name.ok) return name;
|
|
290
|
+
if (typeof obj.version !== "string" || !/^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$/.test(obj.version)) {
|
|
291
|
+
return err('version must be semver-like "1.2.3"');
|
|
292
|
+
}
|
|
293
|
+
const desc = checkDesc(obj.description);
|
|
294
|
+
if (!desc.ok) return desc;
|
|
295
|
+
if (!Array.isArray(obj.tools) || obj.tools.length < 1 || obj.tools.length > MAX_TOOLS) {
|
|
296
|
+
return err(`tools must be an array of 1..${MAX_TOOLS}`);
|
|
297
|
+
}
|
|
298
|
+
const tools = [];
|
|
299
|
+
const seen = new Set();
|
|
300
|
+
for (const t of obj.tools) {
|
|
301
|
+
if (!isObj(t)) return err("each tool must be an object");
|
|
302
|
+
const tAllowed = new Set(["name", "description", "input_schema", "action"]);
|
|
303
|
+
for (const k of Object.keys(t)) {
|
|
304
|
+
if (BANNED_TOP_KEYS.test(k)) return err(`tool contains a code field ("${k}") — plugins are declarative only`);
|
|
305
|
+
if (!tAllowed.has(k)) return err(`tool: unknown key "${k}"`);
|
|
306
|
+
}
|
|
307
|
+
const tn = checkName(t.name, "tool");
|
|
308
|
+
if (!tn.ok) return tn;
|
|
309
|
+
if (seen.has(tn.value)) return err(`duplicate tool name "${tn.value}"`);
|
|
310
|
+
seen.add(tn.value);
|
|
311
|
+
const td = checkDesc(t.description);
|
|
312
|
+
if (!td.ok) return td;
|
|
313
|
+
const sch = checkInputSchema(t.input_schema);
|
|
314
|
+
if (!sch.ok) return err(`tool "${tn.value}": ${sch.error}`);
|
|
315
|
+
const action = t.action;
|
|
316
|
+
if (!isObj(action)) return err(`tool "${tn.value}": action must be an object`);
|
|
317
|
+
if (!ACTION_TYPES.has(action.type)) {
|
|
318
|
+
return err(`tool "${tn.value}": action.type must be one of ${[...ACTION_TYPES].join(", ")}`);
|
|
319
|
+
}
|
|
320
|
+
let av;
|
|
321
|
+
switch (action.type) {
|
|
322
|
+
case "http":
|
|
323
|
+
av = checkHttpAction(action, sch.value);
|
|
324
|
+
break;
|
|
325
|
+
case "command":
|
|
326
|
+
av = checkCommandAction(action, sch.value, allowedCommands);
|
|
327
|
+
break;
|
|
328
|
+
case "read-file":
|
|
329
|
+
av = checkReadFileAction(action);
|
|
330
|
+
break;
|
|
331
|
+
case "transform":
|
|
332
|
+
av = checkTransformAction(action, sch.value);
|
|
333
|
+
break;
|
|
334
|
+
}
|
|
335
|
+
if (!av.ok) return err(`tool "${tn.value}": ${av.error}`);
|
|
336
|
+
const destructive = av.value.kind === "command" || (av.value.kind === "http" && av.value.method !== "GET");
|
|
337
|
+
tools.push({
|
|
338
|
+
name: tn.value,
|
|
339
|
+
description: td.value,
|
|
340
|
+
inputSchema: t.input_schema,
|
|
341
|
+
action: av.value,
|
|
342
|
+
requiresApproval: destructive,
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
return { ok: true, manifest: { name: name.value, version: obj.version, description: desc.value, tools } };
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// Default allowlist; overridable per load call (config / tests).
|
|
349
|
+
export const DEFAULT_ALLOWED_COMMANDS = ["roforge"];
|
|
350
|
+
const ALLOWED_COMMANDS = DEFAULT_ALLOWED_COMMANDS;
|
|
351
|
+
|
|
352
|
+
// ---- execution ----
|
|
353
|
+
|
|
354
|
+
function capText(text, max, label) {
|
|
355
|
+
const s = String(text);
|
|
356
|
+
if (s.length <= max) return s;
|
|
357
|
+
return `${s.slice(0, max)}\n… truncated (${s.length - max} more chars; ${label} is capped at ${max})`;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
export function pluginToolExecutor(plugin, tool) {
|
|
361
|
+
return async (args) => {
|
|
362
|
+
const a = tool.action;
|
|
363
|
+
args = isObj(args) ? args : {};
|
|
364
|
+
try {
|
|
365
|
+
if (a.kind === "http") {
|
|
366
|
+
// Manual redirects: every hop must re-pass the public-host guard
|
|
367
|
+
// (a 302 to a private address is an SSRF and gets refused).
|
|
368
|
+
const start = new URL(a.url);
|
|
369
|
+
for (const [k, tmpl] of Object.entries(a.query)) start.searchParams.set(k, fillTemplate(tmpl, args));
|
|
370
|
+
let res = null;
|
|
371
|
+
let cur = start;
|
|
372
|
+
for (let hop = 0; hop <= 3; hop++) {
|
|
373
|
+
res = await fetch(cur.toString(), {
|
|
374
|
+
method: hop === 0 ? a.method : "GET",
|
|
375
|
+
headers: a.headers,
|
|
376
|
+
body: hop === 0 && a.body !== undefined ? fillTemplate(a.body, args) : undefined,
|
|
377
|
+
redirect: "manual",
|
|
378
|
+
signal: AbortSignal.timeout(a.timeoutMs),
|
|
379
|
+
});
|
|
380
|
+
if ([301, 302, 303, 307, 308].includes(res.status)) {
|
|
381
|
+
const loc = res.headers.get("location");
|
|
382
|
+
if (!loc) return "ERROR: redirect without Location";
|
|
383
|
+
let next;
|
|
384
|
+
try {
|
|
385
|
+
next = new URL(loc, cur);
|
|
386
|
+
} catch {
|
|
387
|
+
return "ERROR: invalid redirect Location";
|
|
388
|
+
}
|
|
389
|
+
if (next.protocol !== "https:" || !isPublicHost(next.hostname) || (next.port !== "" && next.port !== "443")) {
|
|
390
|
+
return "ERROR: redirect to a non-public https target is refused (SSRF guard)";
|
|
391
|
+
}
|
|
392
|
+
cur = next;
|
|
393
|
+
continue;
|
|
394
|
+
}
|
|
395
|
+
break;
|
|
396
|
+
}
|
|
397
|
+
if (!res) return "ERROR: too many redirects";
|
|
398
|
+
// Cap the response body (a hostile public host cannot stream gigabytes
|
|
399
|
+
// into memory): read up to 1 MiB, stop, discard the rest.
|
|
400
|
+
const MAX_BODY = 1024 * 1024;
|
|
401
|
+
const reader = res.body ? res.body.getReader() : null;
|
|
402
|
+
let text;
|
|
403
|
+
if (reader) {
|
|
404
|
+
const parts = [];
|
|
405
|
+
let total = 0;
|
|
406
|
+
for (;;) {
|
|
407
|
+
const { done, value } = await reader.read();
|
|
408
|
+
if (done) break;
|
|
409
|
+
const room = MAX_BODY - total;
|
|
410
|
+
if (value.length > room) {
|
|
411
|
+
parts.push(value.subarray(0, room));
|
|
412
|
+
total += room;
|
|
413
|
+
try { await reader.cancel(); } catch { /* ignore */ }
|
|
414
|
+
break;
|
|
415
|
+
}
|
|
416
|
+
parts.push(value);
|
|
417
|
+
total += value.length;
|
|
418
|
+
}
|
|
419
|
+
text = Buffer.concat(parts).toString("utf8");
|
|
420
|
+
} else {
|
|
421
|
+
text = await res.text();
|
|
422
|
+
}
|
|
423
|
+
if (!res.ok) return `ERROR: HTTP ${res.status} ${res.statusText} from ${a.url}`;
|
|
424
|
+
return capText(text, a.outputMax, "plugin output");
|
|
425
|
+
}
|
|
426
|
+
if (a.kind === "command") {
|
|
427
|
+
const out = await new Promise((resolve, reject) => {
|
|
428
|
+
execFile(a.command, a.args.map((t) => fillTemplateRaw(t, args)), {
|
|
429
|
+
timeout: 30000,
|
|
430
|
+
maxBuffer: Math.max(a.outputMax * 2, 64 * 1024),
|
|
431
|
+
}, (e, stdout, stderr) => (e ? reject(e) : resolve(stdout)));
|
|
432
|
+
});
|
|
433
|
+
return capText(out, a.outputMax, "plugin output");
|
|
434
|
+
}
|
|
435
|
+
if (a.kind === "read-file") {
|
|
436
|
+
const root = fs.realpathSync(process.cwd());
|
|
437
|
+
let full;
|
|
438
|
+
try {
|
|
439
|
+
full = fs.realpathSync(path.resolve(root, a.path));
|
|
440
|
+
} catch {
|
|
441
|
+
return `ERROR: file not found: ${a.path}`;
|
|
442
|
+
}
|
|
443
|
+
// realpath follows symlinks — a link pointing outside the project is
|
|
444
|
+
// rejected here, not at parse time.
|
|
445
|
+
if (full !== root && !full.startsWith(root + path.sep)) return "ERROR: path escapes the project root";
|
|
446
|
+
if (!fs.statSync(full).isFile()) return `ERROR: not a file: ${a.path}`;
|
|
447
|
+
const st = fs.statSync(full);
|
|
448
|
+
if (st.size > a.maxBytes) return `ERROR: file too large (${st.size} > ${a.maxBytes} bytes)`;
|
|
449
|
+
return capText(fs.readFileSync(full, "utf8"), a.outputMax, "file output");
|
|
450
|
+
}
|
|
451
|
+
if (a.kind === "transform") {
|
|
452
|
+
// Plain-text output: raw (unencoded) values.
|
|
453
|
+
return capText(fillTemplateRaw(a.template, args), a.outputMax, "plugin output");
|
|
454
|
+
}
|
|
455
|
+
} catch (e) {
|
|
456
|
+
return `ERROR: ${e.message || String(e)}`;
|
|
457
|
+
}
|
|
458
|
+
return "ERROR: unknown action";
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// ---- discovery + build ----
|
|
463
|
+
|
|
464
|
+
export function pluginDirs({ cwd, configDirBase }) {
|
|
465
|
+
const dirs = [];
|
|
466
|
+
if (process.env.ROFORGE_PLUGINS_DIR) dirs.push(process.env.ROFORGE_PLUGINS_DIR);
|
|
467
|
+
if (cwd) dirs.push(path.join(cwd, "plugins"));
|
|
468
|
+
if (configDirBase) dirs.push(path.join(configDirBase, "plugins"));
|
|
469
|
+
return [...new Set(dirs)];
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
export function loadPlugins({ cwd, configDirBase, allowedCommands = ALLOWED_COMMANDS } = {}) {
|
|
473
|
+
// Bind the allowlist for this load (validateManifest reads ALLOWED_COMMANDS
|
|
474
|
+
// via the closure below).
|
|
475
|
+
const tools = [];
|
|
476
|
+
const plugins = [];
|
|
477
|
+
const errors = [];
|
|
478
|
+
const seenTool = new Set();
|
|
479
|
+
for (const dir of pluginDirs({ cwd, configDirBase })) {
|
|
480
|
+
let entries = [];
|
|
481
|
+
try {
|
|
482
|
+
entries = fs.readdirSync(dir).filter((f) => f.endsWith(".json")).sort();
|
|
483
|
+
} catch {
|
|
484
|
+
continue;
|
|
485
|
+
}
|
|
486
|
+
for (const f of entries) {
|
|
487
|
+
const file = path.join(dir, f);
|
|
488
|
+
let raw;
|
|
489
|
+
try {
|
|
490
|
+
raw = fs.readFileSync(file);
|
|
491
|
+
} catch {
|
|
492
|
+
errors.push({ file, error: "unreadable" });
|
|
493
|
+
continue;
|
|
494
|
+
}
|
|
495
|
+
if (raw.length > MAX_MANIFEST_BYTES) {
|
|
496
|
+
errors.push({ file, error: `manifest too large (${raw.length} > ${MAX_MANIFEST_BYTES} bytes)` });
|
|
497
|
+
continue;
|
|
498
|
+
}
|
|
499
|
+
let obj;
|
|
500
|
+
try {
|
|
501
|
+
obj = JSON.parse(raw.toString("utf8"));
|
|
502
|
+
} catch (e) {
|
|
503
|
+
errors.push({ file, error: `invalid JSON: ${e.message}` });
|
|
504
|
+
continue;
|
|
505
|
+
}
|
|
506
|
+
const res = validateManifest(obj, allowedCommands);
|
|
507
|
+
if (!res.ok) {
|
|
508
|
+
errors.push({ file, error: res.error });
|
|
509
|
+
continue;
|
|
510
|
+
}
|
|
511
|
+
const m = res.manifest;
|
|
512
|
+
const dupTools = m.tools.filter((t) => seenTool.has(t.name));
|
|
513
|
+
if (dupTools.length) {
|
|
514
|
+
errors.push({ file, error: `tool name conflict with an already-loaded plugin: ${dupTools.map((t) => t.name).join(", ")}` });
|
|
515
|
+
continue;
|
|
516
|
+
}
|
|
517
|
+
plugins.push({ name: m.name, version: m.version, file, toolCount: m.tools.length });
|
|
518
|
+
for (const t of m.tools) {
|
|
519
|
+
seenTool.add(t.name);
|
|
520
|
+
tools.push({
|
|
521
|
+
name: t.name,
|
|
522
|
+
description: `[plugin:${m.name}] ${t.description}`,
|
|
523
|
+
inputSchema: t.inputSchema,
|
|
524
|
+
tier: "plugin",
|
|
525
|
+
plugin: m.name,
|
|
526
|
+
requiresApproval: t.requiresApproval,
|
|
527
|
+
execute: pluginToolExecutor(m, t),
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
return { tools, plugins, errors };
|
|
533
|
+
}
|
package/src/session.js
CHANGED
|
@@ -49,7 +49,14 @@ export class Session {
|
|
|
49
49
|
luauAnalyzePath: this.luauAnalyzePath,
|
|
50
50
|
});
|
|
51
51
|
this.tools = info.tools;
|
|
52
|
-
this.studioInfo = {
|
|
52
|
+
this.studioInfo = {
|
|
53
|
+
mcp: info.mcp,
|
|
54
|
+
bridge: info.bridge,
|
|
55
|
+
mcpToolCount: info.mcpToolCount,
|
|
56
|
+
mcpCapture: info.mcpCapture || [],
|
|
57
|
+
plugins: info.plugins || [],
|
|
58
|
+
pluginErrors: info.pluginErrors || [],
|
|
59
|
+
};
|
|
53
60
|
this.cfg._activeModel = this.model;
|
|
54
61
|
return this.studioInfo;
|
|
55
62
|
}
|
|
@@ -90,7 +97,19 @@ Rules:
|
|
|
90
97
|
Current state:
|
|
91
98
|
- Model: ${this.model} (${this.providerName}${PROVIDERS[this.providerName] && PROVIDERS[this.providerName].hasFreeTier && this.cfg.freeFirst !== false ? ", free tier" : ""})
|
|
92
99
|
- ${studio.join(" ")}
|
|
93
|
-
- Tools: ${this.tools.map((t) => t.name).join(", ")}
|
|
100
|
+
- Tools: ${this.tools.map((t) => t.name).join(", ")}${
|
|
101
|
+
this.studioInfo.plugins && this.studioInfo.plugins.length
|
|
102
|
+
? `\n\nPlugins (strict declarative JSON — no code, outputs capped, mutations approval-gated): ${this.studioInfo.plugins
|
|
103
|
+
.map((p) => `${p.name}@${p.version} (${p.toolCount} tools)`)
|
|
104
|
+
.join(", ")}.`
|
|
105
|
+
: ""
|
|
106
|
+
}${
|
|
107
|
+
this.studioInfo.pluginErrors && this.studioInfo.pluginErrors.length
|
|
108
|
+
? `\n\nRejected plugins (tell the user if they ask; fix the JSON and restart): ${this.studioInfo.pluginErrors
|
|
109
|
+
.map((e) => `${e.file}: ${e.error}`)
|
|
110
|
+
.join("; ")}.`
|
|
111
|
+
: ""
|
|
112
|
+
}`;
|
|
94
113
|
}
|
|
95
114
|
|
|
96
115
|
async send(userText) {
|
package/src/tools/index.js
CHANGED
|
@@ -7,6 +7,8 @@ import { robloxTools } from "./roblox.js";
|
|
|
7
7
|
import { projectTools } from "./project.js";
|
|
8
8
|
import { bridgeTools, mcpToolsFromList, mcpCaptureNames } from "./studio.js";
|
|
9
9
|
import { McpClient } from "../mcp.js";
|
|
10
|
+
import { loadPlugins, DEFAULT_ALLOWED_COMMANDS } from "../plugins.js";
|
|
11
|
+
import { configDir } from "../config.js";
|
|
10
12
|
|
|
11
13
|
export async function buildTools({ cfg, cwd, bridgeServer, luauAnalyzePath }) {
|
|
12
14
|
const tools = [];
|
|
@@ -24,6 +26,18 @@ export async function buildTools({ cfg, cwd, bridgeServer, luauAnalyzePath }) {
|
|
|
24
26
|
add(projectTools({ cwd, luauAnalyzePath }));
|
|
25
27
|
if (bridgeServer) add(bridgeTools(bridgeServer));
|
|
26
28
|
|
|
29
|
+
// Strict declarative plugins (JSON only — no code fields, ever).
|
|
30
|
+
const pcfg = (cfg && cfg.plugins) || {};
|
|
31
|
+
const allowedCommands =
|
|
32
|
+
Array.isArray(pcfg.allowedCommands) && pcfg.allowedCommands.length
|
|
33
|
+
? pcfg.allowedCommands.filter((c) => typeof c === "string" && /^[a-z0-9._-]{1,32}$/i.test(c))
|
|
34
|
+
: DEFAULT_ALLOWED_COMMANDS;
|
|
35
|
+
const pluginInfo =
|
|
36
|
+
pcfg.enabled === false
|
|
37
|
+
? { tools: [], plugins: [], pluginErrors: [] }
|
|
38
|
+
: loadPlugins({ cwd, configDirBase: configDir(), allowedCommands });
|
|
39
|
+
add(pluginInfo.tools);
|
|
40
|
+
|
|
27
41
|
// MCP tier (official, built into Studio)
|
|
28
42
|
if (cfg.studioMode === "mcp" || cfg.studioMode === "auto") {
|
|
29
43
|
try {
|
|
@@ -34,14 +48,30 @@ export async function buildTools({ cfg, cwd, bridgeServer, luauAnalyzePath }) {
|
|
|
34
48
|
add(mcpToolsFromList(client, raw));
|
|
35
49
|
cfg._mcpClient = client;
|
|
36
50
|
cfg._mcpConnected = true;
|
|
37
|
-
return {
|
|
51
|
+
return {
|
|
52
|
+
tools,
|
|
53
|
+
mcp: true,
|
|
54
|
+
bridge: Boolean(bridgeServer),
|
|
55
|
+
mcpToolCount: raw.length,
|
|
56
|
+
mcpCapture: mcpCaptureNames(raw),
|
|
57
|
+
plugins: pluginInfo.plugins,
|
|
58
|
+
pluginErrors: pluginInfo.errors,
|
|
59
|
+
};
|
|
38
60
|
}
|
|
39
61
|
} catch {
|
|
40
62
|
/* fall through to bridge-only */
|
|
41
63
|
}
|
|
42
64
|
}
|
|
43
65
|
|
|
44
|
-
return {
|
|
66
|
+
return {
|
|
67
|
+
tools,
|
|
68
|
+
mcp: false,
|
|
69
|
+
bridge: Boolean(bridgeServer),
|
|
70
|
+
mcpToolCount: 0,
|
|
71
|
+
mcpCapture: [],
|
|
72
|
+
plugins: pluginInfo.plugins,
|
|
73
|
+
pluginErrors: pluginInfo.errors,
|
|
74
|
+
};
|
|
45
75
|
}
|
|
46
76
|
|
|
47
77
|
export function listToolNames(tools) {
|
package/src/tools/studio.js
CHANGED
|
@@ -29,6 +29,10 @@ const BRIDGE_TOOL_NAMES = [
|
|
|
29
29
|
"forge_export",
|
|
30
30
|
"forge_import",
|
|
31
31
|
"forge_pro",
|
|
32
|
+
"forge_pro_features",
|
|
33
|
+
"forge_cloud_snapshot",
|
|
34
|
+
"forge_cloud_restore",
|
|
35
|
+
"forge_team_share",
|
|
32
36
|
];
|
|
33
37
|
|
|
34
38
|
const BRIDGE_DESCRIPTIONS = {
|
|
@@ -57,6 +61,10 @@ const BRIDGE_DESCRIPTIONS = {
|
|
|
57
61
|
forge_export: "Export a DataModel subtree as JSON (properties, script sources truncated, attributes). Defaults to workspace, depth 3 (max 6; 10 with RoForge Pro).",
|
|
58
62
|
forge_import: "Apply a forge_export JSON back into Studio: recreates the instance tree (properties, sources, attributes) under a parent. dry_run=true only reports. Destructive; max 500 nodes (2500 with RoForge Pro).",
|
|
59
63
|
forge_pro: "Report the current RoForge Pro entitlement: Free or Pro, which pass/product the Studio user owns, and the active limits + Pro features. Call it to know whether the user has Pro.",
|
|
64
|
+
forge_pro_features: "Status of the Pro feature component (closed module): installed or absent, plus its report (snapshot store, team workspace, MCP relay). Read-only; call before offering Pro features.",
|
|
65
|
+
forge_cloud_snapshot: "PRO: save a named snapshot of a place subtree (default: workspace) to the Pro store for later restore. Needs the Pro module installed and the Pro pass.",
|
|
66
|
+
forge_cloud_restore: "PRO: restore a named Pro snapshot into the place (default under workspace). Destructive: rebuilds the instance tree. Needs the Pro module installed and the Pro pass.",
|
|
67
|
+
forge_team_share: "PRO: publish a reference (place id, checkpoint label, or note) to the shared team workspace visible to teammates in the place. Destructive: writes the shared store. Needs the Pro module installed and the Pro pass.",
|
|
60
68
|
};
|
|
61
69
|
|
|
62
70
|
const BRIDGE_SCHEMAS = {
|
|
@@ -216,6 +224,33 @@ const BRIDGE_SCHEMAS = {
|
|
|
216
224
|
additionalProperties: false,
|
|
217
225
|
},
|
|
218
226
|
forge_pro: { type: "object", properties: {}, additionalProperties: false },
|
|
227
|
+
forge_pro_features: { type: "object", properties: {}, additionalProperties: false },
|
|
228
|
+
forge_cloud_snapshot: {
|
|
229
|
+
type: "object",
|
|
230
|
+
properties: {
|
|
231
|
+
path: { type: "string", description: "Dotted subtree root (default: workspace)" },
|
|
232
|
+
name: { type: "string", description: "Snapshot label (default: auto)" },
|
|
233
|
+
},
|
|
234
|
+
additionalProperties: false,
|
|
235
|
+
},
|
|
236
|
+
forge_cloud_restore: {
|
|
237
|
+
type: "object",
|
|
238
|
+
properties: {
|
|
239
|
+
name: { type: "string", description: "Snapshot label to restore" },
|
|
240
|
+
path: { type: "string", description: "Dotted parent to restore into (default: workspace)" },
|
|
241
|
+
},
|
|
242
|
+
required: ["name"],
|
|
243
|
+
additionalProperties: false,
|
|
244
|
+
},
|
|
245
|
+
forge_team_share: {
|
|
246
|
+
type: "object",
|
|
247
|
+
properties: {
|
|
248
|
+
label: { type: "string", description: "Short name, e.g. 'level1-wip'" },
|
|
249
|
+
ref: { type: "string", description: "The reference to share, e.g. 'place:12345'" },
|
|
250
|
+
},
|
|
251
|
+
required: ["label", "ref"],
|
|
252
|
+
additionalProperties: false,
|
|
253
|
+
},
|
|
219
254
|
};
|
|
220
255
|
|
|
221
256
|
// Tools that modify the DataModel — gated by the approval prompt.
|
|
@@ -229,6 +264,8 @@ const DESTRUCTIVE = new Set([
|
|
|
229
264
|
"forge_undo",
|
|
230
265
|
"forge_bulk_create",
|
|
231
266
|
"forge_import",
|
|
267
|
+
"forge_cloud_restore",
|
|
268
|
+
"forge_team_share",
|
|
232
269
|
]);
|
|
233
270
|
|
|
234
271
|
export function bridgeTools(bridgeServer) {
|
package/src/tui/tui.js
CHANGED
|
@@ -312,6 +312,12 @@ export class TUI {
|
|
|
312
312
|
);
|
|
313
313
|
}
|
|
314
314
|
}
|
|
315
|
+
if (s.plugins && s.plugins.length) {
|
|
316
|
+
lines.push(`${green("●")} plugins: ${s.plugins.map((p) => `${p.name}@${p.version}`).join(", ")} (declarative JSON, no code)`);
|
|
317
|
+
}
|
|
318
|
+
if (s.pluginErrors && s.pluginErrors.length) {
|
|
319
|
+
lines.push(`${red("✕")} rejected plugins: ${s.pluginErrors.map((e) => `${e.file}: ${e.error}`).join("; ")}`);
|
|
320
|
+
}
|
|
315
321
|
return lines.join("\n");
|
|
316
322
|
}
|
|
317
323
|
|