mirror of
https://gh.wpcy.net/https://github.com/discourse/discourse.git
synced 2026-05-28 01:19:30 +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
136 lines
4.6 KiB
Ruby
Vendored
136 lines
4.6 KiB
Ruby
Vendored
# frozen_string_literal: true
|
|
|
|
require "rails_helper"
|
|
|
|
RSpec.describe DiscourseAi::Agents::ToolRunner do
|
|
fab!(:user) { Fabricate(:user, refresh_auto_groups: true) }
|
|
fab!(:bot_user) { Fabricate(:user, admin: true, refresh_auto_groups: true) }
|
|
fab!(:tool) do
|
|
AiTool.create!(
|
|
name: "test_tool",
|
|
tool_name: "test_tool",
|
|
description: "a test tool",
|
|
script: "function invoke(params) { return { result: 'ok' }; }",
|
|
summary: "test",
|
|
created_by: user,
|
|
)
|
|
end
|
|
fab!(:llm_model)
|
|
fab!(:ai_secret)
|
|
let(:llm) { DiscourseAi::Completions::Llm.proxy(llm_model.id) }
|
|
|
|
before { enable_current_plugin }
|
|
|
|
describe "#invoke" do
|
|
it "can execute a simple script" do
|
|
runner = described_class.new(parameters: {}, llm: llm, bot_user: bot_user, tool: tool)
|
|
result = runner.invoke
|
|
expect(result).to eq({ "result" => "ok" })
|
|
end
|
|
|
|
it "exposes discourse.baseUrl" do
|
|
tool.update!(script: "function invoke() { return { baseUrl: discourse.baseUrl }; }")
|
|
runner = described_class.new(parameters: {}, llm: llm, bot_user: bot_user, tool: tool)
|
|
result = runner.invoke
|
|
expect(result["baseUrl"]).to eq(Discourse.base_url)
|
|
end
|
|
|
|
it "allows scripts to resolve configured secret aliases" do
|
|
tool.update!(
|
|
secret_contracts: [{ alias: "external_api_key" }],
|
|
script: "function invoke() { return { key: secrets.get('external_api_key') }; }",
|
|
)
|
|
AiToolSecretBinding.create!(
|
|
ai_tool: tool,
|
|
alias: "external_api_key",
|
|
ai_secret_id: ai_secret.id,
|
|
)
|
|
|
|
runner = described_class.new(parameters: {}, llm: llm, bot_user: bot_user, tool: tool)
|
|
result = runner.invoke
|
|
|
|
expect(result["key"]).to eq(ai_secret.secret)
|
|
end
|
|
|
|
it "raises when secret alias is not bound" do
|
|
tool.update!(
|
|
secret_contracts: [{ alias: "external_api_key" }],
|
|
script: "function invoke() { return secrets.get('external_api_key'); }",
|
|
)
|
|
|
|
runner = described_class.new(parameters: {}, llm: llm, bot_user: bot_user, tool: tool)
|
|
|
|
expect { runner.invoke }.to raise_error(
|
|
Discourse::InvalidParameters,
|
|
/Missing required credential bindings/,
|
|
)
|
|
end
|
|
|
|
it "resolves secrets from in-flight secret_bindings override" do
|
|
tool.update!(
|
|
secret_contracts: [{ alias: "external_api_key" }],
|
|
script: "function invoke() { return { key: secrets.get('external_api_key') }; }",
|
|
)
|
|
|
|
bindings = [{ "alias" => "external_api_key", "ai_secret_id" => ai_secret.id }]
|
|
|
|
runner =
|
|
described_class.new(
|
|
parameters: {
|
|
},
|
|
llm: llm,
|
|
bot_user: bot_user,
|
|
tool: tool,
|
|
secret_bindings: bindings,
|
|
)
|
|
result = runner.invoke
|
|
|
|
expect(result["key"]).to eq(ai_secret.secret)
|
|
end
|
|
end
|
|
|
|
describe "#has_custom_system_message?" do
|
|
it "returns true when script defines customSystemMessage function" do
|
|
tool.update!(script: <<~JS)
|
|
function invoke(params) { return {}; }
|
|
function customSystemMessage() { return "extra system instructions"; }
|
|
JS
|
|
runner = described_class.new(parameters: {}, llm: llm, bot_user: bot_user, tool: tool)
|
|
expect(runner.has_custom_system_message?).to eq(true)
|
|
end
|
|
|
|
it "returns false when script does not define customSystemMessage" do
|
|
runner = described_class.new(parameters: {}, llm: llm, bot_user: bot_user, tool: tool)
|
|
expect(runner.has_custom_system_message?).to eq(false)
|
|
end
|
|
end
|
|
|
|
describe "#custom_system_message" do
|
|
it "returns the string from customSystemMessage()" do
|
|
tool.update!(script: <<~JS)
|
|
function invoke(params) { return {}; }
|
|
function customSystemMessage() { return "You are a coding assistant"; }
|
|
JS
|
|
runner = described_class.new(parameters: {}, llm: llm, bot_user: bot_user, tool: tool)
|
|
expect(runner.custom_system_message).to eq("You are a coding assistant")
|
|
end
|
|
|
|
it "returns nil when customSystemMessage returns null" do
|
|
tool.update!(script: <<~JS)
|
|
function invoke(params) { return {}; }
|
|
function customSystemMessage() { return null; }
|
|
JS
|
|
runner = described_class.new(parameters: {}, llm: llm, bot_user: bot_user, tool: tool)
|
|
expect(runner.custom_system_message).to be_nil
|
|
end
|
|
|
|
it "returns nil when script errors" do
|
|
tool.update!(script: <<~JS)
|
|
function invoke(params) { return {}; }
|
|
function customSystemMessage() { throw new Error("oops"); }
|
|
JS
|
|
runner = described_class.new(parameters: {}, llm: llm, bot_user: bot_user, tool: tool)
|
|
expect(runner.custom_system_message).to be_nil
|
|
end
|
|
end
|
|
end
|