Browse Source

Add curl-like http.request() with full control over method, headers, and body (#280)

* AGI: curl-like http.request + 60min LLM timeout

http library:
- Add http.request(options) supporting method selection, custom headers,
  raw/JSON/form/binary(base64) request bodies, HTTP basic auth, per-request
  timeout, redirect-follow control and text/base64 response bodies. Returns a
  {ok, status, statusText, headers, body, error} response object.
- Add http.put/patch/delete/postForm/postJSON helpers built on http.request.
- http.get and http.post now accept optional headers (backward compatible:
  the classic no-header signatures behave exactly as before).
- Add agi.http_test.go covering the body builder, request execution
  (methods, headers, form/binary bodies, basic auth, redirect handling) and
  the JS bindings.

llm library:
- Raise the AI completion request timeout from 120s to 60 minutes so long
  reasoning/agentic replies are not cut short.

Docs: update mod/agi/README.md and Terminal api.json; bump AgiVersion to 3.5.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N7iat5zMwn2N5xDVEAvgpN

* UnitTest: add http.request example backend script

Add src/web/UnitTest/backend/http.request.js demonstrating the new
curl-like http.request(options) call (method, custom headers, JSON body,
timeout, response object) plus the http.postForm helper, matching the
existing http.get/post/head/download unit-test examples.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N7iat5zMwn2N5xDVEAvgpN

---------

Co-authored-by: Claude <noreply@anthropic.com>
Alan Yeung 1 month ago
parent
commit
8aaae6a245

+ 52 - 2
src/mod/agi/README.md

@@ -431,12 +431,62 @@ Load:
 requirelib("http");
 ```
 
-### `http.get(url)`
+### `http.request(options)`
+Curl-like request giving full control over the method, headers and body. Returns
+a response object `{ok, status, statusText, headers, body, error}` (`error` is a
+non-empty string when the request could not be completed; `body` is base64 when
+`responseType` is `"base64"`).
+
+Supported `options`:
+
+| Field | Description |
+|-------|-------------|
+| `url` | Target URL (required) |
+| `method` | HTTP method, default `GET` (case-insensitive) |
+| `headers` | Object of request headers to set, e.g. `{"Authorization":"Bearer x"}` |
+| `body` | Raw text request body |
+| `json` | Object sent as a JSON body (sets `Content-Type: application/json`) |
+| `form` | Object sent as `application/x-www-form-urlencoded` |
+| `bodyBase64` | Binary request body, base64 encoded (sets `application/octet-stream`) |
+| `contentType` | Override the `Content-Type` header |
+| `username` / `password` | HTTP basic auth credentials |
+| `timeout` | Timeout in seconds (`0` / omitted = no timeout) |
+| `followRedirect` | Follow 3xx redirects (default `true`) |
+| `responseType` | `"text"` (default) or `"base64"` for binary responses |
+
+Body precedence when several are supplied: `bodyBase64` > `form` > `json` > `body`.
+
+```javascript
+var resp = http.request({
+    url: "https://example.com/api",
+    method: "POST",
+    headers: {"Authorization": "Bearer TOKEN"},
+    json: {a: 1, b: 2}
+});
+if (resp.ok){
+    console.log(resp.status, resp.body);
+}
+```
+
+Convenience helpers built on `http.request` (all return the response object):
+`http.put(url, body, headers, contentType)`,
+`http.patch(url, body, headers, contentType)`,
+`http.delete(url, headers)`,
+`http.postForm(url, formObject, headers)` and
+`http.postJSON(url, object, headers)`.
+
+### `http.get(url, headers)`
+`headers` is optional. Without it, returns the body string (or `null` on error)
+for backward compatibility; with it, returns the response body string.
 ```javascript
 var body = http.get("https://example.com");
+var body2 = http.get("https://example.com", {"Authorization": "Bearer x"});
 ```
 
-### `http.post(url, jsonString)`
+### `http.post(url, body, headers, contentType)`
+`headers` and `contentType` are optional. Without them the body is sent as JSON
+(backward compatible); with them the given headers / content type are used.
+Returns the response body string.
 ```javascript
 var body = http.post("https://example.com/api", JSON.stringify({a:1}));
 ```

+ 1 - 1
src/mod/agi/agi.go

@@ -39,7 +39,7 @@ import (
 */
 
 var (
-	AgiVersion string = "3.4" //Defination of the agi runtime version. Update this when new function is added
+	AgiVersion string = "3.5" //Defination of the agi runtime version. Update this when new function is added
 
 	//AGI Internal Error Standard
 	errExitcall = errors.New("errExit")

+ 202 - 3
src/mod/agi/agi.http.go

@@ -11,6 +11,8 @@ import (
 	"net/url"
 	"os"
 	"path/filepath"
+	"strings"
+	"time"
 
 	"github.com/robertkrimen/otto"
 	"imuslab.com/arozos/mod/agi/static"
@@ -23,9 +25,45 @@ import (
 	This is a library for allowing AGI script to make HTTP Request from the VM
 	Returning either the head or the body of the request
 
+	In addition to the classic helpers (get / post / head / download / getb64 /
+	getCode / redirect), the library exposes a curl-like http.request(options)
+	function that lets a script pick the method, set arbitrary request headers,
+	send a raw / JSON / url-encoded form / binary (base64) body, use HTTP basic
+	auth, set a timeout and control redirect following, then read back the
+	status code, response headers and body of the reply.
+
 	Author: tobychui
 */
 
+// httpRequestOptions mirrors the JS options object passed to http.request. It
+// covers the most common parameters curl supports (method, headers, request
+// body in several shapes, basic auth, timeout and redirect handling).
+type httpRequestOptions struct {
+	URL            string            `json:"url"`            //Target URL (required)
+	Method         string            `json:"method"`         //HTTP method, default GET
+	Headers        map[string]string `json:"headers"`        //Request headers to set
+	Body           string            `json:"body"`           //Raw text request body
+	BodyBase64     string            `json:"bodyBase64"`     //Binary request body, base64 encoded
+	Form           map[string]string `json:"form"`           //application/x-www-form-urlencoded body
+	JSON           json.RawMessage   `json:"json"`           //JSON request body (sets Content-Type)
+	ContentType    string            `json:"contentType"`    //Override the Content-Type header
+	Username       string            `json:"username"`       //HTTP basic auth username
+	Password       string            `json:"password"`       //HTTP basic auth password
+	Timeout        float64           `json:"timeout"`        //Timeout in seconds (0 = no timeout)
+	FollowRedirect *bool             `json:"followRedirect"` //Follow 3xx redirects (default true)
+	ResponseType   string            `json:"responseType"`   //"text" (default) or "base64" for binary
+}
+
+// httpResponse is the object returned by http.request describing the reply.
+type httpResponse struct {
+	Ok         bool                `json:"ok"`         //True when the status code is in the 2xx range
+	Status     int                 `json:"status"`     //HTTP status code
+	StatusText string              `json:"statusText"` //HTTP status line text
+	Headers    map[string][]string `json:"headers"`    //Response headers
+	Body       string              `json:"body"`       //Response body (text, or base64 when responseType is "base64")
+	Error      string              `json:"error"`      //Non-empty when the request could not be completed
+}
+
 func (g *Gateway) HTTPLibRegister() {
 	err := g.RegisterLib("http", g.injectHTTPFunctions)
 	if err != nil {
@@ -34,6 +72,105 @@ func (g *Gateway) HTTPLibRegister() {
 	}
 }
 
+// buildHTTPRequestBody resolves the request body and default Content-Type from
+// the given options. Body precedence is: bodyBase64 > form > json > body.
+func buildHTTPRequestBody(opt httpRequestOptions) (io.Reader, string, error) {
+	switch {
+	case opt.BodyBase64 != "":
+		raw, err := base64.StdEncoding.DecodeString(opt.BodyBase64)
+		if err != nil {
+			return nil, "", errors.New("invalid bodyBase64: " + err.Error())
+		}
+		return bytes.NewReader(raw), "application/octet-stream", nil
+	case len(opt.Form) > 0:
+		values := url.Values{}
+		for k, v := range opt.Form {
+			values.Set(k, v)
+		}
+		return strings.NewReader(values.Encode()), "application/x-www-form-urlencoded", nil
+	case len(opt.JSON) > 0:
+		return bytes.NewReader(opt.JSON), "application/json", nil
+	case opt.Body != "":
+		return strings.NewReader(opt.Body), "", nil
+	default:
+		return nil, "", nil
+	}
+}
+
+// doHTTPRequest builds and executes an HTTP request from the given options and
+// returns a populated httpResponse. Any transport-level failure is reported via
+// the Error field rather than as a Go error so scripts always get an object.
+func doHTTPRequest(opt httpRequestOptions) httpResponse {
+	if strings.TrimSpace(opt.URL) == "" {
+		return httpResponse{Error: "missing request url"}
+	}
+
+	method := strings.ToUpper(strings.TrimSpace(opt.Method))
+	if method == "" {
+		method = "GET"
+	}
+
+	body, defaultContentType, err := buildHTTPRequestBody(opt)
+	if err != nil {
+		return httpResponse{Error: err.Error()}
+	}
+
+	req, err := http.NewRequest(method, opt.URL, body)
+	if err != nil {
+		return httpResponse{Error: err.Error()}
+	}
+
+	//Apply a default Content-Type for the chosen body, then let explicit
+	//headers / the contentType option override it.
+	if defaultContentType != "" {
+		req.Header.Set("Content-Type", defaultContentType)
+	}
+	req.Header.Set("User-Agent", "arozos-http-client/1.1")
+	for k, v := range opt.Headers {
+		req.Header.Set(k, v)
+	}
+	if strings.TrimSpace(opt.ContentType) != "" {
+		req.Header.Set("Content-Type", opt.ContentType)
+	}
+	if opt.Username != "" || opt.Password != "" {
+		req.SetBasicAuth(opt.Username, opt.Password)
+	}
+
+	client := &http.Client{}
+	if opt.Timeout > 0 {
+		client.Timeout = time.Duration(opt.Timeout * float64(time.Second))
+	}
+	if opt.FollowRedirect != nil && !*opt.FollowRedirect {
+		client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
+			return http.ErrUseLastResponse
+		}
+	}
+
+	resp, err := client.Do(req)
+	if err != nil {
+		return httpResponse{Error: err.Error()}
+	}
+	defer resp.Body.Close()
+
+	bodyContent, err := io.ReadAll(resp.Body)
+	if err != nil {
+		return httpResponse{Error: err.Error()}
+	}
+
+	bodyString := string(bodyContent)
+	if strings.ToLower(strings.TrimSpace(opt.ResponseType)) == "base64" {
+		bodyString = base64.StdEncoding.EncodeToString(bodyContent)
+	}
+
+	return httpResponse{
+		Ok:         resp.StatusCode >= 200 && resp.StatusCode < 300,
+		Status:     resp.StatusCode,
+		StatusText: resp.Status,
+		Headers:    resp.Header,
+		Body:       bodyString,
+	}
+}
+
 func (g *Gateway) injectHTTPFunctions(payload *static.AgiLibInjectionPayload) {
 	vm := payload.VM
 	u := payload.User
@@ -41,6 +178,26 @@ func (g *Gateway) injectHTTPFunctions(payload *static.AgiLibInjectionPayload) {
 	//scriptPath := payload.ScriptPath
 	w := payload.Writer
 	//r := payload.Request
+
+	//_http_request(optionsJSON) => response object as JSON string. This is the
+	//curl-like entry point backing http.request and all method helpers.
+	vm.Set("_http_request", func(call otto.FunctionCall) otto.Value {
+		opt := httpRequestOptions{}
+		optJSON := getOttoStringArg(call, 0)
+		if s := strings.TrimSpace(optJSON); s != "" && s != "undefined" && s != "null" {
+			if err := json.Unmarshal([]byte(optJSON), &opt); err != nil {
+				out, _ := json.Marshal(httpResponse{Error: "invalid request options: " + err.Error()})
+				rv, _ := vm.ToValue(string(out))
+				return rv
+			}
+		}
+
+		resp := doHTTPRequest(opt)
+		out, _ := json.Marshal(resp)
+		rv, _ := vm.ToValue(string(out))
+		return rv
+	})
+
 	vm.Set("_http_get", func(call otto.FunctionCall) otto.Value {
 		//Get URL from function variable
 		url, err := call.Argument(0).ToString()
@@ -276,11 +433,53 @@ func (g *Gateway) injectHTTPFunctions(payload *static.AgiLibInjectionPayload) {
 		return otto.TrueValue()
 	})
 
-	//Wrap all the native code function into an imagelib class
+	//Wrap all the native code function into an http class
 	vm.Run(`
 		var http = {};
-		http.get = _http_get;
-		http.post = _http_post;
+
+		//http.request(options) => response object {ok, status, statusText, headers, body, error}
+		//options: {url, method, headers, body, bodyBase64, form, json, contentType,
+		//          username, password, timeout, followRedirect, responseType}
+		http.request = function(options){
+			return JSON.parse(_http_request(JSON.stringify(options || {})));
+		};
+
+		//Classic helpers. http.get / http.post now accept optional headers.
+		http.get = function(url, headers){
+			if (typeof headers == "undefined"){
+				//Backward-compatible fast path: returns body string (or null on error)
+				return _http_get(url);
+			}
+			return http.request({url: url, method: "GET", headers: headers}).body;
+		};
+		http.post = function(url, body, headers, contentType){
+			if (typeof headers == "undefined" && typeof contentType == "undefined"){
+				//Backward-compatible fast path: JSON body, returns body string
+				return _http_post(url, body);
+			}
+			return http.request({
+				url: url, method: "POST", body: body,
+				headers: headers, contentType: contentType
+			}).body;
+		};
+
+		//Method + body-shape convenience helpers built on http.request.
+		http.put = function(url, body, headers, contentType){
+			return http.request({url: url, method: "PUT", body: body, headers: headers, contentType: contentType});
+		};
+		http.patch = function(url, body, headers, contentType){
+			return http.request({url: url, method: "PATCH", body: body, headers: headers, contentType: contentType});
+		};
+		http.delete = function(url, headers){
+			return http.request({url: url, method: "DELETE", headers: headers});
+		};
+		http.postForm = function(url, form, headers){
+			return http.request({url: url, method: "POST", form: form, headers: headers});
+		};
+		http.postJSON = function(url, obj, headers){
+			return http.request({url: url, method: "POST", json: obj, headers: headers});
+		};
+
 		http.head = _http_head;
 		http.download = _http_download;
 		http.getb64 = _http_getb64;

+ 291 - 0
src/mod/agi/agi.http_test.go

@@ -0,0 +1,291 @@
+package agi
+
+import (
+	"encoding/base64"
+	"encoding/json"
+	"io"
+	"net/http"
+	"net/http/httptest"
+	"net/url"
+	"strings"
+	"testing"
+
+	"github.com/robertkrimen/otto"
+	"imuslab.com/arozos/mod/agi/static"
+	user "imuslab.com/arozos/mod/user"
+)
+
+// ─── body builder ────────────────────────────────────────────────────────────
+
+func TestBuildHTTPRequestBody(t *testing.T) {
+	//bodyBase64 takes precedence and decodes to raw bytes
+	t.Run("base64", func(t *testing.T) {
+		raw := []byte{0x00, 0x01, 0x02, 0xff}
+		opt := httpRequestOptions{BodyBase64: base64.StdEncoding.EncodeToString(raw)}
+		r, ct, err := buildHTTPRequestBody(opt)
+		if err != nil {
+			t.Fatalf("unexpected error: %v", err)
+		}
+		if ct != "application/octet-stream" {
+			t.Errorf("unexpected content-type: %q", ct)
+		}
+		got, _ := io.ReadAll(r)
+		if string(got) != string(raw) {
+			t.Errorf("binary body not decoded correctly: %v", got)
+		}
+	})
+
+	t.Run("invalid base64", func(t *testing.T) {
+		_, _, err := buildHTTPRequestBody(httpRequestOptions{BodyBase64: "!!!not base64!!!"})
+		if err == nil {
+			t.Error("expected error for invalid base64 body")
+		}
+	})
+
+	t.Run("form", func(t *testing.T) {
+		opt := httpRequestOptions{Form: map[string]string{"a": "1", "b": "hello world"}}
+		r, ct, err := buildHTTPRequestBody(opt)
+		if err != nil {
+			t.Fatalf("unexpected error: %v", err)
+		}
+		if ct != "application/x-www-form-urlencoded" {
+			t.Errorf("unexpected content-type: %q", ct)
+		}
+		got, _ := io.ReadAll(r)
+		values, _ := url.ParseQuery(string(got))
+		if values.Get("a") != "1" || values.Get("b") != "hello world" {
+			t.Errorf("form not encoded correctly: %q", string(got))
+		}
+	})
+
+	t.Run("json", func(t *testing.T) {
+		opt := httpRequestOptions{JSON: json.RawMessage(`{"x":1}`)}
+		r, ct, err := buildHTTPRequestBody(opt)
+		if err != nil {
+			t.Fatalf("unexpected error: %v", err)
+		}
+		if ct != "application/json" {
+			t.Errorf("unexpected content-type: %q", ct)
+		}
+		got, _ := io.ReadAll(r)
+		if strings.TrimSpace(string(got)) != `{"x":1}` {
+			t.Errorf("json body mismatch: %q", string(got))
+		}
+	})
+
+	t.Run("raw", func(t *testing.T) {
+		opt := httpRequestOptions{Body: "plain text"}
+		r, ct, err := buildHTTPRequestBody(opt)
+		if err != nil {
+			t.Fatalf("unexpected error: %v", err)
+		}
+		if ct != "" {
+			t.Errorf("raw body should carry no default content-type, got %q", ct)
+		}
+		got, _ := io.ReadAll(r)
+		if string(got) != "plain text" {
+			t.Errorf("raw body mismatch: %q", string(got))
+		}
+	})
+
+	t.Run("empty", func(t *testing.T) {
+		r, ct, err := buildHTTPRequestBody(httpRequestOptions{})
+		if err != nil || r != nil || ct != "" {
+			t.Errorf("empty options should yield no body, got r=%v ct=%q err=%v", r, ct, err)
+		}
+	})
+}
+
+// ─── request execution ───────────────────────────────────────────────────────
+
+func TestDoHTTPRequestMethodHeadersAndBody(t *testing.T) {
+	var gotMethod, gotHeader, gotBody, gotContentType string
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		gotMethod = r.Method
+		gotHeader = r.Header.Get("X-Custom")
+		gotContentType = r.Header.Get("Content-Type")
+		b, _ := io.ReadAll(r.Body)
+		gotBody = string(b)
+		w.Header().Set("X-Reply", "pong")
+		w.WriteHeader(201)
+		io.WriteString(w, "created")
+	}))
+	defer srv.Close()
+
+	resp := doHTTPRequest(httpRequestOptions{
+		URL:     srv.URL,
+		Method:  "put",
+		Headers: map[string]string{"X-Custom": "yes"},
+		Body:    "the payload",
+	})
+
+	if resp.Error != "" {
+		t.Fatalf("unexpected error: %s", resp.Error)
+	}
+	if gotMethod != "PUT" {
+		t.Errorf("method not applied/uppercased, got %q", gotMethod)
+	}
+	if gotHeader != "yes" {
+		t.Errorf("custom header not sent, got %q", gotHeader)
+	}
+	if gotBody != "the payload" {
+		t.Errorf("body not sent, got %q", gotBody)
+	}
+	if gotContentType != "" {
+		t.Errorf("raw body should not set a content-type, got %q", gotContentType)
+	}
+	if resp.Status != 201 || !resp.Ok {
+		t.Errorf("status/ok mismatch: status=%d ok=%v", resp.Status, resp.Ok)
+	}
+	if resp.Body != "created" {
+		t.Errorf("response body mismatch: %q", resp.Body)
+	}
+	if len(resp.Headers["X-Reply"]) == 0 || resp.Headers["X-Reply"][0] != "pong" {
+		t.Errorf("response headers not captured: %v", resp.Headers)
+	}
+}
+
+func TestDoHTTPRequestFormAndContentTypeOverride(t *testing.T) {
+	var gotContentType, gotBody string
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		gotContentType = r.Header.Get("Content-Type")
+		b, _ := io.ReadAll(r.Body)
+		gotBody = string(b)
+		io.WriteString(w, "ok")
+	}))
+	defer srv.Close()
+
+	//Form body sets the urlencoded content type by default.
+	resp := doHTTPRequest(httpRequestOptions{URL: srv.URL, Method: "POST", Form: map[string]string{"k": "v"}})
+	if resp.Error != "" {
+		t.Fatalf("unexpected error: %s", resp.Error)
+	}
+	if gotContentType != "application/x-www-form-urlencoded" {
+		t.Errorf("form content-type not defaulted, got %q", gotContentType)
+	}
+	if values, _ := url.ParseQuery(gotBody); values.Get("k") != "v" {
+		t.Errorf("form body mismatch: %q", gotBody)
+	}
+
+	//Explicit contentType option overrides the default.
+	doHTTPRequest(httpRequestOptions{URL: srv.URL, Method: "POST", Body: "x", ContentType: "text/csv"})
+	if gotContentType != "text/csv" {
+		t.Errorf("contentType override not applied, got %q", gotContentType)
+	}
+}
+
+func TestDoHTTPRequestBinaryResponse(t *testing.T) {
+	raw := []byte{0x10, 0x20, 0x30, 0xff, 0x00}
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.Write(raw)
+	}))
+	defer srv.Close()
+
+	resp := doHTTPRequest(httpRequestOptions{URL: srv.URL, ResponseType: "base64"})
+	if resp.Error != "" {
+		t.Fatalf("unexpected error: %s", resp.Error)
+	}
+	decoded, err := base64.StdEncoding.DecodeString(resp.Body)
+	if err != nil {
+		t.Fatalf("response body was not valid base64: %v", err)
+	}
+	if string(decoded) != string(raw) {
+		t.Errorf("binary response mismatch: %v", decoded)
+	}
+}
+
+func TestDoHTTPRequestBasicAuth(t *testing.T) {
+	var gotUser, gotPass string
+	var ok bool
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		gotUser, gotPass, ok = r.BasicAuth()
+		io.WriteString(w, "ok")
+	}))
+	defer srv.Close()
+
+	doHTTPRequest(httpRequestOptions{URL: srv.URL, Username: "alice", Password: "s3cr3t"})
+	if !ok || gotUser != "alice" || gotPass != "s3cr3t" {
+		t.Errorf("basic auth not applied: user=%q pass=%q ok=%v", gotUser, gotPass, ok)
+	}
+}
+
+func TestDoHTTPRequestNoFollowRedirect(t *testing.T) {
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		if r.URL.Path == "/start" {
+			http.Redirect(w, r, "/dest", http.StatusFound)
+			return
+		}
+		io.WriteString(w, "final")
+	}))
+	defer srv.Close()
+
+	follow := false
+	resp := doHTTPRequest(httpRequestOptions{URL: srv.URL + "/start", FollowRedirect: &follow})
+	if resp.Error != "" {
+		t.Fatalf("unexpected error: %s", resp.Error)
+	}
+	if resp.Status != http.StatusFound {
+		t.Errorf("expected 302 when not following redirect, got %d", resp.Status)
+	}
+	if len(resp.Headers["Location"]) == 0 || !strings.HasSuffix(resp.Headers["Location"][0], "/dest") {
+		t.Errorf("expected Location header, got %v", resp.Headers)
+	}
+}
+
+func TestDoHTTPRequestMissingURL(t *testing.T) {
+	resp := doHTTPRequest(httpRequestOptions{})
+	if resp.Error == "" {
+		t.Error("expected an error when url is missing")
+	}
+}
+
+// ─── JS object exposure ─────────────────────────────────────────────────────
+
+func TestInjectHTTPLib_JSObjectExposed(t *testing.T) {
+	g := minimalGateway()
+	vm := otto.New()
+	payload := &static.AgiLibInjectionPayload{VM: vm, User: &user.User{Username: "alice"}}
+	g.injectHTTPFunctions(payload)
+
+	for _, method := range []string{"request", "get", "post", "put", "patch", "delete", "postForm", "postJSON", "head", "download", "getb64", "getCode", "redirect"} {
+		val, err := vm.Run(`typeof http.` + method)
+		if err != nil {
+			t.Fatalf("evaluating http.%s: %v", method, err)
+		}
+		s, _ := val.ToString()
+		if s != "function" {
+			t.Errorf("http.%s should be a function, got %q", method, s)
+		}
+	}
+}
+
+func TestHTTPRequestFromVM(t *testing.T) {
+	var gotMethod, gotBody string
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		gotMethod = r.Method
+		b, _ := io.ReadAll(r.Body)
+		gotBody = string(b)
+		io.WriteString(w, "hello vm")
+	}))
+	defer srv.Close()
+
+	g := minimalGateway()
+	vm := otto.New()
+	payload := &static.AgiLibInjectionPayload{VM: vm, User: &user.User{Username: "alice"}}
+	g.injectHTTPFunctions(payload)
+
+	val, err := vm.Run(`
+		var resp = http.request({url: "` + srv.URL + `", method: "POST", body: "vm-body"});
+		resp.status + "|" + resp.ok + "|" + resp.body;
+	`)
+	if err != nil {
+		t.Fatalf("vm run error: %v", err)
+	}
+	out, _ := val.ToString()
+	if out != "200|true|hello vm" {
+		t.Errorf("unexpected VM response object: %q", out)
+	}
+	if gotMethod != "POST" || gotBody != "vm-body" {
+		t.Errorf("request not sent as expected: method=%q body=%q", gotMethod, gotBody)
+	}
+}

+ 4 - 2
src/mod/agi/agi.llm.go

@@ -58,8 +58,10 @@ const (
 	//key field was left untouched. When received, the stored key is kept.
 	llmKeyMask = "********"
 
-	//llmRequestTimeout is the maximum time to wait for a completion.
-	llmRequestTimeout = 120 * time.Second
+	//llmRequestTimeout is the maximum time to wait for a completion. Large
+	//reasoning / agentic models can legitimately take many minutes to reply,
+	//so this is set generously to 60 minutes to avoid cutting long calls short.
+	llmRequestTimeout = 60 * time.Minute
 )
 
 // llmMetricsMux guards read-modify-write cycles on the metrics record so

+ 25 - 4
src/web/Terminal/docs/api.json

@@ -353,20 +353,41 @@
    "desc": "Outbound HTTP request helpers.",
    "load": "requirelib(\"http\");",
    "functions": [
+    {
+     "name": "http.request",
+     "sig": "http.request(options)",
+     "desc": "Curl-like request. options: {url, method, headers, body, json, form, bodyBase64, contentType, username, password, timeout, followRedirect, responseType}. Returns a response object {ok, status, statusText, headers, body, error}. Body precedence: bodyBase64 > form > json > body; responseType 'base64' returns a binary body base64 encoded.",
+     "ret": "object",
+     "example": "requirelib(\"http\");\nvar resp = http.request({url:\"https://example.com/api\", method:\"POST\", headers:{\"Authorization\":\"Bearer x\"}, json:{a:1}});\nif (resp.ok){ console.log(resp.status, resp.body); }"
+    },
     {
      "name": "http.get",
-     "sig": "http.get(url)",
-     "desc": "Perform an HTTP GET and return the response body as a string.",
+     "sig": "http.get(url, headers)",
+     "desc": "Perform an HTTP GET and return the response body as a string. headers is optional.",
      "ret": "string",
      "example": "requirelib(\"http\");\nvar body = http.get(\"https://example.com/api\");"
     },
     {
      "name": "http.post",
-     "sig": "http.post(url, jsonString)",
-     "desc": "Perform an HTTP POST with JSON body and return the response body.",
+     "sig": "http.post(url, body, headers, contentType)",
+     "desc": "Perform an HTTP POST and return the response body. Without headers/contentType the body is sent as JSON (backward compatible).",
      "ret": "string",
      "example": "requirelib(\"http\");\nvar resp = http.post(\"https://example.com/api\", JSON.stringify({a:1}));"
     },
+    {
+     "name": "http.put / http.patch / http.delete",
+     "sig": "http.put(url, body, headers, contentType)",
+     "desc": "Method helpers built on http.request; each returns the response object. http.delete(url, headers) sends no body.",
+     "ret": "object",
+     "example": "requirelib(\"http\");\nvar resp = http.put(\"https://example.com/api/1\", JSON.stringify({a:1}), {\"Content-Type\":\"application/json\"});"
+    },
+    {
+     "name": "http.postForm / http.postJSON",
+     "sig": "http.postForm(url, formObject, headers)",
+     "desc": "POST helpers built on http.request. postForm sends application/x-www-form-urlencoded; postJSON sends a JSON body. Both return the response object.",
+     "ret": "object",
+     "example": "requirelib(\"http\");\nvar resp = http.postForm(\"https://example.com/api\", {a:1, b:2});"
+    },
     {
      "name": "http.head",
      "sig": "http.head(url, headerKey)",

+ 43 - 0
src/web/UnitTest/backend/http.request.js

@@ -0,0 +1,43 @@
+/*
+    http.request Curl-like request with full control over method, headers and body.
+
+    options: {url, method, headers, body, json, form, bodyBase64, contentType,
+              username, password, timeout, followRedirect, responseType}
+    returns: {ok, status, statusText, headers, body, error}
+*/
+
+requirelib("http")
+
+//POST a JSON body with a custom header and read back the full response object
+var resp = http.request({
+    url: "http://localhost:8080/system/file_system/listDir",
+    method: "POST",
+    headers: {
+        "X-Requested-With": "arozos-unittest"
+    },
+    json: {
+        dir: "user:/Desktop",
+        sort: "default"
+    },
+    timeout: 30
+});
+
+//Convenience helpers built on http.request
+//  http.postForm(url, formObject, headers) => url-encoded form body
+//  http.postJSON(url, object, headers)     => JSON body
+//  http.put / http.patch / http.delete     => method helpers
+var formResp = http.postForm("http://localhost:8080/system/file_system/listDir", {
+    dir: "user:/Desktop",
+    sort: "default"
+});
+
+//Relay the result of both calls to the client
+sendJSONResp(JSON.stringify({
+    "request-ok": resp.ok,
+    "request-status": resp.status,
+    "request-statusText": resp.statusText,
+    "request-body": resp.body,
+    "request-error": resp.error,
+    "postForm-status": formResp.status,
+    "postForm-body": formResp.body
+}));