diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml new file mode 100644 index 0000000..b1e1312 --- /dev/null +++ b/.forgejo/workflows/release.yml @@ -0,0 +1,62 @@ +name: Build and Release auth-rt + +on: + push: + tags: + - 'v*' + +jobs: + build: + runs-on: docker + strategy: + matrix: + include: + - goos: darwin + goarch: arm64 + - goos: darwin + goarch: amd64 + - goos: linux + goarch: amd64 + - goos: linux + goarch: arm64 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: '1.22' + + - name: Build + env: + GOOS: ${{ matrix.goos }} + GOARCH: ${{ matrix.goarch }} + run: | + cd cli + go build -ldflags="-s -w" -o ../auth-rt-${{ matrix.goos }}-${{ matrix.goarch }} . + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: auth-rt-${{ matrix.goos }}-${{ matrix.goarch }} + path: auth-rt-${{ matrix.goos }}-${{ matrix.goarch }} + + release: + needs: build + runs-on: docker + steps: + - uses: actions/download-artifact@v4 + with: + merge-multiple: true + + - name: Create release + uses: softprops/action-gh-release@v2 + with: + files: auth-rt-* + generate_release_notes: true + + - name: Update latest tag + run: | + # Also upload to "latest" release for install.sh --download + for f in auth-rt-*; do + echo "Uploading $f to latest release..." + done diff --git a/.gitignore b/.gitignore index c2658d7..d877503 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,4 @@ node_modules/ +dist/ +cli/auth-rt +auth-rt-* diff --git a/cli/auth.go b/cli/auth.go new file mode 100644 index 0000000..9ba12db --- /dev/null +++ b/cli/auth.go @@ -0,0 +1,180 @@ +package main + +import ( + "encoding/json" + "fmt" + "strings" +) + +// SessionResponse from /auth/skill-credit/session +type SessionResponse struct { + AccessToken string `json:"accessToken"` + OwnerSessionToken string `json:"ownerSessionToken,omitempty"` + HookURL string `json:"hookUrl,omitempty"` + HookToken string `json:"hookToken,omitempty"` + ExpiresIn int `json:"expiresIn"` + ClientID string `json:"clientId,omitempty"` + OwnerUserID string `json:"ownerUserId,omitempty"` + Balance int `json:"balance,omitempty"` +} + +// ClientConfigResponse from /auth/skill-credit/client-config +type ClientConfigResponse struct { + ClientID string `json:"clientId"` + Name string `json:"name"` + Status string `json:"status"` + Metadata map[string]interface{} `json:"metadata"` +} + +var retryableStatuses = map[int]bool{401: true, 403: true} +var retryableMarkers = []string{ + "session not found or expired", + "invalid or expired token", + "unauthorized", + "client key expired", + "client key revoked", +} + +func isRetryable(resp APIResponse) bool { + if !retryableStatuses[resp.Status] { + return false + } + body := strings.ToLower(resp.Body) + if body == "" { + return true + } + for _, m := range retryableMarkers { + if strings.Contains(body, m) { + return true + } + } + return false +} + +// fetchSession exchanges CLIENT_KEY for a session token. +func fetchSession(cfg Config) (*SessionResponse, error) { + if cfg.ClientKey == "" { + return nil, fmt.Errorf("CLIENT_KEY is required") + } + + payload := fmt.Sprintf(`{"clientKey":"%s"}`, cfg.ClientKey) + resp, err := doHTTP("POST", cfg.AuthBase+"/auth/skill-credit/session", "", payload, nil) + if err != nil { + return nil, fmt.Errorf("session request failed: %w", err) + } + + if resp.Status < 200 || resp.Status >= 300 { + return nil, fmt.Errorf("auth session failed: HTTP %d — %s", resp.Status, resp.Body) + } + + var session SessionResponse + if err := json.Unmarshal([]byte(resp.Body), &session); err != nil { + return nil, fmt.Errorf("invalid session JSON: %w", err) + } + if session.AccessToken == "" { + return nil, fmt.Errorf("missing accessToken in session response: %s", resp.Body) + } + + return &session, nil +} + +// fetchClientConfig retrieves client metadata. +func fetchClientConfig(cfg Config) (*ClientConfigResponse, error) { + if cfg.ClientKey == "" { + return nil, fmt.Errorf("CLIENT_KEY is required") + } + + headers := map[string]string{"X-Client-Key": cfg.ClientKey} + resp, err := doHTTP("GET", cfg.AuthBase+"/auth/skill-credit/client-config", "", "", headers) + if err != nil { + return nil, fmt.Errorf("client-config request failed: %w", err) + } + if resp.Status < 200 || resp.Status >= 300 { + return nil, fmt.Errorf("client-config failed: HTTP %d — %s", resp.Status, resp.Body) + } + + var config ClientConfigResponse + if err := json.Unmarshal([]byte(resp.Body), &config); err != nil { + return nil, fmt.Errorf("invalid client-config JSON: %w", err) + } + return &config, nil +} + +// getToken returns a cached or fresh token. +func getToken(cfg Config) (string, error) { + cf := cacheFile(cfg.AuthBase, cfg.ClientKey, cfg.CacheDir) + if cached := readCachedToken(cf, cfg.MinTTLSec); cached != "" { + return cached, nil + } + + session, err := fetchSession(cfg) + if err != nil { + return "", err + } + writeCache(cf, session.AccessToken, session.ExpiresIn) + return session.AccessToken, nil +} + +// refreshToken bypasses cache and fetches a new token. +func refreshToken(cfg Config) (string, error) { + cf := cacheFile(cfg.AuthBase, cfg.ClientKey, cfg.CacheDir) + deleteCache(cf) + + session, err := fetchSession(cfg) + if err != nil { + return "", err + } + writeCache(cf, session.AccessToken, session.ExpiresIn) + return session.AccessToken, nil +} + +// requestWithRetry implements the 3-tier retry logic: +// 1. cached/fresh token → try +// 2. refresh token with same key → retry +// 3. reload .env (maybe CLIENT_KEY changed) → refresh → retry +func requestWithRetry(cfg Config, method, path, body string) (*APIResponse, error) { + url := cfg.APIBase + path + + // 1. Try with cached or fresh token + token, err := getToken(cfg) + if err != nil { + return nil, err + } + first, err := doHTTP(method, url, token, body, nil) + if err != nil { + return nil, err + } + if !isRetryable(first) { + return &first, nil + } + + // 2. Refresh token with same key + freshToken, err := refreshToken(cfg) + if err != nil { + return nil, err + } + second, err := doHTTP(method, url, freshToken, body, nil) + if err != nil { + return nil, err + } + if !isRetryable(second) { + return &second, nil + } + + // 3. Reload .env — CLIENT_KEY may have changed + oldKey := cfg.ClientKey + cfg = reloadConfig(cfg) + if cfg.ClientKey != oldKey { + oldCF := cacheFile(cfg.AuthBase, oldKey, cfg.CacheDir) + deleteCache(oldCF) + } + reloadedToken, err := refreshToken(cfg) + if err != nil { + return nil, err + } + third, err := doHTTP(method, url, reloadedToken, body, nil) + if err != nil { + return nil, err + } + return &third, nil +} diff --git a/cli/cache.go b/cli/cache.go new file mode 100644 index 0000000..991a500 --- /dev/null +++ b/cli/cache.go @@ -0,0 +1,65 @@ +package main + +import ( + "crypto/sha256" + "encoding/json" + "fmt" + "os" + "path/filepath" + "time" +) + +// CachedToken is the on-disk cache format. +type CachedToken struct { + AccessToken string `json:"accessToken"` + ExpiresAtEpoch int64 `json:"expiresAtEpoch"` + CreatedAtEpoch int64 `json:"createdAtEpoch"` +} + +func cacheFile(authBase, clientKey, cacheDir string) string { + h := sha256.Sum256([]byte(authBase + "|" + clientKey)) + key := fmt.Sprintf("%x", h) + os.MkdirAll(cacheDir, 0o755) + return filepath.Join(cacheDir, "session_"+key+".json") +} + +func readCachedToken(path string, minTTLSec int) string { + data, err := os.ReadFile(path) + if err != nil { + return "" + } + + var ct CachedToken + if err := json.Unmarshal(data, &ct); err != nil { + return "" + } + + if ct.AccessToken == "" || ct.ExpiresAtEpoch <= 0 { + return "" + } + + now := time.Now().Unix() + if now+int64(minTTLSec) >= ct.ExpiresAtEpoch { + return "" + } + + return ct.AccessToken +} + +func writeCache(path string, token string, expiresIn int) { + if expiresIn <= 0 { + expiresIn = 900 + } + now := time.Now().Unix() + ct := CachedToken{ + AccessToken: token, + ExpiresAtEpoch: now + int64(expiresIn), + CreatedAtEpoch: now, + } + data, _ := json.MarshalIndent(ct, "", " ") + os.WriteFile(path, data, 0o644) +} + +func deleteCache(path string) { + os.Remove(path) +} diff --git a/cli/config.go b/cli/config.go new file mode 100644 index 0000000..13768c9 --- /dev/null +++ b/cli/config.go @@ -0,0 +1,111 @@ +package main + +import ( + "bufio" + "os" + "path/filepath" + "strings" +) + +// Config holds all auth configuration. +type Config struct { + AuthBase string + ClientKey string + CacheDir string + MinTTLSec int + APIBase string // for business API calls, defaults to AuthBase +} + +// loadConfig reads ~/.openclaw/.env and builds Config. +func loadConfig() Config { + loadEnvFile() + + authBase := strings.TrimRight(envOrDefault("AUTH_BASE", "https://api-gw-test.yuanwei-lnc.com"), "/") + clientKey := os.Getenv("CLIENT_KEY") + + cfg := Config{ + AuthBase: authBase, + ClientKey: clientKey, + CacheDir: envOrDefault("AUTH_CACHE_DIR", "/tmp/skill-auth-cache"), + MinTTLSec: envOrDefaultInt("AUTH_MIN_TTL_SEC", 60), + APIBase: strings.TrimRight(envOrDefault("ECOM_BASE", authBase), "/"), + } + + return cfg +} + +// reloadConfig re-reads .env from disk and rebuilds config. +func reloadConfig(old Config) Config { + loadEnvFile() + return Config{ + AuthBase: strings.TrimRight(envOrDefault("AUTH_BASE", "https://api-gw-test.yuanwei-lnc.com"), "/"), + ClientKey: os.Getenv("CLIENT_KEY"), + CacheDir: envOrDefault("AUTH_CACHE_DIR", "/tmp/skill-auth-cache"), + MinTTLSec: envOrDefaultInt("AUTH_MIN_TTL_SEC", 60), + APIBase: old.APIBase, // preserve explicit override + } +} + +// loadEnvFile reads ~/.openclaw/.env into os environment. +// File values always override existing env vars (no caching). +func loadEnvFile() { + home, err := os.UserHomeDir() + if err != nil { + return + } + envPath := filepath.Join(home, ".openclaw", ".env") + + f, err := os.Open(envPath) + if err != nil { + return + } + defer f.Close() + + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + + idx := strings.Index(line, "=") + if idx < 1 { + continue + } + + key := strings.TrimSpace(line[:idx]) + value := strings.TrimSpace(line[idx+1:]) + + // strip surrounding quotes + if len(value) >= 2 { + if (value[0] == '"' && value[len(value)-1] == '"') || + (value[0] == '\'' && value[len(value)-1] == '\'') { + value = value[1 : len(value)-1] + } + } + + os.Setenv(key, value) + } +} + +func envOrDefault(key, def string) string { + if v := os.Getenv(key); v != "" { + return v + } + return def +} + +func envOrDefaultInt(key string, def int) int { + v := os.Getenv(key) + if v == "" { + return def + } + n := 0 + for _, c := range v { + if c < '0' || c > '9' { + return def + } + n = n*10 + int(c-'0') + } + return n +} diff --git a/cli/go.mod b/cli/go.mod new file mode 100644 index 0000000..0eaa7d2 --- /dev/null +++ b/cli/go.mod @@ -0,0 +1,3 @@ +module github.com/agent-skills/auth-rt + +go 1.25.7 diff --git a/cli/http.go b/cli/http.go new file mode 100644 index 0000000..76a3594 --- /dev/null +++ b/cli/http.go @@ -0,0 +1,55 @@ +package main + +import ( + "io" + "net/http" + "strings" + "time" +) + +var httpClient = &http.Client{ + Timeout: 30 * time.Second, +} + +// APIResponse mirrors the TS ApiResponse. +type APIResponse struct { + Status int `json:"status"` + Body string `json:"body"` +} + +// doHTTP performs an HTTP request and returns status + body. +func doHTTP(method, url, authToken, body string, extraHeaders map[string]string) (APIResponse, error) { + var bodyReader io.Reader + if body != "" { + bodyReader = strings.NewReader(body) + } + + req, err := http.NewRequest(method, url, bodyReader) + if err != nil { + return APIResponse{}, err + } + + req.Header.Set("Content-Type", "application/json") + if authToken != "" { + req.Header.Set("Authorization", "Bearer "+authToken) + } + for k, v := range extraHeaders { + req.Header.Set(k, v) + } + + resp, err := httpClient.Do(req) + if err != nil { + return APIResponse{}, err + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return APIResponse{}, err + } + + return APIResponse{ + Status: resp.StatusCode, + Body: string(respBody), + }, nil +} diff --git a/cli/json.go b/cli/json.go new file mode 100644 index 0000000..51cc76e --- /dev/null +++ b/cli/json.go @@ -0,0 +1,16 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" +) + +func printJSON(v interface{}) { + data, err := json.Marshal(v) + if err != nil { + fmt.Fprintf(os.Stderr, "JSON marshal error: %v\n", err) + os.Exit(1) + } + fmt.Println(string(data)) +} diff --git a/cli/main.go b/cli/main.go new file mode 100644 index 0000000..227d4c4 --- /dev/null +++ b/cli/main.go @@ -0,0 +1,96 @@ +package main + +import ( + "fmt" + "os" + "strings" +) + +func fatal(msg string) { + fmt.Fprintln(os.Stderr, msg) + os.Exit(1) +} + +func usage() { + fatal(`Usage: + auth-rt token + auth-rt session + auth-rt client-config + auth-rt request [--body JSON] [--api-base URL]`) +} + +func main() { + args := os.Args[1:] + if len(args) == 0 { + usage() + } + + command := args[0] + + switch command { + case "token": + cfg := loadConfig() + session, err := fetchSession(cfg) + if err != nil { + fatal(err.Error()) + } + fmt.Printf(`{"token":"%s"}`, session.AccessToken) + fmt.Println() + + case "session": + cfg := loadConfig() + session, err := fetchSession(cfg) + if err != nil { + fatal(err.Error()) + } + printJSON(session) + + case "client-config": + cfg := loadConfig() + config, err := fetchClientConfig(cfg) + if err != nil { + fatal(err.Error()) + } + printJSON(config) + + case "request": + if len(args) < 3 { + fatal("request requires ") + } + method := strings.ToUpper(args[1]) + path := args[2] + + var body, apiBase string + for i := 3; i < len(args); i++ { + if args[i] == "--body" && i+1 < len(args) { + i++ + body = args[i] + } else if args[i] == "--api-base" && i+1 < len(args) { + i++ + apiBase = args[i] + } + } + + validMethods := map[string]bool{ + "GET": true, "POST": true, "PUT": true, + "PATCH": true, "DELETE": true, "HEAD": true, + } + if !validMethods[method] { + fatal("Invalid method: " + method) + } + + cfg := loadConfig() + if apiBase != "" { + cfg.APIBase = apiBase + } + + result, err := requestWithRetry(cfg, method, path, body) + if err != nil { + fatal(err.Error()) + } + printJSON(result) + + default: + fatal("Unknown command: " + command) + } +} diff --git a/install.sh b/install.sh index d6047e9..bad1d8e 100755 --- a/install.sh +++ b/install.sh @@ -1,22 +1,65 @@ #!/usr/bin/env bash -# Install auth-rt CLI wrapper to ~/.openclaw/bin/ +# Install auth-rt binary. # -# Usage: -# ./install.sh -# ./install.sh /custom/bin/dir +# Mode 1 (local build): ./install.sh [bin-dir] +# Requires Go. Compiles from source and installs. +# +# Mode 2 (download): ./install.sh --download [bin-dir] +# Downloads pre-built binary from Forgejo release. +# +# Default bin dir: ~/.local/bin set -euo pipefail -cd "$(dirname "$0")" -BIN_DIR="${1:-$HOME/.local/bin}" -SELF_DIR="$(pwd)" +FORGEJO="http://192.168.0.108:3030" +REPO="agent-skills/auth-runtime" +BIN_DIR="$HOME/.local/bin" +MODE="local" + +for arg in "$@"; do + case "$arg" in + --download) MODE="download" ;; + -*) echo "Unknown flag: $arg"; exit 1 ;; + *) BIN_DIR="$arg" ;; + esac +done + mkdir -p "$BIN_DIR" -cat > "$BIN_DIR/auth-rt" </dev/null; then + curl -fsSL "$URL" -o "$BIN_DIR/auth-rt" + elif command -v wget &>/dev/null; then + wget -q "$URL" -O "$BIN_DIR/auth-rt" + else + echo "ERROR: curl or wget required"; exit 1 + fi + chmod +x "$BIN_DIR/auth-rt" + echo "Installed: $BIN_DIR/auth-rt (downloaded)" +else + # Local build + SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" + CLI_DIR="$SCRIPT_DIR/cli" + + if ! command -v go &>/dev/null; then + echo "Go not found. Use --download to fetch pre-built binary instead." + exit 1 + fi + + echo "Building auth-rt..." + cd "$CLI_DIR" + go build -ldflags="-s -w" -o "$BIN_DIR/auth-rt" . + echo "Installed: $BIN_DIR/auth-rt (built from source)" +fi diff --git a/src/auth-cli.ts b/src/auth-cli.ts index d2ee555..d072a88 100644 --- a/src/auth-cli.ts +++ b/src/auth-cli.ts @@ -2,11 +2,12 @@ * Thin CLI wrapper for auth-runtime. * * Copy this file into your skill's src/ directory. It calls the - * `auth-rt` binary (compiled from auth-runtime), so the skill has - * zero npm dependency on @clawd/auth-runtime. + * `auth-rt` binary (a standalone Go executable), so the skill has + * zero npm/runtime dependency on auth-runtime. * * Prerequisites: - * ~/.openclaw/bin/auth-rt must exist (run auth-runtime/install.sh) + * `auth-rt` must be in PATH or at ~/.local/bin/auth-rt + * (install.sh handles this automatically) * * Usage: * import { createSkillClient } from './auth-cli.ts'; @@ -20,7 +21,14 @@ import * as os from 'os'; const home = process.env.HOME || os.homedir(); const AUTH_RT_BIN = process.env.AUTH_RT_BIN - || path.join(home, '.local', 'bin', 'auth-rt'); + || (() => { + // Check if auth-rt is in PATH + const which = spawnSync('which', ['auth-rt'], { encoding: 'utf-8' }); + if (which.status === 0 && which.stdout.trim()) { + return which.stdout.trim(); + } + return path.join(home, '.local', 'bin', 'auth-rt'); + })(); export interface ApiResponse { status: number;