Compare commits
No commits in common. "main" and "v1.1.0" have entirely different histories.
|
|
@ -1,91 +0,0 @@
|
||||||
name: Build and Release auth-rt
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
tags:
|
|
||||||
- 'v*'
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
release:
|
|
||||||
runs-on: docker
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Install Go
|
|
||||||
run: |
|
|
||||||
curl -fsSL https://go.dev/dl/go1.22.12.linux-amd64.tar.gz | tar -C /usr/local -xz
|
|
||||||
echo "/usr/local/go/bin" >> $GITHUB_PATH
|
|
||||||
|
|
||||||
- name: Build all platforms
|
|
||||||
run: |
|
|
||||||
export PATH="/usr/local/go/bin:$PATH"
|
|
||||||
export GOTOOLCHAIN=local
|
|
||||||
cd cli
|
|
||||||
for pair in darwin/arm64 darwin/amd64 linux/amd64 linux/arm64; do
|
|
||||||
os="${pair%/*}"
|
|
||||||
arch="${pair#*/}"
|
|
||||||
echo "Building auth-rt-${os}-${arch}..."
|
|
||||||
GOOS="$os" GOARCH="$arch" CGO_ENABLED=0 go build -ldflags="-s -w" -o "../auth-rt-${os}-${arch}" .
|
|
||||||
done
|
|
||||||
ls -lh ../auth-rt-*
|
|
||||||
|
|
||||||
- name: Create release and upload binaries
|
|
||||||
env:
|
|
||||||
TAG: ${{ github.ref_name }}
|
|
||||||
FORGEJO_TOKEN: ${{ secrets.FORGEJO_TOKEN }}
|
|
||||||
API_URL: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
|
|
||||||
run: |
|
|
||||||
json_get() { node -e "process.stdout.write(String(JSON.parse(require('fs').readFileSync('/dev/stdin','utf8')).$1 || ''))"; }
|
|
||||||
|
|
||||||
# Create release
|
|
||||||
RELEASE_ID=$(curl -s -X POST "$API_URL/releases" \
|
|
||||||
-H "Authorization: token $FORGEJO_TOKEN" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d "{\"tag_name\":\"$TAG\",\"name\":\"$TAG\",\"body\":\"auth-rt $TAG\"}" \
|
|
||||||
| json_get id)
|
|
||||||
|
|
||||||
echo "Created release ID: $RELEASE_ID"
|
|
||||||
|
|
||||||
# Upload each binary
|
|
||||||
for f in auth-rt-*; do
|
|
||||||
echo "Uploading $f..."
|
|
||||||
curl -s -X POST "$API_URL/releases/$RELEASE_ID/assets?name=$f" \
|
|
||||||
-H "Authorization: token $FORGEJO_TOKEN" \
|
|
||||||
-H "Content-Type: application/octet-stream" \
|
|
||||||
--data-binary "@$f"
|
|
||||||
echo ""
|
|
||||||
done
|
|
||||||
|
|
||||||
# Maintain "latest" release
|
|
||||||
LATEST_ID=$(curl -s "$API_URL/releases/tags/latest" \
|
|
||||||
-H "Authorization: token $FORGEJO_TOKEN" | json_get id)
|
|
||||||
|
|
||||||
if [ -n "$LATEST_ID" ]; then
|
|
||||||
# Delete old assets
|
|
||||||
ASSET_IDS=$(curl -s "$API_URL/releases/$LATEST_ID/assets" \
|
|
||||||
-H "Authorization: token $FORGEJO_TOKEN" \
|
|
||||||
| node -e "JSON.parse(require('fs').readFileSync('/dev/stdin','utf8')).forEach(a=>console.log(a.id))")
|
|
||||||
for aid in $ASSET_IDS; do
|
|
||||||
curl -s -X DELETE "$API_URL/releases/$LATEST_ID/assets/$aid" \
|
|
||||||
-H "Authorization: token $FORGEJO_TOKEN"
|
|
||||||
done
|
|
||||||
curl -s -X PATCH "$API_URL/releases/$LATEST_ID" \
|
|
||||||
-H "Authorization: token $FORGEJO_TOKEN" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d "{\"body\":\"auth-rt latest (from $TAG)\"}"
|
|
||||||
else
|
|
||||||
LATEST_ID=$(curl -s -X POST "$API_URL/releases" \
|
|
||||||
-H "Authorization: token $FORGEJO_TOKEN" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"tag_name":"latest","name":"latest","body":"auth-rt latest","prerelease":true}' \
|
|
||||||
| json_get id)
|
|
||||||
fi
|
|
||||||
|
|
||||||
for f in auth-rt-*; do
|
|
||||||
curl -s -X POST "$API_URL/releases/$LATEST_ID/assets?name=$f" \
|
|
||||||
-H "Authorization: token $FORGEJO_TOKEN" \
|
|
||||||
-H "Content-Type: application/octet-stream" \
|
|
||||||
--data-binary "@$f"
|
|
||||||
done
|
|
||||||
|
|
||||||
echo "Done. Release $TAG + latest updated."
|
|
||||||
|
|
@ -1,4 +1 @@
|
||||||
node_modules/
|
node_modules/
|
||||||
dist/
|
|
||||||
cli/auth-rt
|
|
||||||
auth-rt-*
|
|
||||||
|
|
|
||||||
180
cli/auth.go
180
cli/auth.go
|
|
@ -1,180 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
||||||
65
cli/cache.go
65
cli/cache.go
|
|
@ -1,65 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
||||||
111
cli/config.go
111
cli/config.go
|
|
@ -1,111 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
module github.com/agent-skills/auth-rt
|
|
||||||
|
|
||||||
go 1.22
|
|
||||||
55
cli/http.go
55
cli/http.go
|
|
@ -1,55 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
||||||
16
cli/json.go
16
cli/json.go
|
|
@ -1,16 +0,0 @@
|
||||||
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))
|
|
||||||
}
|
|
||||||
96
cli/main.go
96
cli/main.go
|
|
@ -1,96 +0,0 @@
|
||||||
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 <METHOD> <path> [--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> <path>")
|
|
||||||
}
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
82
install.sh
82
install.sh
|
|
@ -1,65 +1,33 @@
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Install auth-rt binary.
|
# Bootstrap auth-runtime into the skill store.
|
||||||
|
# Run this ONCE before installing any skills.
|
||||||
#
|
#
|
||||||
# Mode 1 (local build): ./install.sh [bin-dir]
|
# Usage:
|
||||||
# Requires Go. Compiles from source and installs.
|
# curl -fsSL https://your-forgejo/raw/auth-runtime/main/install.sh | bash
|
||||||
#
|
# # or:
|
||||||
# Mode 2 (download): ./install.sh --download [bin-dir]
|
# ./install.sh [skill-store-dir]
|
||||||
# Downloads pre-built binary from Forgejo release.
|
|
||||||
#
|
|
||||||
# Default bin dir: ~/.local/bin
|
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
FORGEJO="http://192.168.0.108:3030"
|
STORE="${1:-$HOME/.openclaw/skills}"
|
||||||
REPO="agent-skills/auth-runtime"
|
DEST="$STORE/_shared/auth-runtime"
|
||||||
BIN_DIR="$HOME/.local/bin"
|
REPO="${AUTH_RUNTIME_REPO:-}" # set via env or hardcode below
|
||||||
MODE="local"
|
|
||||||
|
|
||||||
for arg in "$@"; do
|
# Hardcode your Forgejo URL here:
|
||||||
case "$arg" in
|
# REPO="https://git.yourserver.com/you/auth-runtime.git"
|
||||||
--download) MODE="download" ;;
|
|
||||||
-*) echo "Unknown flag: $arg"; exit 1 ;;
|
|
||||||
*) BIN_DIR="$arg" ;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
|
|
||||||
mkdir -p "$BIN_DIR"
|
if [[ -z "$REPO" ]]; then
|
||||||
|
echo "Error: set AUTH_RUNTIME_REPO=https://git.yourserver.com/you/auth-runtime.git"
|
||||||
if [ "$MODE" = "download" ]; then
|
exit 1
|
||||||
# Detect platform
|
|
||||||
OS="$(uname -s | tr '[:upper:]' '[:lower:]')"
|
|
||||||
ARCH="$(uname -m)"
|
|
||||||
case "$ARCH" in
|
|
||||||
x86_64) ARCH="amd64" ;;
|
|
||||||
aarch64) ARCH="arm64" ;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
ASSET="auth-rt-${OS}-${ARCH}"
|
|
||||||
URL="$FORGEJO/$REPO/releases/download/latest/$ASSET"
|
|
||||||
|
|
||||||
echo "Downloading $ASSET..."
|
|
||||||
if command -v curl &>/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
|
fi
|
||||||
|
|
||||||
|
if [[ -d "$DEST/.git" ]]; then
|
||||||
|
echo "Updating auth-runtime..."
|
||||||
|
git -C "$DEST" pull --ff-only
|
||||||
|
else
|
||||||
|
echo "Installing auth-runtime to $DEST..."
|
||||||
|
mkdir -p "$STORE/_shared"
|
||||||
|
git clone "$REPO" "$DEST"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "auth-runtime ready at $DEST"
|
||||||
|
|
|
||||||
152
src/auth-cli.ts
152
src/auth-cli.ts
|
|
@ -1,152 +0,0 @@
|
||||||
/**
|
|
||||||
* Thin CLI wrapper for auth-runtime.
|
|
||||||
*
|
|
||||||
* Copy this file into your skill's src/ directory. It calls the
|
|
||||||
* `auth-rt` binary (a standalone Go executable), so the skill has
|
|
||||||
* zero npm/runtime dependency on auth-runtime.
|
|
||||||
*
|
|
||||||
* Prerequisites:
|
|
||||||
* `auth-rt` must be in PATH or at ~/.local/bin/auth-rt
|
|
||||||
* (install.sh handles this automatically)
|
|
||||||
*
|
|
||||||
* Usage:
|
|
||||||
* import { createSkillClient } from './auth-cli.ts';
|
|
||||||
* const client = createSkillClient();
|
|
||||||
* const res = await client.post('/ecom/tasks/scrape', { url: '...' });
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { spawnSync } from 'child_process';
|
|
||||||
import * as path from 'path';
|
|
||||||
import * as os from 'os';
|
|
||||||
|
|
||||||
const home = process.env.HOME || os.homedir();
|
|
||||||
|
|
||||||
// ── session ID (Langfuse tracing) ──
|
|
||||||
// Priority: SKILL_SESSION_ID env > auto-generate
|
|
||||||
const SESSION_ID = process.env.SKILL_SESSION_ID || (() => {
|
|
||||||
const ts = new Date();
|
|
||||||
const pad = (n: number) => String(n).padStart(2, '0');
|
|
||||||
const tsPart = `${ts.getFullYear()}${pad(ts.getMonth()+1)}${pad(ts.getDate())}-${pad(ts.getHours())}${pad(ts.getMinutes())}${pad(ts.getSeconds())}`;
|
|
||||||
const rand = Math.random().toString(36).slice(2, 6);
|
|
||||||
return `skill-${tsPart}-${rand}`;
|
|
||||||
})();
|
|
||||||
process.env.SKILL_SESSION_ID = SESSION_ID;
|
|
||||||
|
|
||||||
const AUTH_RT_BIN = process.env.AUTH_RT_BIN
|
|
||||||
|| (() => {
|
|
||||||
// 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;
|
|
||||||
body: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SessionResponse {
|
|
||||||
accessToken: string;
|
|
||||||
expiresIn: number;
|
|
||||||
ownerSessionToken?: string;
|
|
||||||
hookUrl?: string;
|
|
||||||
hookToken?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ClientConfig {
|
|
||||||
clientId: string;
|
|
||||||
name: string;
|
|
||||||
status: string;
|
|
||||||
metadata: {
|
|
||||||
provider?: {
|
|
||||||
api_key?: string;
|
|
||||||
base_url?: string;
|
|
||||||
model?: string;
|
|
||||||
};
|
|
||||||
[key: string]: unknown;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SkillClientOptions {
|
|
||||||
apiBase?: string;
|
|
||||||
dryRun?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
function runCli(...args: string[]): string {
|
|
||||||
const result = spawnSync(AUTH_RT_BIN, args, {
|
|
||||||
encoding: 'utf-8',
|
|
||||||
timeout: 60_000,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result.error) {
|
|
||||||
throw new Error(`auth-rt spawn failed: ${result.error.message}`);
|
|
||||||
}
|
|
||||||
if (result.status !== 0) {
|
|
||||||
throw new Error(`auth-rt failed (exit ${result.status}): ${(result.stderr || '').trim()}`);
|
|
||||||
}
|
|
||||||
return (result.stdout || '').trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
export class SkillClient {
|
|
||||||
private readonly apiBase?: string;
|
|
||||||
private readonly dryRun: boolean;
|
|
||||||
|
|
||||||
constructor(options: SkillClientOptions = {}) {
|
|
||||||
this.apiBase = options.apiBase;
|
|
||||||
this.dryRun = options.dryRun ?? false;
|
|
||||||
}
|
|
||||||
|
|
||||||
async session(): Promise<SessionResponse> {
|
|
||||||
if (this.dryRun) {
|
|
||||||
return { accessToken: '<dry-run-token>', expiresIn: 900 };
|
|
||||||
}
|
|
||||||
return JSON.parse(runCli('session'));
|
|
||||||
}
|
|
||||||
|
|
||||||
async clientConfig(): Promise<ClientConfig> {
|
|
||||||
if (this.dryRun) {
|
|
||||||
return { clientId: '<dry-run>', name: '<dry-run>', status: 'active', metadata: {} };
|
|
||||||
}
|
|
||||||
return JSON.parse(runCli('client-config'));
|
|
||||||
}
|
|
||||||
|
|
||||||
async get(urlPath: string): Promise<ApiResponse> {
|
|
||||||
return this.request('GET', urlPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
async post(urlPath: string, body?: unknown): Promise<ApiResponse> {
|
|
||||||
return this.request('POST', urlPath, body);
|
|
||||||
}
|
|
||||||
|
|
||||||
async put(urlPath: string, body?: unknown): Promise<ApiResponse> {
|
|
||||||
return this.request('PUT', urlPath, body);
|
|
||||||
}
|
|
||||||
|
|
||||||
async patch(urlPath: string, body?: unknown): Promise<ApiResponse> {
|
|
||||||
return this.request('PATCH', urlPath, body);
|
|
||||||
}
|
|
||||||
|
|
||||||
async delete(urlPath: string, body?: unknown): Promise<ApiResponse> {
|
|
||||||
return this.request('DELETE', urlPath, body);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async request(method: string, urlPath: string, body?: unknown): Promise<ApiResponse> {
|
|
||||||
if (this.dryRun) {
|
|
||||||
return { status: 200, body: JSON.stringify({ dryRun: true, method, path: urlPath }) };
|
|
||||||
}
|
|
||||||
const args = ['request', method, urlPath];
|
|
||||||
if (body != null) {
|
|
||||||
args.push('--body', JSON.stringify(body));
|
|
||||||
}
|
|
||||||
if (this.apiBase) {
|
|
||||||
args.push('--api-base', this.apiBase);
|
|
||||||
}
|
|
||||||
return JSON.parse(runCli(...args));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createSkillClient(options?: SkillClientOptions): SkillClient {
|
|
||||||
return new SkillClient(options);
|
|
||||||
}
|
|
||||||
26
src/env.ts
26
src/env.ts
|
|
@ -4,20 +4,29 @@ import * as os from 'os';
|
||||||
|
|
||||||
const GLOBAL_ENV_PATH = path.join(os.homedir(), '.openclaw', '.env');
|
const GLOBAL_ENV_PATH = path.join(os.homedir(), '.openclaw', '.env');
|
||||||
|
|
||||||
|
/** Keys that were loaded from .env (not pre-existing in process.env) */
|
||||||
|
const loadedKeys = new Set<string>();
|
||||||
|
let loaded = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load ~/.openclaw/.env into process.env.
|
* Load ~/.openclaw/.env into process.env (once, won't overwrite explicitly set env vars).
|
||||||
* Always reads fresh from disk — no caching.
|
|
||||||
* File values always override process.env.
|
|
||||||
*/
|
*/
|
||||||
export function loadGlobalEnv(): void {
|
export function loadGlobalEnv(): void {
|
||||||
|
if (loaded) return;
|
||||||
|
loaded = true;
|
||||||
applyEnvFile();
|
applyEnvFile();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Force re-read ~/.openclaw/.env.
|
* Force re-read ~/.openclaw/.env. Overwrites keys that were originally loaded
|
||||||
* Same as loadGlobalEnv — always reads fresh.
|
* from .env, but still won't touch keys set externally (e.g. shell export).
|
||||||
*/
|
*/
|
||||||
export function reloadGlobalEnv(): void {
|
export function reloadGlobalEnv(): void {
|
||||||
|
// Clear values we previously loaded so applyEnvFile can overwrite them
|
||||||
|
for (const key of loadedKeys) {
|
||||||
|
delete process.env[key];
|
||||||
|
}
|
||||||
|
loadedKeys.clear();
|
||||||
applyEnvFile();
|
applyEnvFile();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -44,7 +53,10 @@ function applyEnvFile(): void {
|
||||||
value = value.slice(1, -1);
|
value = value.slice(1, -1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Always use file value — real-time read, no caching
|
// don't overwrite explicitly set env vars
|
||||||
process.env[key] = value;
|
if (process.env[key] === undefined) {
|
||||||
|
process.env[key] = value;
|
||||||
|
loadedKeys.add(key);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue