discourse/plugins/discourse-ai/lib/agents/tool_runner/http.rb
Sam ab51aa7d81
FEATURE: add crypto utilities to AI tool runner (#39343)
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
2026-04-21 14:25:18 +10:00

88 lines
2.8 KiB
Ruby
Vendored

# frozen_string_literal: true
module DiscourseAi
module Agents
class ToolRunner
module HTTP
def attach_http(mini_racer_context)
mini_racer_context.attach(
"_http_get",
->(url, options) do
begin
@http_requests_made += 1
if @http_requests_made > MAX_HTTP_REQUESTS
raise TooManyRequestsError.new("Tool made too many HTTP requests")
end
in_attached_function do
headers = (options && options["headers"]) || {}
base64_encode = options && options["base64Encode"]
result = {}
DiscourseAi::Agents::Tools::Tool.send_http_request(
url,
headers: headers,
) do |response|
if base64_encode
result[:body] = Base64.strict_encode64(response.body)
else
result[:body] = response.body
end
result[:status] = response.code.to_i
end
result
end
end
end,
)
%i[post put patch delete].each do |method|
mini_racer_context.attach(
"_http_#{method}",
->(url, options) do
begin
@http_requests_made += 1
if @http_requests_made > MAX_HTTP_REQUESTS
raise TooManyRequestsError.new("Tool made too many HTTP requests")
end
in_attached_function do
headers = (options && options["headers"]) || {}
body = options && options["body"]
base64_encode = options && options["base64Encode"]
result = {}
DiscourseAi::Agents::Tools::Tool.send_http_request(
url,
method: method,
headers: headers,
body: body,
) do |response|
if base64_encode
result[:body] = Base64.strict_encode64(response.body)
else
result[:body] = response.body
end
result[:status] = response.code.to_i
end
result
rescue => e
if Rails.env.development?
p url
p options
p e
puts e.backtrace
end
raise e
end
end
end,
)
end
end
end
end
end
end