Files
goclaw/cmd/pkg-helper/main.go
T
Kai (Tam Nhu) Tran 30708ae79d feat(providers): support Codex OAuth pools with inherited routing defaults
* feat(auth): support named chatgpt oauth providers

- add provider-scoped ChatGPT OAuth routes and CLI support

- persist refresh tokens per provider and reject provider-type collisions

- wire provider OAuth setup flows in the dashboard and setup UI

Refs #448

* feat(agent): add chatgpt oauth account routing

- add agent other_config routing for manual and round-robin selection

- reuse routed provider resolution across resolver and pending loaders

- add router, parser, and agent advanced dialog coverage for multi-account use

Refs #448

* docs(api): describe chatgpt oauth routing

- document named-provider ChatGPT OAuth auth routes

- describe agent-side account routing and round-robin behavior

- update OpenAPI agent config schema and provider type enum

Refs #448

* fix(store): add missing agent key context helpers

* feat(ui): clarify chatgpt oauth account setup and routing

* docs(providers): align chatgpt oauth alias examples

* feat(agent): add codex pool activity dashboard

* fix(providers): harden codex oauth alias setup

* feat(codex-pool): improve routing dashboard UX

- redesign the Codex/OpenAI pool page around saved-pool checkpoints and live evidence

- add clearer selection, attention, and recent-proof states for pool members

- make the lower panels fill the remaining desktop viewport while staying responsive

* fix(store): resolve context helper merge duplication

* feat(oauth): add codex pool quota and observation APIs

- add quota inspection and observation endpoints for ChatGPT Subscription (OAuth) providers

- teach codex routing to surface pool activity, observation metadata, and quota-aware readiness

- extend tests and HTTP docs/OpenAPI for the new pool monitoring flows

* feat(web): add codex pool quota monitor and controls

- add provider quota fetching, readiness badges, and live routing evidence on the account pool page

- redesign pool setup and activity panels for multi-account management with localized copy updates

- keep the live monitor internally scrollable and compact the account cards for better viewport fit

* fix(web): clarify pool routing labels

- rename the recent request badge from Direct to Selected

- restore compact quota bars in the live pool cards

* feat(codex-pool): add runtime health dashboard

- derive per-provider success and failure health from routed Codex traces

- surface routing, quota, and recent request evidence in the pool UI

- align provider alias guidance and owner access with the dashboard role model

* docs(auth): document tenant scoping and key roles

* fix(auth): harden tenant and codex pool access control

* fix(providers): align codex pool runtime defaults

* feat(ui): tighten codex pool responsive layout

* feat(chatgpt-oauth): refine codex pool management UX

* feat(chatgpt-oauth): surface quota bars on provider pages

- add compact quota bars to Codex provider rows and provider detail

- fetch quota only for ready visible provider rows and ready detail aliases

- fix managed-member detail visibility and tighten provider locale copy
2026-03-27 09:35:57 +07:00

278 lines
7.5 KiB
Go

// pkg-helper is a root-privileged helper that listens on a Unix socket
// and executes apk add/del commands on behalf of the non-root app process.
// It is started by docker-entrypoint.sh before dropping privileges.
package main
import (
"bufio"
"encoding/json"
"fmt"
"log/slog"
"net"
"os"
"os/exec"
"os/signal"
"path/filepath"
"regexp"
"strings"
"syscall"
"time"
)
const socketPath = "/tmp/pkg.sock"
// goclawGID is the group ID of the goclaw process.
// Persist files and sockets are chowned to root:goclaw so the
// unprivileged app process (uid 1000, gid 1000) can read them.
const goclawGID = 1000
// validPkgName allows alphanumeric, hyphens, underscores, dots, @, / (scoped npm).
// Rejects names starting with - to prevent argument injection.
var validPkgName = regexp.MustCompile(`^[a-zA-Z0-9@][a-zA-Z0-9._+\-/@]*$`)
type request struct {
Action string `json:"action"`
Package string `json:"package"`
}
type response struct {
OK bool `json:"ok"`
Error string `json:"error,omitempty"`
}
func main() {
slog.Info("pkg-helper: starting", "socket", socketPath)
// Remove stale socket.
os.Remove(socketPath)
// Restrictive umask: socket created as 0660 (not default 0777).
oldMask := syscall.Umask(0117)
listener, err := net.Listen("unix", socketPath)
syscall.Umask(oldMask)
if err != nil {
slog.Error("pkg-helper: listen failed", "error", err)
os.Exit(1)
}
defer listener.Close()
// Socket permissions: owner root, group goclaw (gid 1000), mode 0660.
// Chown requires CAP_CHOWN; if missing (misconfigured container), warn but continue
// since umask already set restrictive permissions.
if os.Getuid() == 0 {
if err := os.Chown(socketPath, 0, goclawGID); err != nil {
slog.Warn("pkg-helper: chown socket failed (missing CAP_CHOWN?)", "error", err)
}
}
if err := os.Chmod(socketPath, 0660); err != nil {
slog.Warn("pkg-helper: chmod socket failed", "error", err)
}
// Ensure persist directory is writable by root (self-healing for upgrades).
ensurePersistDir()
// Graceful shutdown on SIGTERM/SIGINT.
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGTERM, syscall.SIGINT)
go func() {
<-sigCh
slog.Info("pkg-helper: shutting down")
listener.Close()
os.Remove(socketPath)
os.Exit(0)
}()
const maxConns = 3
sem := make(chan struct{}, maxConns)
slog.Info("pkg-helper: ready")
for {
conn, err := listener.Accept()
if err != nil {
break
}
select {
case sem <- struct{}{}:
go func(c net.Conn) {
defer func() { <-sem }()
c.SetDeadline(time.Now().Add(30 * time.Second)) //nolint:errcheck
handleConn(c)
}(conn)
default:
slog.Warn("pkg-helper: connection limit reached, rejecting")
conn.Close()
}
}
}
func handleConn(conn net.Conn) {
defer conn.Close()
scanner := bufio.NewScanner(conn)
encoder := json.NewEncoder(conn)
for scanner.Scan() {
var req request
if err := json.Unmarshal(scanner.Bytes(), &req); err != nil {
encoder.Encode(response{Error: "invalid json"}) //nolint:errcheck
continue
}
resp := handleRequest(req)
encoder.Encode(resp) //nolint:errcheck
}
}
func handleRequest(req request) response {
pkg := req.Package
if pkg == "" {
return response{Error: "package required"}
}
if !validPkgName.MatchString(pkg) {
return response{Error: "invalid package name"}
}
switch req.Action {
case "install":
return doInstall(pkg)
case "uninstall":
return doUninstall(pkg)
default:
return response{Error: fmt.Sprintf("unknown action: %s", req.Action)}
}
}
func doInstall(pkg string) response {
slog.Info("pkg-helper: installing", "package", pkg)
cmd := exec.Command("apk", "add", "--no-cache", pkg)
out, err := cmd.CombinedOutput()
if err != nil {
msg := fmt.Sprintf("%s: %v", strings.TrimSpace(string(out)), err)
slog.Error("pkg-helper: install failed", "package", pkg, "error", msg)
return response{Error: msg}
}
persistAdd(pkg)
slog.Info("pkg-helper: installed", "package", pkg)
return response{OK: true}
}
func doUninstall(pkg string) response {
slog.Info("pkg-helper: uninstalling", "package", pkg)
cmd := exec.Command("apk", "del", pkg)
out, err := cmd.CombinedOutput()
if err != nil {
msg := fmt.Sprintf("%s: %v", strings.TrimSpace(string(out)), err)
slog.Error("pkg-helper: uninstall failed", "package", pkg, "error", msg)
return response{Error: msg}
}
persistRemove(pkg)
slog.Info("pkg-helper: uninstalled", "package", pkg)
return response{OK: true}
}
// persistAdd appends a package name to the apk persist file (dedup check).
func persistAdd(pkg string) {
listFile := apkListFile()
// Check if already persisted (avoid duplicates).
if data, err := os.ReadFile(listFile); err == nil {
for line := range strings.SplitSeq(string(data), "\n") {
if strings.TrimSpace(line) == pkg {
return // already persisted
}
}
}
created := false
if _, err := os.Stat(listFile); os.IsNotExist(err) {
created = true
}
f, err := os.OpenFile(listFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0640)
if err != nil {
slog.Warn("pkg-helper: persist add failed", "error", err)
return
}
defer f.Close()
fmt.Fprintln(f, pkg)
// Ensure group ownership allows the goclaw process to read the file.
if created {
if err := os.Chown(listFile, 0, goclawGID); err != nil {
slog.Warn("pkg-helper: chown persist file failed", "file", listFile, "error", err)
}
}
}
// persistRemove removes a package name from the apk persist file.
// Uses write-to-temp-then-rename for atomic update (avoids truncation on disk-full).
func persistRemove(pkg string) {
listFile := apkListFile()
data, err := os.ReadFile(listFile)
if err != nil {
return
}
var kept []string
for line := range strings.SplitSeq(string(data), "\n") {
line = strings.TrimSpace(line)
if line != "" && line != pkg {
kept = append(kept, line)
}
}
tmpFile := listFile + ".tmp"
if err := os.WriteFile(tmpFile, []byte(strings.Join(kept, "\n")+"\n"), 0640); err != nil {
slog.Warn("pkg-helper: persist remove write failed", "error", err)
return
}
if err := os.Rename(tmpFile, listFile); err != nil {
slog.Warn("pkg-helper: persist remove rename failed", "error", err)
os.Remove(tmpFile) //nolint:errcheck
return
}
// Restore group ownership so the goclaw process (gid 1000) can read the file.
// Without this, the renamed file inherits root:root from the temp file,
// causing ListInstalledPackages to return nil for system packages.
if err := os.Chown(listFile, 0, goclawGID); err != nil {
slog.Warn("pkg-helper: chown persist file failed", "file", listFile, "error", err)
}
}
func apkListFile() string {
runtimeDir := os.Getenv("RUNTIME_DIR")
if runtimeDir == "" {
runtimeDir = "/app/data/.runtime"
}
return runtimeDir + "/apk-packages"
}
// ensurePersistDir ensures the apk persist file's parent directory is writable by root.
// On existing volumes the directory may be goclaw-owned (from older images); fix ownership
// using CAP_CHOWN so pkg-helper can create/write the persist file.
func ensurePersistDir() {
dir := filepath.Dir(apkListFile())
fi, err := os.Stat(dir)
if err != nil {
// Directory doesn't exist — entrypoint should have created it.
return
}
if !fi.IsDir() {
return
}
// Try to fix ownership to root:goclaw (gid 1000) if not already root-owned.
// CAP_CHOWN is available even when CAP_DAC_OVERRIDE is dropped.
if stat, ok := fi.Sys().(*syscall.Stat_t); ok && stat.Uid != 0 {
if err := os.Chown(dir, 0, goclawGID); err != nil {
slog.Warn("pkg-helper: cannot fix persist dir ownership", "dir", dir, "error", err)
} else {
os.Chmod(dir, 0750) //nolint:errcheck
slog.Info("pkg-helper: fixed persist dir ownership", "dir", dir)
}
}
}