From c77647952f91058dd8389522742c1ea4d4e87c4a Mon Sep 17 00:00:00 2001 From: qinxuye Date: Sat, 4 Jul 2026 20:55:31 +0800 Subject: [PATCH 1/7] feat(install): add get.xagent.co one-line installer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add scripts/install.sh — the installer served at https://get.xagent.co: curl -fsSL https://get.xagent.co | sh It installs the published `xagent-ai` package via `uv tool install` (isolated, no system-Python/PEP 668), bootstrapping uv if missing, and prints how to start the server. Supports XAGENT_VERSION pinning; POSIX sh; Linux/macOS. Also add: - scripts/get.xagent.co/: a Cloudflare Worker (+ wrangler.toml, README) that serves the script pinned to the latest release tag (falls back to main). - .github/workflows/install-script.yml: ShellCheck plus an end-to-end smoke test that runs the installer on Linux and macOS and asserts `xagent --help`, so the one-liner can't silently rot. --- .github/workflows/install-script.yml | 43 +++++++++++++++++ scripts/get.xagent.co/README.md | 26 ++++++++++ scripts/get.xagent.co/worker.js | 61 ++++++++++++++++++++++++ scripts/get.xagent.co/wrangler.toml | 9 ++++ scripts/install.sh | 71 ++++++++++++++++++++++++++++ 5 files changed, 210 insertions(+) create mode 100644 .github/workflows/install-script.yml create mode 100644 scripts/get.xagent.co/README.md create mode 100644 scripts/get.xagent.co/worker.js create mode 100644 scripts/get.xagent.co/wrangler.toml create mode 100755 scripts/install.sh diff --git a/.github/workflows/install-script.yml b/.github/workflows/install-script.yml new file mode 100644 index 0000000000..d149fc259a --- /dev/null +++ b/.github/workflows/install-script.yml @@ -0,0 +1,43 @@ +name: Install script + +on: + pull_request: + paths: + - scripts/install.sh + - .github/workflows/install-script.yml + push: + branches: [main] + paths: + - scripts/install.sh + - .github/workflows/install-script.yml + workflow_dispatch: + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: ShellCheck + run: shellcheck scripts/install.sh # pre-installed on ubuntu runners + + smoke: + # Actually run the one-liner end to end so it can't silently rot. + needs: lint + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - name: Run installer and verify the command works + env: + # Isolate the tool install so the runner's env stays clean and we + # control where the `xagent` entry point lands. + UV_TOOL_BIN_DIR: ${{ runner.temp }}/xagent-bin + run: | + set -eu + sh scripts/install.sh + export PATH="$UV_TOOL_BIN_DIR:$PATH" + command -v xagent + xagent --help diff --git a/scripts/get.xagent.co/README.md b/scripts/get.xagent.co/README.md new file mode 100644 index 0000000000..11895c751d --- /dev/null +++ b/scripts/get.xagent.co/README.md @@ -0,0 +1,26 @@ +# get.xagent.co + +Cloudflare Worker that serves [`scripts/install.sh`](../install.sh) so users can install Xagent with: + +```bash +curl -fsSL https://get.xagent.co | sh +``` + +The Worker fetches the installer pinned to the **latest GitHub release tag** (falling back to `main` only if the release lookup fails), so the public one-liner always serves a shipped, reviewed version. + +## Deploy + +Requires [`wrangler`](https://developers.cloudflare.com/workers/wrangler/) and access to the Cloudflare account that owns the `xagent.co` zone. + +```bash +cd scripts/get.xagent.co +wrangler deploy +``` + +Then map `get.xagent.co` to this Worker (the `routes` entry in `wrangler.toml` does this once the zone is on the account). + +## Notes + +- The Worker only serves the script; it runs no user code. +- Edge-caches the resolved script for 5 minutes (`CACHE_TTL_SECONDS`). +- To publish a change to the installer: merge it to `main`, then it goes live at the next release (or immediately via the `main` fallback if there is no release yet). diff --git a/scripts/get.xagent.co/worker.js b/scripts/get.xagent.co/worker.js new file mode 100644 index 0000000000..9456990910 --- /dev/null +++ b/scripts/get.xagent.co/worker.js @@ -0,0 +1,61 @@ +// Cloudflare Worker backing https://get.xagent.co +// +// Serves scripts/install.sh from the xagent repo so users can run: +// +// curl -fsSL https://get.xagent.co | sh +// +// The script is pinned to the latest GitHub *release tag* (not `main`), so the +// public one-liner always fetches a shipped, reviewed version. Falls back to +// `main` only if the release lookup fails. Deploy with `wrangler deploy`. + +const REPO = "xorbitsai/xagent"; +const SCRIPT_PATH = "scripts/install.sh"; +const FALLBACK_REF = "main"; +// Cache the resolved script at the edge to avoid hitting GitHub on every hit. +const CACHE_TTL_SECONDS = 300; + +async function latestReleaseTag() { + const res = await fetch(`https://api.github.com/repos/${REPO}/releases/latest`, { + headers: { "User-Agent": "get.xagent.co", Accept: "application/vnd.github+json" }, + cf: { cacheTtl: CACHE_TTL_SECONDS, cacheEverything: true }, + }); + if (!res.ok) return null; + const data = await res.json(); + return typeof data.tag_name === "string" && data.tag_name ? data.tag_name : null; +} + +async function fetchScript(ref) { + const url = `https://raw.githubusercontent.com/${REPO}/${ref}/${SCRIPT_PATH}`; + return fetch(url, { + headers: { "User-Agent": "get.xagent.co" }, + cf: { cacheTtl: CACHE_TTL_SECONDS, cacheEverything: true }, + }); +} + +export default { + async fetch() { + const ref = (await latestReleaseTag()) || FALLBACK_REF; + + let res = await fetchScript(ref); + if (!res.ok && ref !== FALLBACK_REF) { + res = await fetchScript(FALLBACK_REF); // tag exists but file missing at that tag + } + if (!res.ok) { + return new Response("# Xagent installer temporarily unavailable\n", { + status: 502, + headers: { "content-type": "text/plain; charset=utf-8" }, + }); + } + + const body = await res.text(); + return new Response(body, { + status: 200, + headers: { + // text/plain so `curl | sh` gets the raw script, never rendered HTML. + "content-type": "text/plain; charset=utf-8", + "cache-control": `public, max-age=${CACHE_TTL_SECONDS}`, + "x-xagent-install-ref": ref, + }, + }); + }, +}; diff --git a/scripts/get.xagent.co/wrangler.toml b/scripts/get.xagent.co/wrangler.toml new file mode 100644 index 0000000000..6c1a4a8186 --- /dev/null +++ b/scripts/get.xagent.co/wrangler.toml @@ -0,0 +1,9 @@ +name = "xagent-get" +main = "worker.js" +compatibility_date = "2026-07-01" + +# Serve the installer at https://get.xagent.co (the zone must be on this +# Cloudflare account; adjust if the domain moves). +routes = [ + { pattern = "get.xagent.co", custom_domain = true }, +] diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100755 index 0000000000..0704b67352 --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,71 @@ +#!/bin/sh +# Xagent installer — https://get.xagent.co +# +# curl -fsSL https://get.xagent.co | sh +# +# Installs the `xagent-ai` package (backend + bundled web UI) as an isolated uv +# tool, so nothing touches your system Python and PEP 668 never bites. On +# success the `xagent` command is available; start it and open the browser. +# +# Options (environment variables): +# XAGENT_VERSION pin a specific version, e.g. XAGENT_VERSION=0.6.0 +# +# Prefer not to pipe curl into sh? The equivalent manual install is: +# uv tool install xagent-ai # or, in a venv: pip install xagent-ai +set -eu + +APP="xagent-ai" +CMD="xagent" + +info() { printf '\033[1;34m==>\033[0m %s\n' "$1"; } +warn() { printf '\033[1;33mwarning:\033[0m %s\n' "$1" >&2; } +err() { + printf '\033[1;31merror:\033[0m %s\n' "$1" >&2 + exit 1 +} + +# uv supports Linux and macOS. Windows users should use pip in a venv. +os="$(uname -s)" +case "$os" in + Linux | Darwin) ;; + *) err "Unsupported OS '$os'. On Windows, install with: pip install $APP (in a virtualenv)." ;; +esac + +# Ensure uv is available (isolates the install; avoids system-Python/PEP 668). +if ! command -v uv >/dev/null 2>&1; then + info "Installing uv (Python tool manager)..." + curl -LsSf https://astral.sh/uv/install.sh | sh + # uv installs into ~/.local/bin (or ~/.cargo/bin on older installers); make it + # visible to the rest of this script without requiring a new shell. + for d in "$HOME/.local/bin" "$HOME/.cargo/bin"; do + if [ -d "$d" ]; then + case ":$PATH:" in + *":$d:"*) ;; + *) PATH="$d:$PATH" ;; + esac + fi + done + export PATH +fi +command -v uv >/dev/null 2>&1 || err "uv not found on PATH after install; open a new shell and re-run." + +spec="$APP" +if [ -n "${XAGENT_VERSION:-}" ]; then + spec="$APP==$XAGENT_VERSION" +fi + +info "Installing $spec ..." +uv tool install --upgrade "$spec" + +printf '\n' +info "Installed. Next steps:" +printf '\n' +printf ' Start Xagent: %s\n' "$CMD" +printf ' Open: http://127.0.0.1:8000\n' +printf ' Configure an LLM key (e.g. OPENAI_API_KEY) via a .env file or env var.\n' +printf '\n' + +if ! command -v "$CMD" >/dev/null 2>&1; then + warn "'$CMD' is not on your PATH in this shell yet." + warn "Run 'uv tool update-shell' and open a new terminal, then run '$CMD'." +fi From 81293881ad5946fb93545d348ba115da6d833bb5 Mon Sep 17 00:00:00 2001 From: qinxuye Date: Sat, 4 Jul 2026 21:08:47 +0800 Subject: [PATCH 2/7] =?UTF-8?q?fix(install):=20address=20review=20?= =?UTF-8?q?=E2=80=94=20original=20PATH=20check=20and=20worker=20robustness?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - install.sh: capture ORIG_PATH before mutating PATH and use it for the final 'not on PATH' warning, so the warning reflects the user's real shell env rather than the PATH the script temporarily extended. - worker.js: wrap the GitHub release lookup and the whole request handler in try/catch so an API outage or unexpected error degrades to the main fallback / a clean 502 text response instead of a Cloudflare HTML 500; only serve the installer at the root path. --- scripts/get.xagent.co/worker.js | 75 +++++++++++++++++++++------------ scripts/install.sh | 7 ++- 2 files changed, 54 insertions(+), 28 deletions(-) diff --git a/scripts/get.xagent.co/worker.js b/scripts/get.xagent.co/worker.js index 9456990910..89fe9d23c0 100644 --- a/scripts/get.xagent.co/worker.js +++ b/scripts/get.xagent.co/worker.js @@ -15,13 +15,19 @@ const FALLBACK_REF = "main"; const CACHE_TTL_SECONDS = 300; async function latestReleaseTag() { - const res = await fetch(`https://api.github.com/repos/${REPO}/releases/latest`, { - headers: { "User-Agent": "get.xagent.co", Accept: "application/vnd.github+json" }, - cf: { cacheTtl: CACHE_TTL_SECONDS, cacheEverything: true }, - }); - if (!res.ok) return null; - const data = await res.json(); - return typeof data.tag_name === "string" && data.tag_name ? data.tag_name : null; + // Never throw: a GitHub API outage/rate-limit must degrade to the main + // fallback, not crash the installer endpoint. + try { + const res = await fetch(`https://api.github.com/repos/${REPO}/releases/latest`, { + headers: { "User-Agent": "get.xagent.co", Accept: "application/vnd.github+json" }, + cf: { cacheTtl: CACHE_TTL_SECONDS, cacheEverything: true }, + }); + if (!res.ok) return null; + const data = await res.json(); + return typeof data.tag_name === "string" && data.tag_name ? data.tag_name : null; + } catch { + return null; + } } async function fetchScript(ref) { @@ -32,30 +38,45 @@ async function fetchScript(ref) { }); } -export default { - async fetch() { - const ref = (await latestReleaseTag()) || FALLBACK_REF; +const UNAVAILABLE = () => + new Response("# Xagent installer temporarily unavailable\n", { + status: 502, + headers: { "content-type": "text/plain; charset=utf-8" }, + }); - let res = await fetchScript(ref); - if (!res.ok && ref !== FALLBACK_REF) { - res = await fetchScript(FALLBACK_REF); // tag exists but file missing at that tag - } - if (!res.ok) { - return new Response("# Xagent installer temporarily unavailable\n", { - status: 502, +export default { + async fetch(request) { + // Only the root path serves the installer; ignore /favicon.ico etc. + if (new URL(request.url).pathname !== "/") { + return new Response("Not Found\n", { + status: 404, headers: { "content-type": "text/plain; charset=utf-8" }, }); } - const body = await res.text(); - return new Response(body, { - status: 200, - headers: { - // text/plain so `curl | sh` gets the raw script, never rendered HTML. - "content-type": "text/plain; charset=utf-8", - "cache-control": `public, max-age=${CACHE_TTL_SECONDS}`, - "x-xagent-install-ref": ref, - }, - }); + try { + const ref = (await latestReleaseTag()) || FALLBACK_REF; + + let res = await fetchScript(ref); + if (!res.ok && ref !== FALLBACK_REF) { + res = await fetchScript(FALLBACK_REF); // tag exists but file missing at that tag + } + if (!res.ok) return UNAVAILABLE(); + + const body = await res.text(); + return new Response(body, { + status: 200, + headers: { + // text/plain so `curl | sh` gets the raw script, never rendered HTML. + "content-type": "text/plain; charset=utf-8", + "cache-control": `public, max-age=${CACHE_TTL_SECONDS}`, + "x-xagent-install-ref": ref, + }, + }); + } catch { + // Any unexpected error → clean 502 text, never a Cloudflare HTML 500 + // (which would break a piped `curl | sh`). + return UNAVAILABLE(); + } }, }; diff --git a/scripts/install.sh b/scripts/install.sh index 0704b67352..208ff9bf2f 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -14,6 +14,11 @@ # uv tool install xagent-ai # or, in a venv: pip install xagent-ai set -eu +# The user's PATH before this script mutates it (below, when bootstrapping uv). +# Used at the end to warn correctly about whether the parent shell will find the +# installed command. +ORIG_PATH="$PATH" + APP="xagent-ai" CMD="xagent" @@ -65,7 +70,7 @@ printf ' Open: http://127.0.0.1:8000\n' printf ' Configure an LLM key (e.g. OPENAI_API_KEY) via a .env file or env var.\n' printf '\n' -if ! command -v "$CMD" >/dev/null 2>&1; then +if ! PATH="$ORIG_PATH" command -v "$CMD" >/dev/null 2>&1; then warn "'$CMD' is not on your PATH in this shell yet." warn "Run 'uv tool update-shell' and open a new terminal, then run '$CMD'." fi From 7c5ef78eafe8f17698d8c99bf8cecd05b084cdf9 Mon Sep 17 00:00:00 2001 From: qinxuye Date: Sat, 4 Jul 2026 21:23:59 +0800 Subject: [PATCH 3/7] =?UTF-8?q?fix(install):=20address=20review=20?= =?UTF-8?q?=E2=80=94=20version=20v-prefix,=20PATH=20hint,=20served=20ref?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - install.sh: strip a leading 'v' from XAGENT_VERSION (v0.6.0 -> 0.6.0) so a git-tag-style value installs; when uv was just installed and isn't on the parent shell's PATH, tell the user to open a new terminal / export PATH rather than run 'uv tool update-shell' (which wouldn't be found). - worker.js: report the actually-served ref in x-xagent-install-ref when the release tag is missing the script and it falls back to main. --- scripts/get.xagent.co/worker.js | 4 +++- scripts/install.sh | 10 ++++++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/scripts/get.xagent.co/worker.js b/scripts/get.xagent.co/worker.js index 89fe9d23c0..f1f210a7b4 100644 --- a/scripts/get.xagent.co/worker.js +++ b/scripts/get.xagent.co/worker.js @@ -57,9 +57,11 @@ export default { try { const ref = (await latestReleaseTag()) || FALLBACK_REF; + let servedRef = ref; let res = await fetchScript(ref); if (!res.ok && ref !== FALLBACK_REF) { res = await fetchScript(FALLBACK_REF); // tag exists but file missing at that tag + servedRef = FALLBACK_REF; } if (!res.ok) return UNAVAILABLE(); @@ -70,7 +72,7 @@ export default { // text/plain so `curl | sh` gets the raw script, never rendered HTML. "content-type": "text/plain; charset=utf-8", "cache-control": `public, max-age=${CACHE_TTL_SECONDS}`, - "x-xagent-install-ref": ref, + "x-xagent-install-ref": servedRef, }, }); } catch { diff --git a/scripts/install.sh b/scripts/install.sh index 208ff9bf2f..9161accbd3 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -56,7 +56,8 @@ command -v uv >/dev/null 2>&1 || err "uv not found on PATH after install; open a spec="$APP" if [ -n "${XAGENT_VERSION:-}" ]; then - spec="$APP==$XAGENT_VERSION" + # Strip a leading 'v' (e.g. v0.6.0 -> 0.6.0) so a git-tag-style value works. + spec="$APP==${XAGENT_VERSION#v}" fi info "Installing $spec ..." @@ -72,5 +73,10 @@ printf '\n' if ! PATH="$ORIG_PATH" command -v "$CMD" >/dev/null 2>&1; then warn "'$CMD' is not on your PATH in this shell yet." - warn "Run 'uv tool update-shell' and open a new terminal, then run '$CMD'." + if PATH="$ORIG_PATH" command -v uv >/dev/null 2>&1; then + warn "Run 'uv tool update-shell' and open a new terminal, then run '$CMD'." + else + # uv was just installed by this script and isn't on the parent shell's PATH. + warn "Open a new terminal, or run: export PATH=\"\$HOME/.local/bin:\$PATH\"" + fi fi From 4498d601e851f50166162adc408b17d4908b55a6 Mon Sep 17 00:00:00 2001 From: qinxuye Date: Sat, 4 Jul 2026 21:43:23 +0800 Subject: [PATCH 4/7] =?UTF-8?q?fix(install):=20fail=20closed=20in=20the=20?= =?UTF-8?q?get.xagent.co=20worker=20=E2=80=94=20never=20serve=20main?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A public curl | sh installer must not serve unreleased code. Drop the main fallback: resolve the latest release tag and serve scripts/install.sh at that immutable tag; on any lookup/fetch failure (API outage, rate-limit, tag missing the script) return 502 instead of falling back to the floating main ref. The endpoint therefore requires a release that includes the script to exist; noted in the README. --- scripts/get.xagent.co/README.md | 6 ++++-- scripts/get.xagent.co/worker.js | 27 +++++++++++++-------------- 2 files changed, 17 insertions(+), 16 deletions(-) diff --git a/scripts/get.xagent.co/README.md b/scripts/get.xagent.co/README.md index 11895c751d..d6f0731ed9 100644 --- a/scripts/get.xagent.co/README.md +++ b/scripts/get.xagent.co/README.md @@ -6,7 +6,9 @@ Cloudflare Worker that serves [`scripts/install.sh`](../install.sh) so users can curl -fsSL https://get.xagent.co | sh ``` -The Worker fetches the installer pinned to the **latest GitHub release tag** (falling back to `main` only if the release lookup fails), so the public one-liner always serves a shipped, reviewed version. +The Worker serves the installer pinned to the **latest GitHub release tag**, so the public one-liner only ever runs a shipped, immutable version. It **fails closed**: if the release can't be resolved, or that tag doesn't contain the script, it returns `502` instead of falling back to a floating ref like `main` — a public `curl | sh` endpoint must never serve unreleased code. + +> The endpoint only works once a release that includes `scripts/install.sh` exists. Cut a release after this lands to bring it online. ## Deploy @@ -23,4 +25,4 @@ Then map `get.xagent.co` to this Worker (the `routes` entry in `wrangler.toml` d - The Worker only serves the script; it runs no user code. - Edge-caches the resolved script for 5 minutes (`CACHE_TTL_SECONDS`). -- To publish a change to the installer: merge it to `main`, then it goes live at the next release (or immediately via the `main` fallback if there is no release yet). +- To publish a change to the installer: merge it to `main`, then cut a release — the endpoint serves the latest release tag, so changes go live only once released. diff --git a/scripts/get.xagent.co/worker.js b/scripts/get.xagent.co/worker.js index f1f210a7b4..fde7fcf621 100644 --- a/scripts/get.xagent.co/worker.js +++ b/scripts/get.xagent.co/worker.js @@ -4,19 +4,21 @@ // // curl -fsSL https://get.xagent.co | sh // -// The script is pinned to the latest GitHub *release tag* (not `main`), so the -// public one-liner always fetches a shipped, reviewed version. Falls back to -// `main` only if the release lookup fails. Deploy with `wrangler deploy`. +// The script is pinned to the latest GitHub *release tag*, so the public +// one-liner only ever serves a shipped, immutable version. It fails closed: +// if the release can't be resolved or the tag doesn't contain the script, it +// returns 502 rather than falling back to a floating ref like `main` — a public +// `curl | sh` endpoint must never serve unreleased code. (This means the +// endpoint only works once a release that includes scripts/install.sh exists.) +// Deploy with `wrangler deploy`. const REPO = "xorbitsai/xagent"; const SCRIPT_PATH = "scripts/install.sh"; -const FALLBACK_REF = "main"; // Cache the resolved script at the edge to avoid hitting GitHub on every hit. const CACHE_TTL_SECONDS = 300; async function latestReleaseTag() { - // Never throw: a GitHub API outage/rate-limit must degrade to the main - // fallback, not crash the installer endpoint. + // Never throw: on any API failure return null so the caller fails closed. try { const res = await fetch(`https://api.github.com/repos/${REPO}/releases/latest`, { headers: { "User-Agent": "get.xagent.co", Accept: "application/vnd.github+json" }, @@ -55,14 +57,11 @@ export default { } try { - const ref = (await latestReleaseTag()) || FALLBACK_REF; + // Fail closed: only ever serve a resolved, immutable release tag. + const ref = await latestReleaseTag(); + if (!ref) return UNAVAILABLE(); - let servedRef = ref; - let res = await fetchScript(ref); - if (!res.ok && ref !== FALLBACK_REF) { - res = await fetchScript(FALLBACK_REF); // tag exists but file missing at that tag - servedRef = FALLBACK_REF; - } + const res = await fetchScript(ref); if (!res.ok) return UNAVAILABLE(); const body = await res.text(); @@ -72,7 +71,7 @@ export default { // text/plain so `curl | sh` gets the raw script, never rendered HTML. "content-type": "text/plain; charset=utf-8", "cache-control": `public, max-age=${CACHE_TTL_SECONDS}`, - "x-xagent-install-ref": servedRef, + "x-xagent-install-ref": ref, }, }); } catch { From 3e5322f4f7e1bc040db39f0f47e1c79e6e39e30a Mon Sep 17 00:00:00 2001 From: qinxuye Date: Sat, 4 Jul 2026 22:02:07 +0800 Subject: [PATCH 5/7] ci(install): cover the get.xagent.co worker The installer workflow only triggered on scripts/install.sh and had no check for the hosted worker. Add scripts/get.xagent.co/** to the path filters and a Node syntax check of worker.js (fed via stdin so --input-type=module applies). --- .github/workflows/install-script.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/install-script.yml b/.github/workflows/install-script.yml index d149fc259a..ff88c3c1b1 100644 --- a/.github/workflows/install-script.yml +++ b/.github/workflows/install-script.yml @@ -4,11 +4,13 @@ on: pull_request: paths: - scripts/install.sh + - scripts/get.xagent.co/** - .github/workflows/install-script.yml push: branches: [main] paths: - scripts/install.sh + - scripts/get.xagent.co/** - .github/workflows/install-script.yml workflow_dispatch: @@ -19,6 +21,10 @@ jobs: - uses: actions/checkout@v4 - name: ShellCheck run: shellcheck scripts/install.sh # pre-installed on ubuntu runners + - name: Check the get.xagent.co worker parses + # worker.js is an ES module; feed it via stdin so --input-type applies + # (it is rejected for a file path argument). + run: node --check --input-type=module < scripts/get.xagent.co/worker.js smoke: # Actually run the one-liner end to end so it can't silently rot. From 585e08fbeff4a30b81cc62d326fc5169c6912dce Mon Sep 17 00:00:00 2001 From: qinxuye Date: Sun, 5 Jul 2026 17:34:40 +0800 Subject: [PATCH 6/7] =?UTF-8?q?fix(install):=20address=20review=20?= =?UTF-8?q?=E2=80=94=20fetch=20timeouts,=20version=20guard,=20worker=20tes?= =?UTF-8?q?ts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - worker.js: add AbortSignal.timeout to both outbound fetches so a stuck upstream fails closed (502) fast instead of hanging the curl | sh client. - install.sh: reject an XAGENT_VERSION that is empty after stripping a leading 'v' (e.g. 'v') with a clear message; simplify the uv PATH-prepend (drop the redundant dedup — first match wins and PATH isn't printed). - get.xagent.co: add functional tests (Node built-in runner, no deps) covering fail-closed 502, non-root 404, and serving from the resolved release tag; package.json marks the dir ESM so 'node --check'/imports work directly. CI runs 'node --test' for the worker. --- .github/workflows/install-script.yml | 9 +++--- scripts/get.xagent.co/package.json | 4 +++ scripts/get.xagent.co/worker.js | 2 ++ scripts/get.xagent.co/worker.test.mjs | 45 +++++++++++++++++++++++++++ scripts/install.sh | 11 +++---- 5 files changed, 60 insertions(+), 11 deletions(-) create mode 100644 scripts/get.xagent.co/package.json create mode 100644 scripts/get.xagent.co/worker.test.mjs diff --git a/.github/workflows/install-script.yml b/.github/workflows/install-script.yml index ff88c3c1b1..a6d62a36f8 100644 --- a/.github/workflows/install-script.yml +++ b/.github/workflows/install-script.yml @@ -21,10 +21,11 @@ jobs: - uses: actions/checkout@v4 - name: ShellCheck run: shellcheck scripts/install.sh # pre-installed on ubuntu runners - - name: Check the get.xagent.co worker parses - # worker.js is an ES module; feed it via stdin so --input-type applies - # (it is rejected for a file path argument). - run: node --check --input-type=module < scripts/get.xagent.co/worker.js + - name: Test the get.xagent.co worker + working-directory: scripts/get.xagent.co + run: | + node --check worker.js # ES module (package.json sets "type": "module") + node --test smoke: # Actually run the one-liner end to end so it can't silently rot. diff --git a/scripts/get.xagent.co/package.json b/scripts/get.xagent.co/package.json new file mode 100644 index 0000000000..e986b24bba --- /dev/null +++ b/scripts/get.xagent.co/package.json @@ -0,0 +1,4 @@ +{ + "private": true, + "type": "module" +} diff --git a/scripts/get.xagent.co/worker.js b/scripts/get.xagent.co/worker.js index fde7fcf621..5940889b21 100644 --- a/scripts/get.xagent.co/worker.js +++ b/scripts/get.xagent.co/worker.js @@ -23,6 +23,7 @@ async function latestReleaseTag() { const res = await fetch(`https://api.github.com/repos/${REPO}/releases/latest`, { headers: { "User-Agent": "get.xagent.co", Accept: "application/vnd.github+json" }, cf: { cacheTtl: CACHE_TTL_SECONDS, cacheEverything: true }, + signal: AbortSignal.timeout(5000), // don't hang the client on a stuck upstream }); if (!res.ok) return null; const data = await res.json(); @@ -37,6 +38,7 @@ async function fetchScript(ref) { return fetch(url, { headers: { "User-Agent": "get.xagent.co" }, cf: { cacheTtl: CACHE_TTL_SECONDS, cacheEverything: true }, + signal: AbortSignal.timeout(10000), // a timeout here throws -> handler returns 502 }); } diff --git a/scripts/get.xagent.co/worker.test.mjs b/scripts/get.xagent.co/worker.test.mjs new file mode 100644 index 0000000000..a57db675a6 --- /dev/null +++ b/scripts/get.xagent.co/worker.test.mjs @@ -0,0 +1,45 @@ +// Functional tests for the get.xagent.co Worker. Uses Node's built-in test +// runner (no dependencies) and stubs global fetch. Run: node --test +import assert from "node:assert/strict"; +import { afterEach, test } from "node:test"; + +import worker from "./worker.js"; + +const realFetch = globalThis.fetch; +afterEach(() => { + globalThis.fetch = realFetch; +}); + +const get = (path = "/") => worker.fetch(new Request(`https://get.xagent.co${path}`)); + +test("non-root paths 404", async () => { + const res = await get("/favicon.ico"); + assert.equal(res.status, 404); +}); + +test("fails closed (502) when the latest release can't be resolved", async () => { + globalThis.fetch = async () => new Response("boom", { status: 500 }); + const res = await get("/"); + assert.equal(res.status, 502); +}); + +test("fails closed (502) when the release tag lacks the script", async () => { + globalThis.fetch = async (url) => + String(url).includes("/releases/latest") + ? new Response(JSON.stringify({ tag_name: "v9.9.9" }), { status: 200 }) + : new Response("not found", { status: 404 }); + const res = await get("/"); + assert.equal(res.status, 502); +}); + +test("serves the script from the resolved release tag", async () => { + globalThis.fetch = async (url) => + String(url).includes("/releases/latest") + ? new Response(JSON.stringify({ tag_name: "v1.2.3" }), { status: 200 }) + : new Response("#!/bin/sh\necho hi\n", { status: 200 }); + const res = await get("/"); + assert.equal(res.status, 200); + assert.equal(res.headers.get("content-type"), "text/plain; charset=utf-8"); + assert.equal(res.headers.get("x-xagent-install-ref"), "v1.2.3"); + assert.match(await res.text(), /echo hi/); +}); diff --git a/scripts/install.sh b/scripts/install.sh index 9161accbd3..06916c56fe 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -43,12 +43,7 @@ if ! command -v uv >/dev/null 2>&1; then # uv installs into ~/.local/bin (or ~/.cargo/bin on older installers); make it # visible to the rest of this script without requiring a new shell. for d in "$HOME/.local/bin" "$HOME/.cargo/bin"; do - if [ -d "$d" ]; then - case ":$PATH:" in - *":$d:"*) ;; - *) PATH="$d:$PATH" ;; - esac - fi + [ -d "$d" ] && PATH="$d:$PATH" done export PATH fi @@ -57,7 +52,9 @@ command -v uv >/dev/null 2>&1 || err "uv not found on PATH after install; open a spec="$APP" if [ -n "${XAGENT_VERSION:-}" ]; then # Strip a leading 'v' (e.g. v0.6.0 -> 0.6.0) so a git-tag-style value works. - spec="$APP==${XAGENT_VERSION#v}" + version="${XAGENT_VERSION#v}" + [ -n "$version" ] || err "XAGENT_VERSION='$XAGENT_VERSION' is not a valid version." + spec="$APP==$version" fi info "Installing $spec ..." From e1f803036c2eea9070477ce6ba73761bfa312a64 Mon Sep 17 00:00:00 2001 From: qinxuye Date: Sun, 5 Jul 2026 22:33:25 +0800 Subject: [PATCH 7/7] test(install): cover worker fetch-rejection paths; pin Node in CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - worker.test.mjs: add two tests where fetch rejects (release lookup, and raw script fetch), exercising the try/catch paths an AbortSignal.timeout abort or malformed JSON would hit — previously only the !res.ok branches were covered. - install-script workflow: add actions/setup-node (pin 22) so node --test and AbortSignal.timeout don't rely on the runner's default Node version. (Two other nits left as-is per the reviewer: the dropped PATH dedup is harmless and was requested in a prior round; an all-whitespace XAGENT_VERSION still surfaces uv's own error — out of scope.) --- .github/workflows/install-script.yml | 3 +++ scripts/get.xagent.co/worker.test.mjs | 22 ++++++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/.github/workflows/install-script.yml b/.github/workflows/install-script.yml index a6d62a36f8..360488a7bc 100644 --- a/.github/workflows/install-script.yml +++ b/.github/workflows/install-script.yml @@ -19,6 +19,9 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "22" # node --test needs >=18; AbortSignal.timeout needs >=17.3 - name: ShellCheck run: shellcheck scripts/install.sh # pre-installed on ubuntu runners - name: Test the get.xagent.co worker diff --git a/scripts/get.xagent.co/worker.test.mjs b/scripts/get.xagent.co/worker.test.mjs index a57db675a6..c2f616fe3b 100644 --- a/scripts/get.xagent.co/worker.test.mjs +++ b/scripts/get.xagent.co/worker.test.mjs @@ -43,3 +43,25 @@ test("serves the script from the resolved release tag", async () => { assert.equal(res.headers.get("x-xagent-install-ref"), "v1.2.3"); assert.match(await res.text(), /echo hi/); }); + +test("fails closed (502) when the release lookup rejects (e.g. timeout abort)", async () => { + // Exercises latestReleaseTag()'s try/catch (an AbortSignal.timeout abort or + // malformed JSON lands here) -> null -> 502. + globalThis.fetch = async () => { + throw new Error("boom"); + }; + const res = await get("/"); + assert.equal(res.status, 502); +}); + +test("fails closed (502) when the script fetch rejects (e.g. timeout abort)", async () => { + // Tag resolves, then the raw fetch rejects -> the handler's outer try/catch. + globalThis.fetch = async (url) => { + if (String(url).includes("/releases/latest")) { + return new Response(JSON.stringify({ tag_name: "v1.2.3" }), { status: 200 }); + } + throw new Error("boom"); + }; + const res = await get("/"); + assert.equal(res.status, 502); +});