Просмотр исходного кода

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 месяц назад
Родитель
Сommit
8aaae6a245

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

@@ -431,12 +431,62 @@ Load:
 requirelib("http");
 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
 ```javascript
 var body = http.get("https://example.com");
 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
 ```javascript
 var body = http.post("https://example.com/api", JSON.stringify({a:1}));
 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 (
 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
 	//AGI Internal Error Standard
 	errExitcall = errors.New("errExit")
 	errExitcall = errors.New("errExit")

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

@@ -11,6 +11,8 @@ import (
 	"net/url"
 	"net/url"
 	"os"
 	"os"
 	"path/filepath"
 	"path/filepath"
+	"strings"
+	"time"
 
 
 	"github.com/robertkrimen/otto"
 	"github.com/robertkrimen/otto"
 	"imuslab.com/arozos/mod/agi/static"
 	"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
 	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
 	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
 	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() {
 func (g *Gateway) HTTPLibRegister() {
 	err := g.RegisterLib("http", g.injectHTTPFunctions)
 	err := g.RegisterLib("http", g.injectHTTPFunctions)
 	if err != nil {
 	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) {
 func (g *Gateway) injectHTTPFunctions(payload *static.AgiLibInjectionPayload) {
 	vm := payload.VM
 	vm := payload.VM
 	u := payload.User
 	u := payload.User
@@ -41,6 +178,26 @@ func (g *Gateway) injectHTTPFunctions(payload *static.AgiLibInjectionPayload) {
 	//scriptPath := payload.ScriptPath
 	//scriptPath := payload.ScriptPath
 	w := payload.Writer
 	w := payload.Writer
 	//r := payload.Request
 	//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 {
 	vm.Set("_http_get", func(call otto.FunctionCall) otto.Value {
 		//Get URL from function variable
 		//Get URL from function variable
 		url, err := call.Argument(0).ToString()
 		url, err := call.Argument(0).ToString()
@@ -276,11 +433,53 @@ func (g *Gateway) injectHTTPFunctions(payload *static.AgiLibInjectionPayload) {
 		return otto.TrueValue()
 		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(`
 	vm.Run(`
 		var http = {};
 		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.head = _http_head;
 		http.download = _http_download;
 		http.download = _http_download;
 		http.getb64 = _http_getb64;
 		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.
 	//key field was left untouched. When received, the stored key is kept.
 	llmKeyMask = "********"
 	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
 // 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.",
    "desc": "Outbound HTTP request helpers.",
    "load": "requirelib(\"http\");",
    "load": "requirelib(\"http\");",
    "functions": [
    "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",
      "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",
      "ret": "string",
      "example": "requirelib(\"http\");\nvar body = http.get(\"https://example.com/api\");"
      "example": "requirelib(\"http\");\nvar body = http.get(\"https://example.com/api\");"
     },
     },
     {
     {
      "name": "http.post",
      "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",
      "ret": "string",
      "example": "requirelib(\"http\");\nvar resp = http.post(\"https://example.com/api\", JSON.stringify({a:1}));"
      "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",
      "name": "http.head",
      "sig": "http.head(url, headerKey)",
      "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
+}));