Compare commits

...

14 Commits
v1.1.0 ... main

Author SHA1 Message Date
ywkj 5430437bfd fix: 补充 ClientConfig 接口和 clientConfig() 方法 2026-04-26 20:14:59 +08:00
ywkj 0d72c189b3 feat: 模块级 session ID 自动生成
Build and Release auth-rt / release (push) Successful in 1m50s Details
- auth-cli.ts 加载时自动生成 SKILL_SESSION_ID(格式: skill-YYYYMMDD-HHMMSS-xxxx)
- 优先级: 已有 SKILL_SESSION_ID env > 自动生成
- 所有 import 此文件的 skill 自动获得 session 追踪

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-26 19:00:37 +08:00
ywkj 39ef2f59b6 fix: go.mod 降级到 1.22,CI 加 GOTOOLCHAIN=local
Build and Release auth-rt / release (push) Successful in 1m42s Details
避免 CI 里 Go 1.22 尝试下载 1.25.7 toolchain。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 21:24:22 +08:00
ywkj 48a03f3376 fix: CI 用 node 替代 jq 解析 JSON
Build and Release auth-rt / release (push) Failing after 51s Details
node:20-bookworm 没有 jq,改用 node -e 解析 API 响应。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 21:21:42 +08:00
ywkj 9fd284e556 fix: CI 去掉 golang container,在 node:20-bookworm 里直接装 Go
Build and Release auth-rt / release (push) Failing after 2m25s Details
避免拉 golang 镜像的网络延迟,直接下载 Go tarball 编译。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 21:17:45 +08:00
ywkj f03e09dca2 fix: CI 改用 Forgejo API 上传 release(不依赖 upload-artifact)
Build and Release auth-rt / release (push) Has been cancelled Details
Forgejo 不支持 actions/upload-artifact@v4,改为单 job 交叉编译
+ curl 调用 Forgejo release API 上传二进制。
同时维护 latest release 供 install.sh --download 使用。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 21:03:32 +08:00
ywkj dabcddb05e feat: Go 重写 auth-rt CLI,真正的零依赖二进制
Build and Release auth-rt / build (amd64, darwin) (push) Failing after 2m53s Details
Build and Release auth-rt / build (amd64, linux) (push) Failing after 1m16s Details
Build and Release auth-rt / build (arm64, darwin) (push) Failing after 1m9s Details
Build and Release auth-rt / build (arm64, linux) (push) Failing after 1m15s Details
Build and Release auth-rt / release (push) Has been skipped Details
- cli/ 目录:完整的 Go 实现(env/cache/auth/http/retry)
- install.sh:支持本地编译和 --download 从 release 下载
- .forgejo/workflows/release.yml:CI 交叉编译 darwin/linux × amd64/arm64
- auth-cli.ts:改为调用 auth-rt 二进制(不再需要 bun/node)
- 分发策略:skill install.sh 优先从 release 下载,失败则 clone + go build

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 20:51:45 +08:00
ivanberry e5944a75a2 fix: env.ts 实时读取 ~/.openclaw/.env,不缓存
- 移除 loaded 标志,每次都从文件读取
- 移除 loadedKeys 追踪
- 直接覆盖 process.env,不判断是否已存在
- 确保文件值永远是最新的
2026-03-20 20:15:17 +08:00
ywkj 06cd08d833 chore: 默认安装路径改为 ~/.local/bin/auth-rt
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 18:35:27 +08:00
ywkj ff8e74e0aa fix: 改用 shell wrapper 代替 bun compile(bun 1.x compile 有 bug)
install.sh 生成 shell wrapper 脚本 exec bun run cli.ts,
而非 bun build --compile(生成空文件)。
auth-cli.ts 修复 HOME 环境变量缺失时 homedir() 返回空的问题。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 18:30:56 +08:00
ywkj f079b68559 feat: 编译为独立 auth-rt 二进制,安装到 ~/.openclaw/bin/
- bun build --compile 生成独立可执行文件
- install.sh 编译并安装到 ~/.openclaw/bin/auth-rt
- auth-cli.ts 改为调用 auth-rt 二进制(不再需要 bun run cli.ts)
- skill 不再需要 clone auth-runtime 源码

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 18:26:20 +08:00
ywkj 97a8fd9c7b feat: add auth-cli.ts template for skills to use CLI via subprocess
Skills copy this file into their src/ and import from it instead of
npm-depending on @clawd/auth-runtime. Uses process.execPath for bun
resolution and AUTH_RUNTIME_DIR env for path override.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 14:35:15 +08:00
ywkj da16552ad1 revert: remove notify-dependents CI workflow
Auth-runtime is infrastructure — cascading updates to all clients x
all skills is a business-system concern, not a CI job. Dependent
skills will pick up latest auth-runtime next time they are deployed
via their install.sh (which always does fresh npm install).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 07:09:46 +08:00
ywkj 62fadf30ce ci: add notify-dependents workflow
When auth-runtime is pushed to main, CI notifies the skill-update
webhook to reinstall all dependent skills (1688-product-master,
client-finder, email-content-compose), ensuring they pick up the
latest auth-runtime via their install.sh.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 07:04:23 +08:00
12 changed files with 834 additions and 42 deletions

View File

@ -0,0 +1,91 @@
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."

3
.gitignore vendored
View File

@ -1 +1,4 @@
node_modules/
dist/
cli/auth-rt
auth-rt-*

180
cli/auth.go Normal file
View File

@ -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
}

65
cli/cache.go Normal file
View File

@ -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)
}

111
cli/config.go Normal file
View File

@ -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
}

3
cli/go.mod Normal file
View File

@ -0,0 +1,3 @@
module github.com/agent-skills/auth-rt
go 1.22

55
cli/http.go Normal file
View File

@ -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
}

16
cli/json.go Normal file
View File

@ -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))
}

96
cli/main.go Normal file
View File

@ -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 <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)
}
}

View File

@ -1,33 +1,65 @@
#!/usr/bin/env bash
# Bootstrap auth-runtime into the skill store.
# Run this ONCE before installing any skills.
# Install auth-rt binary.
#
# Usage:
# curl -fsSL https://your-forgejo/raw/auth-runtime/main/install.sh | bash
# # or:
# ./install.sh [skill-store-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
STORE="${1:-$HOME/.openclaw/skills}"
DEST="$STORE/_shared/auth-runtime"
REPO="${AUTH_RUNTIME_REPO:-}" # set via env or hardcode below
FORGEJO="http://192.168.0.108:3030"
REPO="agent-skills/auth-runtime"
BIN_DIR="$HOME/.local/bin"
MODE="local"
# Hardcode your Forgejo URL here:
# REPO="https://git.yourserver.com/you/auth-runtime.git"
for arg in "$@"; do
case "$arg" in
--download) MODE="download" ;;
-*) echo "Unknown flag: $arg"; exit 1 ;;
*) BIN_DIR="$arg" ;;
esac
done
if [[ -z "$REPO" ]]; then
echo "Error: set AUTH_RUNTIME_REPO=https://git.yourserver.com/you/auth-runtime.git"
mkdir -p "$BIN_DIR"
if [ "$MODE" = "download" ]; then
# 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
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"
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
echo "auth-runtime ready at $DEST"

152
src/auth-cli.ts Normal file
View File

@ -0,0 +1,152 @@
/**
* 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);
}

View File

@ -4,29 +4,20 @@ import * as os from 'os';
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 (once, won't overwrite explicitly set env vars).
* Load ~/.openclaw/.env into process.env.
* Always reads fresh from disk no caching.
* File values always override process.env.
*/
export function loadGlobalEnv(): void {
if (loaded) return;
loaded = true;
applyEnvFile();
}
/**
* Force re-read ~/.openclaw/.env. Overwrites keys that were originally loaded
* from .env, but still won't touch keys set externally (e.g. shell export).
* Force re-read ~/.openclaw/.env.
* Same as loadGlobalEnv always reads fresh.
*/
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();
}
@ -53,10 +44,7 @@ function applyEnvFile(): void {
value = value.slice(1, -1);
}
// don't overwrite explicitly set env vars
if (process.env[key] === undefined) {
// Always use file value — real-time read, no caching
process.env[key] = value;
loadedKeys.add(key);
}
}
}