mirror of
https://gh.wpcy.net/https://github.com/discourse/discourse.git
synced 2026-06-19 07:43:46 +08:00
Adds a `crypto` API to the JavaScript tool runner exposing HMAC
(SHA1/SHA256), hashing (MD5/SHA1/SHA256), RSA PKCS1v15 signing, base64
and base64url encoding, and cryptographically secure random bytes. All
functions are synchronous bridges to Ruby's OpenSSL with string or
Uint8Array inputs and a 10MB per-call limit, enabling webhook signature
verification, JWT signing for service accounts, and similar use cases.
Also splits the monolithic `tool_runner.rb` into per-feature modules
(`http`, `llm`, `index`, `upload`, `discourse`, `crypto`) with matching
spec files, and documents the discourse API's security model in the
tool preamble so authors understand the privilege boundaries when
non-admins trigger their tools.
New interface allows for stuff like this in a tool:
```
function signJWT(cred) {
const now = Math.floor(Date.now() / 1000);
const hdr = crypto.base64UrlEncode(JSON.stringify({ alg: "RS256", typ: "JWT" }));
const pay = crypto.base64UrlEncode(JSON.stringify({
iss: cred.client_email,
scope: "https://www.googleapis.com/auth/bigquery.readonly",
aud: "https://oauth2.googleapis.com/token",
iat: now,
exp: now + 3600,
}));
const msg = hdr + "." + pay;
return msg + "." + crypto.base64UrlEncode(crypto.signRsaSha256(cred.private_key, msg));
}
function getToken(cred) {
const jwt = signJWT(cred);
const r = http.post("https://oauth2.googleapis.com/token", {
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body:
"grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Ajwt-bearer&assertion=" + jwt,
});
if (r.status !== 200) throw new Error("OAuth2 failed (" + r.status + "): " + r.body);
return JSON.parse(r.body).access_token;
}
function invoke(params) {
const cred = JSON.parse(secrets.get("json_cred"));
const token = getToken(cred);
const project = cred.project_id;
const url =
"https://bigquery.googleapis.com/bigquery/v2/projects/" +
encodeURIComponent(project) +
"/datasets";
const r = http.get(url, { headers: { Authorization: "Bearer " + token } });
if (r.status !== 200) throw new Error("BigQuery (" + r.status + "): " + r.body);
const body = JSON.parse(r.body);
const datasets = (body.datasets || []).map(function (ds) {
return ds.datasetReference.datasetId;
});
return { project: project, datasets: datasets };
}
function details() {
return "Lists all BigQuery datasets in the project";
}
```
Previously to achieve the same thing we would need to implement crypto
direct in JS
221 lines
6 KiB
Ruby
Vendored
221 lines
6 KiB
Ruby
Vendored
# frozen_string_literal: true
|
|
|
|
require "rails_helper"
|
|
|
|
RSpec.describe DiscourseAi::Agents::ToolRunner do
|
|
fab!(:llm_model) { Fabricate(:llm_model, name: "claude-2") }
|
|
let(:llm) { DiscourseAi::Completions::Llm.proxy(llm_model) }
|
|
fab!(:bot_user) { Discourse.system_user }
|
|
|
|
def create_tool(script:)
|
|
AiTool.create!(
|
|
name: "test #{SecureRandom.uuid}",
|
|
tool_name: "test_#{SecureRandom.uuid.underscore}",
|
|
description: "test",
|
|
parameters: [{ name: "query", type: "string", description: "perform a search" }],
|
|
script: script,
|
|
created_by_id: 1,
|
|
summary: "Test tool summary",
|
|
)
|
|
end
|
|
|
|
before { enable_current_plugin }
|
|
|
|
describe "HTTP operations" do
|
|
it "can base64 encode binary HTTP responses" do
|
|
binary_data = (0..255).map(&:chr).join
|
|
expected_base64 = Base64.strict_encode64(binary_data)
|
|
|
|
script = <<~JS
|
|
function invoke(params) {
|
|
const result = http.post("https://example.com/binary", {
|
|
body: "test",
|
|
base64Encode: true
|
|
});
|
|
return result.body;
|
|
}
|
|
JS
|
|
|
|
tool = create_tool(script: script)
|
|
runner = tool.runner({}, llm: nil, bot_user: nil)
|
|
|
|
stub_request(:post, "https://example.com/binary").to_return(
|
|
status: 200,
|
|
body: binary_data,
|
|
headers: {
|
|
},
|
|
)
|
|
|
|
result = runner.invoke
|
|
|
|
expect(result).to eq(expected_base64)
|
|
expect(Base64.strict_decode64(result).bytes).to eq((0..255).to_a)
|
|
end
|
|
|
|
it "can base64 encode binary GET responses" do
|
|
binary_data = (0..255).map(&:chr).join
|
|
expected_base64 = Base64.strict_encode64(binary_data)
|
|
|
|
script = <<~JS
|
|
function invoke(params) {
|
|
const result = http.get("https://example.com/binary", {
|
|
base64Encode: true
|
|
});
|
|
return result.body;
|
|
}
|
|
JS
|
|
|
|
tool = create_tool(script: script)
|
|
runner = tool.runner({}, llm: nil, bot_user: nil)
|
|
|
|
stub_request(:get, "https://example.com/binary").to_return(
|
|
status: 200,
|
|
body: binary_data,
|
|
headers: {
|
|
},
|
|
)
|
|
|
|
result = runner.invoke
|
|
|
|
expect(result).to eq(expected_base64)
|
|
expect(Base64.strict_decode64(result).bytes).to eq((0..255).to_a)
|
|
end
|
|
|
|
it "can perform HTTP requests with various verbs" do
|
|
%i[post put delete patch].each do |verb|
|
|
script = <<~JS
|
|
function invoke(params) {
|
|
result = http.#{verb}("https://example.com/api",
|
|
{
|
|
headers: { TestHeader: "TestValue" },
|
|
body: JSON.stringify({ data: params.data })
|
|
}
|
|
);
|
|
|
|
return result.body;
|
|
}
|
|
JS
|
|
|
|
tool = create_tool(script: script)
|
|
runner = tool.runner({ "data" => "test data" }, llm: nil, bot_user: nil)
|
|
|
|
stub_request(verb, "https://example.com/api").with(
|
|
body: "{\"data\":\"test data\"}",
|
|
headers: {
|
|
"Accept" => "*/*",
|
|
"Testheader" => "TestValue",
|
|
"User-Agent" => "Discourse AI Bot 1.0 (https://www.discourse.org)",
|
|
},
|
|
).to_return(status: 200, body: "Success", headers: {})
|
|
|
|
result = runner.invoke
|
|
|
|
expect(result).to eq("Success")
|
|
end
|
|
end
|
|
|
|
it "can perform GET HTTP requests, with 1 param" do
|
|
script = <<~JS
|
|
function invoke(params) {
|
|
result = http.get("https://example.com/" + params.query);
|
|
return result.body;
|
|
}
|
|
JS
|
|
|
|
tool = create_tool(script: script)
|
|
runner = tool.runner({ "query" => "test" }, llm: nil, bot_user: nil)
|
|
|
|
stub_request(:get, "https://example.com/test").with(
|
|
headers: {
|
|
"Accept" => "*/*",
|
|
"User-Agent" => "Discourse AI Bot 1.0 (https://www.discourse.org)",
|
|
},
|
|
).to_return(status: 200, body: "Hello World", headers: {})
|
|
|
|
result = runner.invoke
|
|
|
|
expect(result).to eq("Hello World")
|
|
end
|
|
|
|
it "is limited to MAX http requests" do
|
|
script = <<~JS
|
|
function invoke(params) {
|
|
let i = 0;
|
|
while (i < 21) {
|
|
http.get("https://example.com/");
|
|
i += 1;
|
|
}
|
|
return "will not happen";
|
|
}
|
|
JS
|
|
|
|
tool = create_tool(script: script)
|
|
runner = tool.runner({}, llm: nil, bot_user: nil)
|
|
|
|
stub_request(:get, "https://example.com/").to_return(
|
|
status: 200,
|
|
body: "Hello World",
|
|
headers: {
|
|
},
|
|
)
|
|
|
|
expect { runner.invoke }.to raise_error(DiscourseAi::Agents::ToolRunner::TooManyRequestsError)
|
|
end
|
|
|
|
it "can perform GET HTTP requests" do
|
|
script = <<~JS
|
|
function invoke(params) {
|
|
result = http.get("https://example.com/" + params.query,
|
|
{ headers: { TestHeader: "TestValue" } }
|
|
);
|
|
|
|
return result.body;
|
|
}
|
|
JS
|
|
|
|
tool = create_tool(script: script)
|
|
runner = tool.runner({ "query" => "test" }, llm: nil, bot_user: nil)
|
|
|
|
stub_request(:get, "https://example.com/test").with(
|
|
headers: {
|
|
"Accept" => "*/*",
|
|
"Testheader" => "TestValue",
|
|
"User-Agent" => "Discourse AI Bot 1.0 (https://www.discourse.org)",
|
|
},
|
|
).to_return(status: 200, body: "Hello World", headers: {})
|
|
|
|
result = runner.invoke
|
|
|
|
expect(result).to eq("Hello World")
|
|
end
|
|
|
|
it "will not timeout on slow HTTP reqs" do
|
|
script = <<~JS
|
|
function invoke(params) {
|
|
result = http.get("https://example.com/" + params.query,
|
|
{ headers: { TestHeader: "TestValue" } }
|
|
);
|
|
|
|
return result.body;
|
|
}
|
|
JS
|
|
|
|
tool = create_tool(script: script)
|
|
runner = tool.runner({ "query" => "test" }, llm: nil, bot_user: nil)
|
|
|
|
stub_request(:get, "https://example.com/test").to_return do
|
|
sleep 0.01
|
|
{ status: 200, body: "Hello World", headers: {} }
|
|
end
|
|
|
|
tool = create_tool(script: script)
|
|
runner = tool.runner({ "query" => "test" }, llm: nil, bot_user: nil)
|
|
|
|
runner.timeout = 10
|
|
|
|
result = runner.invoke
|
|
|
|
expect(result).to eq("Hello World")
|
|
end
|
|
end
|
|
end
|