diff --git a/.github/workflows/goreleaser.yml b/.github/workflows/goreleaser.yml index b880b1f..a577809 100644 --- a/.github/workflows/goreleaser.yml +++ b/.github/workflows/goreleaser.yml @@ -44,3 +44,46 @@ jobs: dist/*.tar.gz dist/*.zip dist/*.txt + + - name: Mirror release assets to S3-compatible storage + env: + AWS_ACCESS_KEY_ID: ${{ secrets.MIRROR_S3_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.MIRROR_S3_SECRET_ACCESS_KEY }} + AWS_DEFAULT_REGION: ${{ secrets.MIRROR_S3_REGION }} + BUCKET: ${{ secrets.MIRROR_S3_BUCKET }} + ENDPOINT: ${{ secrets.MIRROR_S3_ENDPOINT }} + PREFIX: ${{ secrets.MIRROR_S3_PATH_PREFIX }} + VERSION: ${{ github.ref_name }} + run: | + set -eu + if [ -z "${BUCKET:-}" ] || [ -z "${ENDPOINT:-}" ]; then + echo "Mirror not configured (need MIRROR_S3_BUCKET + MIRROR_S3_ENDPOINT). Skipping." + exit 0 + fi + + # Normalize PREFIX: strip both leading and trailing slashes so a + # value of "/" or "/foo/" doesn't produce a doubled or leading slash + # in the resulting key. + PREFIX="${PREFIX#/}"; PREFIX="${PREFIX%/}" + base="${PREFIX:+${PREFIX}/}releases/download/${VERSION}" + uploaded=0 + for f in dist/*.tar.gz dist/*.zip dist/checksums.txt; do + [ -f "$f" ] || continue + name=$(basename "$f") + echo "Uploading $f -> s3://${BUCKET}/${base}/${name}" + aws --endpoint-url="$ENDPOINT" s3 cp "$f" "s3://${BUCKET}/${base}/${name}" \ + --cache-control "public, max-age=31536000, immutable" + uploaded=$((uploaded + 1)) + done + if [ "$uploaded" -eq 0 ]; then + echo "No release artifacts found in dist/ — refusing to update latest pointer." + exit 1 + fi + + # Latest pointer used by install.sh resolve_version when MIRROR_URL is set. + # Updated last so a partial upload doesn't make the mirror advertise a broken version. + latest_key="${PREFIX:+${PREFIX}/}releases/latest" + printf '%s\n' "$VERSION" > /tmp/latest + aws --endpoint-url="$ENDPOINT" s3 cp /tmp/latest "s3://${BUCKET}/${latest_key}" \ + --cache-control "public, max-age=60" \ + --content-type "text/plain; charset=utf-8" diff --git a/.github/workflows/install-sh.yml b/.github/workflows/install-sh.yml index ea6c95e..c58a736 100644 --- a/.github/workflows/install-sh.yml +++ b/.github/workflows/install-sh.yml @@ -109,3 +109,30 @@ jobs: echo "" echo "ALL CHECKS PASSED" ' + + mirror: + name: mirror install.sh + runs-on: ubuntu-latest + needs: smoke + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + steps: + - uses: actions/checkout@v6 + - name: Upload install.sh to S3-compatible storage + env: + AWS_ACCESS_KEY_ID: ${{ secrets.MIRROR_S3_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.MIRROR_S3_SECRET_ACCESS_KEY }} + AWS_DEFAULT_REGION: ${{ secrets.MIRROR_S3_REGION }} + BUCKET: ${{ secrets.MIRROR_S3_BUCKET }} + ENDPOINT: ${{ secrets.MIRROR_S3_ENDPOINT }} + PREFIX: ${{ secrets.MIRROR_S3_PATH_PREFIX }} + run: | + set -eu + if [ -z "${BUCKET:-}" ] || [ -z "${ENDPOINT:-}" ]; then + echo "Mirror not configured (need MIRROR_S3_BUCKET + MIRROR_S3_ENDPOINT). Skipping." + exit 0 + fi + PREFIX="${PREFIX#/}"; PREFIX="${PREFIX%/}" + key="${PREFIX:+${PREFIX}/}install.sh" + aws --endpoint-url="$ENDPOINT" s3 cp install.sh "s3://${BUCKET}/${key}" \ + --cache-control "public, max-age=300" \ + --content-type "text/x-shellscript; charset=utf-8" diff --git a/install.sh b/install.sh index 5daa53b..1f67cf6 100755 --- a/install.sh +++ b/install.sh @@ -17,6 +17,18 @@ set -eu : "${URL:=wss://api.flashcat.cloud/safari/environment/ws}" : "${VERSION:=}" : "${TOKEN:=}" +# When set, all release downloads are fetched from this prefix instead of +# github.com — the mirror must replicate GitHub's release layout +# (/releases/download//) and expose a plain-text +# /releases/latest file containing the latest tag. +: "${MIRROR_URL:=}" +MIRROR_URL="${MIRROR_URL%/}" +if [ -n "$MIRROR_URL" ]; then + case "$MIRROR_URL" in + https://*) : ;; + *) printf 'MIRROR_URL must use https:// scheme, got: %s\n' "$MIRROR_URL" >&2; exit 1 ;; + esac +fi BINARY_NAME="flashduty-runner" CONFIG_DIR="/etc/flashduty-runner" @@ -85,6 +97,7 @@ ENVIRONMENT: URL Runtime WebSocket URL (default: wss://api.flashcat.cloud/…) INSTALL_DIR Binary install directory (default: /usr/local/bin) REPO GitHub owner/repo override (default: flashcatcloud/flashduty-runner) + MIRROR_URL Download release assets from this mirror prefix instead of github.com EXAMPLES: curl -fsSL https://raw.githubusercontent.com/flashcatcloud/flashduty-runner/main/install.sh | sudo bash @@ -180,13 +193,33 @@ resolve_version() { return fi - info "Resolving latest release from github.com/${REPO}" - effective="$(curl --proto '=https' --tlsv1.2 -fsSLI -o /dev/null -w '%{url_effective}' "https://github.com/${REPO}/releases/latest" || true)" - VERSION="${effective##*/}" - - if [ -z "$VERSION" ] || [ "$VERSION" = "latest" ]; then - die 5 "Could not resolve latest version from github.com/${REPO}/releases/latest" + if [ -n "$MIRROR_URL" ]; then + info "Resolving latest release from mirror" + if ! raw="$(curl --proto '=https' --tlsv1.2 -fsSL "${MIRROR_URL}/releases/latest")"; then + die 5 "Could not fetch ${MIRROR_URL}/releases/latest (curl exit $?)" + fi + # Trim only leading/trailing whitespace — internal whitespace would + # signal a corrupted pointer and should fail the regex check below + # rather than be silently collapsed. + VERSION="$(printf '%s' "$raw" | awk 'NR==1 {gsub(/^[[:space:]]+|[[:space:]]+$/, ""); print; exit}')" + else + info "Resolving latest release from github.com/${REPO}" + effective="$(curl --proto '=https' --tlsv1.2 -fsSLI -o /dev/null -w '%{url_effective}' "https://github.com/${REPO}/releases/latest" || true)" + VERSION="${effective##*/}" + if [ "$VERSION" = "latest" ]; then VERSION=""; fi fi + + # Reject anything that doesn't look like a release tag. Two-stage check: + # first reject characters outside the tag charset (path-traversal guard — + # case glob's bare `*` would otherwise swallow slashes), then require the + # leading 'v' + digit shape. + case "$VERSION" in + *[!A-Za-z0-9.+-]*) die 5 "Resolved version contains illegal characters: '${VERSION}'" ;; + esac + case "$VERSION" in + v[0-9]*) : ;; + *) die 5 "Resolved version is not a valid release tag: '${VERSION}'" ;; + esac info "Latest version: $VERSION" } @@ -214,7 +247,11 @@ sha256_of() { download_and_verify() { asset="${BINARY_NAME}_${OS_TITLE}_${ARCH_GR}.tar.gz" - base="https://github.com/${REPO}/releases/download/${VERSION}" + if [ -n "$MIRROR_URL" ]; then + base="${MIRROR_URL}/releases/download/${VERSION}" + else + base="https://github.com/${REPO}/releases/download/${VERSION}" + fi TMPDIR_="$(mktemp -d 2>/dev/null || mktemp -d -t frdl)" trap 'rm -rf "$TMPDIR_"' EXIT INT TERM diff --git a/protocol/messages.go b/protocol/messages.go index 30f3dd4..0ef403a 100644 --- a/protocol/messages.go +++ b/protocol/messages.go @@ -105,19 +105,19 @@ type HeartbeatMetrics struct { type TaskOperation string const ( - TaskOpRead TaskOperation = "read" - TaskOpWrite TaskOperation = "write" - TaskOpList TaskOperation = "list" - TaskOpGlob TaskOperation = "glob" - TaskOpGrep TaskOperation = "grep" - TaskOpBash TaskOperation = "bash" - TaskOpWebFetch TaskOperation = "webfetch" - TaskOpMCPCall TaskOperation = "mcp_call" - TaskOpMCPListTools TaskOperation = "mcp_list_tools" - TaskOpSyncSkill TaskOperation = "sync_skill" - TaskOpStageKnowledgeFiles TaskOperation = "stage_knowledge_files" - TaskOpDeleteKnowledgeFiles TaskOperation = "delete_knowledge_files" - TaskOpReconcileKnowledgeManifest TaskOperation = "reconcile_knowledge_manifest" + TaskOpRead TaskOperation = "read" + TaskOpWrite TaskOperation = "write" + TaskOpList TaskOperation = "list" + TaskOpGlob TaskOperation = "glob" + TaskOpGrep TaskOperation = "grep" + TaskOpBash TaskOperation = "bash" + TaskOpWebFetch TaskOperation = "webfetch" + TaskOpMCPCall TaskOperation = "mcp_call" + TaskOpMCPListTools TaskOperation = "mcp_list_tools" + TaskOpSyncSkill TaskOperation = "sync_skill" + TaskOpStageKnowledgeFiles TaskOperation = "stage_knowledge_files" + TaskOpDeleteKnowledgeFiles TaskOperation = "delete_knowledge_files" + TaskOpReconcileKnowledgeManifest TaskOperation = "reconcile_knowledge_manifest" ) // TaskRequestPayload is the payload for task request messages.