From 9e5d805f5af9d5b005fbbe63008e7511bd13b0a7 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Thu, 5 Nov 2026 02:29:49 +0800 Subject: [PATCH] Add project-scoped Codex execution over a private socket --- README.md | 32 ++++++++ cmd/foundry/main.go | 13 +++- deploy/codex-worker.sh | 23 ++++++ deploy/release.sh | 15 +++- internal/codex/http.go | 143 ++++++++++++++++++++++++++++++++++++ internal/codex/http_test.go | 69 +++++++++++++++++ internal/codex/worker.go | 134 +++++++++++++++++++++++++++++++++ 7 files changed, 426 insertions(+), 3 deletions(-) create mode 100644 deploy/codex-worker.sh create mode 100644 internal/codex/http.go create mode 100644 internal/codex/http_test.go create mode 100644 internal/codex/worker.go diff --git a/README.md b/README.md index 856d3f8..017cc29 100644 --- a/README.md +++ b/README.md @@ -28,3 +28,35 @@ Google OAuth 的授权回调地址为 `https://api.foundry.entroia.com/auth/goog ## 验收 用同一个 Google 测试账号分别登录两个项目,确认用户 ID 不同。用 A 的会话请求 B 的 `me` 应返回 401。验证兑换码重放被拒绝,并确认退出 A 后 B 的会话仍有效。 + +## Codex execution + +When enabled, `POST /projects/{project}/codex/exec` accepts a JSON object +containing only a nonempty `prompt` string and returns `{"result":"…"}`. +Use `Authorization: Bearer ` with a dedicated 32-byte random token +encoded as 64 hex characters. Ordinary OAuth sessions cannot execute Codex. +Missing, unknown and revoked tokens return 401. A valid token for a different +project returns 403. Requests are limited to 32 KiB, CLI output to 512 KiB, +and each execution to 90 seconds. The worker accepts one execution at a time +and returns 429 when busy. + +On the deployment host, provision `/opt/endoleap-infra/foundry/codex` with: + +- `grants/projects.json` containing a JSON object mapping lowercase hex SHA-256 + digests of the **decoded** token bytes to configured project names. Use mode + 0700 for the directory and 0600 for the file, readable by UID 1000. Rotate or + revoke tokens by atomically replacing the file within the mounted directory. + A revocation applies to subsequent requests. +- `home` owned by UID 1000 with an isolated Codex login in `auth.json`. + Keep this home dedicated to the worker. Supply the login only and keep user + configuration, MCP servers, skills, and other workload files out of it. + +Start the single worker with `bash deploy/codex-worker.sh /absolute/path/to/foundry` +using the Linux binary built by ces-control. To replace the worker, stop and remove +the existing `foundry-codex-worker` container first. The next normal Foundry +deployment mounts the worker socket and grants directory read-only. The worker +runs the installed Codex CLI with fixed read-only, no-approval arguments and +disables command execution tools. It receives prompts via stdin and returns +the final agent message. The worker has no published port and Foundry receives +neither the Codex home nor the Docker socket. Keep both containers on the same +host for Unix socket access. diff --git a/cmd/foundry/main.go b/cmd/foundry/main.go index b354ce0..0eac158 100644 --- a/cmd/foundry/main.go +++ b/cmd/foundry/main.go @@ -10,6 +10,7 @@ import ( "time" "gitea.entroia.com/Zayden-Jung/foundry-core/internal/auth" + "gitea.entroia.com/Zayden-Jung/foundry-core/internal/codex" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/stdlib" ) @@ -22,6 +23,9 @@ func env(key, fallback string) string { } func main() { + if len(os.Args) == 2 && os.Args[1] == "codex-worker" { + log.Fatal(codex.ServeWorker("/run/foundry-codex/worker.sock")) + } origin, err := url.Parse(os.Getenv("PUBLIC_ORIGIN")) if err != nil || origin.Scheme != "https" || origin.Hostname() == "" || origin.User != nil || (origin.Path != "" && origin.Path != "/") || origin.RawQuery != "" || origin.Fragment != "" { @@ -45,10 +49,15 @@ func main() { if err != nil { log.Fatal(err) } + mux := http.NewServeMux() + if err := codex.Register(mux, callbacks, os.Getenv("FOUNDRY_CODEX_SOCKET"), os.Getenv("FOUNDRY_CODEX_GRANTS")); err != nil { + log.Fatal(err) + } + mux.Handle("/", handler) server := &http.Server{ Addr: net.JoinHostPort(env("HOST", "127.0.0.1"), env("PORT", "8080")), - Handler: handler, ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 10 * time.Second, - WriteTimeout: 30 * time.Second, IdleTimeout: 60 * time.Second, + Handler: mux, ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 10 * time.Second, + WriteTimeout: 110 * time.Second, IdleTimeout: 60 * time.Second, } log.Fatal(server.ListenAndServe()) } diff --git a/deploy/codex-worker.sh b/deploy/codex-worker.sh new file mode 100644 index 0000000..8e39da9 --- /dev/null +++ b/deploy/codex-worker.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +set -euo pipefail + +base=/opt/endoleap-infra +dir=$base/foundry/codex +binary=$(realpath "$1") +test -x "$binary" +test -s "$dir/home/auth.json" +test -s "$dir/grants/projects.json" +install -d -m 700 -o 1000 -g 1000 "$dir/run" + +# The host operator supplies an isolated Codex home and starts a single worker. +# The Docker default bridge supplies egress independently of Foundry's network. +docker run -d --name foundry-codex-worker --restart unless-stopped --init \ + --network bridge --user 1000:1000 --cap-drop ALL --security-opt no-new-privileges \ + --read-only --pids-limit 128 --memory 384m --tmpfs /tmp:rw,nosuid,nodev \ + --tmpfs /work:ro,nosuid,nodev --workdir /work \ + --mount "type=bind,source=$binary,target=/opt/foundry,readonly" \ + --mount "type=bind,source=$base/codex-cli,target=/opt/codex,readonly" \ + --mount "type=bind,source=$dir/home,target=/home/node/.codex" \ + --mount "type=bind,source=$dir/run,target=/run/foundry-codex" \ + --mount "type=bind,source=/etc/ssl/certs/ca-certificates.crt,target=/etc/ssl/certs/ca-certificates.crt,readonly" \ + node:24-bookworm-slim /opt/foundry codex-worker diff --git a/deploy/release.sh b/deploy/release.sh index 14260ae..fed1fd4 100644 --- a/deploy/release.sh +++ b/deploy/release.sh @@ -44,7 +44,20 @@ docker network inspect "$network" >/dev/null release=$(mktemp -d "$foundry/release.XXXXXXXX") install -m 755 "$1" "$release/foundry" export HOST=0.0.0.0 PORT=8080 PUBLIC_ORIGIN="$origin" -docker run -d --name "$candidate" --network "$network" \ +codex_mounts=() +codex_dir=$foundry/codex +if [[ -d "$codex_dir" ]]; then + test -S "$codex_dir/run/worker.sock" + test -s "$codex_dir/grants/projects.json" + codex_mounts=( + --mount "type=bind,source=$codex_dir/run,target=/run/foundry-codex,readonly" + --mount "type=bind,source=$codex_dir/grants,target=/etc/foundry-codex,readonly" + -e FOUNDRY_CODEX_SOCKET=/run/foundry-codex/worker.sock + -e FOUNDRY_CODEX_GRANTS=/etc/foundry-codex/projects.json + ) +fi + +docker run -d --name "$candidate" "${codex_mounts[@]}" --network "$network" \ --env-file "$foundry/database.env" -e HOST -e PORT -e PUBLIC_ORIGIN \ -e GOOGLE_CLIENT_ID -e GOOGLE_CLIENT_SECRET -e FOUNDRY_PROJECTS \ --user 1000:1000 --cap-drop ALL --security-opt no-new-privileges \ diff --git a/internal/codex/http.go b/internal/codex/http.go new file mode 100644 index 0000000..45c93a0 --- /dev/null +++ b/internal/codex/http.go @@ -0,0 +1,143 @@ +package codex + +import ( + "bytes" + "context" + "crypto/sha256" + "crypto/subtle" + "encoding/hex" + "encoding/json" + "errors" + "io" + "net" + "net/http" + "os" + "strings" + "time" +) + +const ( + maxRequest = 32 << 10 + maxOutput = 512 << 10 + timeout = 90 * time.Second +) + +type task struct { + Prompt string `json:"prompt"` +} + +func writeJSON(w http.ResponseWriter, status int, value any) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(value) +} + +func fail(w http.ResponseWriter, status int, code string) { + writeJSON(w, status, map[string]string{"error": code}) +} + +func readTask(w http.ResponseWriter, r *http.Request, limit int64) (task, bool) { + var input task + decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, limit)) + decoder.DisallowUnknownFields() + err := decoder.Decode(&input) + if err == nil { + err = decoder.Decode(new(any)) + if err == io.EOF && strings.TrimSpace(input.Prompt) != "" { + return input, true + } + } + var tooLarge *http.MaxBytesError + if errors.As(err, &tooLarge) { + fail(w, 413, "request_too_large") + } else { + fail(w, 400, "invalid_task") + } + return task{}, false +} + +// Register enables the public endpoint only when both configuration files are supplied. +// Each credential digest belongs to exactly one project. +func Register(mux *http.ServeMux, projects map[string]string, socket, grantsFile string) error { + if socket == "" && grantsFile == "" { + return nil + } + if socket == "" || grantsFile == "" { + return errors.New("Codex requires both a socket and a grants file") + } + transport := &http.Transport{ + DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { + return (&net.Dialer{}).DialContext(ctx, "unix", socket) + }, + DisableKeepAlives: true, + } + client := &http.Client{ + Transport: transport, + Timeout: timeout + 2*time.Second, + CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }, + } + mux.HandleFunc("POST /projects/{project}/codex/exec", func(w http.ResponseWriter, r *http.Request) { + scheme, token, _ := strings.Cut(r.Header.Get("Authorization"), " ") + key, err := hex.DecodeString(token) + if !strings.EqualFold(scheme, "Bearer") || err != nil || len(key) != 32 { + w.Header().Set("WWW-Authenticate", "Bearer") + fail(w, 401, "unauthorized") + return + } + data, err := os.ReadFile(grantsFile) + var grants map[string]string + if err != nil || json.Unmarshal(data, &grants) != nil { + fail(w, 503, "codex_unavailable") + return + } + hash := sha256.Sum256(key) + var scope string + for digest, project := range grants { + value, err := hex.DecodeString(digest) + if err == nil && subtle.ConstantTimeCompare(hash[:], value) == 1 { + scope = project + } + } + if scope == "" { + w.Header().Set("WWW-Authenticate", "Bearer") + fail(w, 401, "unauthorized") + return + } + project := r.PathValue("project") + if _, exists := projects[project]; !exists || scope != project { + fail(w, 403, "forbidden") + return + } + input, ok := readTask(w, r, maxRequest) + if !ok { + return + } + body, _ := json.Marshal(input) + req, err := http.NewRequestWithContext(r.Context(), http.MethodPost, "http://codex/exec", bytes.NewReader(body)) + if err != nil { + fail(w, 502, "codex_unavailable") + return + } + resp, err := client.Do(req) + if err != nil { + if errors.Is(err, context.DeadlineExceeded) { + fail(w, 504, "codex_timeout") + } else { + fail(w, 502, "codex_unavailable") + } + return + } + defer resp.Body.Close() + data, err = io.ReadAll(io.LimitReader(resp.Body, maxOutput+1)) + if err != nil || len(data) > maxOutput || !json.Valid(data) { + fail(w, 502, "invalid_codex_response") + return + } + if retry := resp.Header.Get("Retry-After"); retry != "" { + w.Header().Set("Retry-After", retry) + } + writeJSON(w, resp.StatusCode, json.RawMessage(data)) + }) + return nil +} diff --git a/internal/codex/http_test.go b/internal/codex/http_test.go new file mode 100644 index 0000000..718a06c --- /dev/null +++ b/internal/codex/http_test.go @@ -0,0 +1,69 @@ +package codex + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "net" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestProjectAuthorizationAndForwarding(t *testing.T) { + dir := t.TempDir() + socket := filepath.Join(dir, "worker.sock") + listener, err := net.Listen("unix", socket) + if err != nil { + t.Fatal(err) + } + worker := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/exec" || r.Header.Get("Authorization") != "" { + fail(w, 500, "unexpected_worker_request") + return + } + input, ok := readTask(w, r, 6*maxRequest) + if ok { + writeJSON(w, 200, map[string]string{"result": input.Prompt}) + } + })} + go worker.Serve(listener) + t.Cleanup(func() { _ = worker.Close() }) + + key := make([]byte, 32) + token := hex.EncodeToString(key) + grants := filepath.Join(dir, "grants.json") + writeGrants := func(data string) { + t.Helper() + if err := os.WriteFile(grants, []byte(data), 0600); err != nil { + t.Fatal(err) + } + } + writeGrants(fmt.Sprintf(`{"%x":"notes"}`, sha256.Sum256(key))) + mux := http.NewServeMux() + if err := Register(mux, map[string]string{"notes": "", "board": ""}, socket, grants); err != nil { + t.Fatal(err) + } + check := func(project, credential, body string, status int, response string) { + t.Helper() + r := httptest.NewRequest("POST", "/projects/"+project+"/codex/exec", strings.NewReader(body)) + if credential != "" { + r.Header.Set("Authorization", "Bearer "+credential) + } + w := httptest.NewRecorder() + mux.ServeHTTP(w, r) + if w.Code != status || !strings.Contains(w.Body.String(), response) { + t.Fatalf("got %d %s, want %d containing %q", w.Code, w.Body.String(), status, response) + } + } + check("notes", "", `{"prompt":"hello"}`, 401, "unauthorized") + check("board", token, `{"prompt":"hello"}`, 403, "forbidden") + check("notes", token, `{"prompt":"hello","sandbox":"danger"}`, 400, "invalid_task") + check("notes", token, `{"prompt":"`+strings.Repeat("x", maxRequest)+`"}`, 413, "request_too_large") + check("notes", token, `{"prompt":"hello"}`, 200, `"result":"hello"`) + writeGrants("{}") + check("notes", token, `{"prompt":"hello"}`, 401, "unauthorized") +} diff --git a/internal/codex/worker.go b/internal/codex/worker.go new file mode 100644 index 0000000..116f1dd --- /dev/null +++ b/internal/codex/worker.go @@ -0,0 +1,134 @@ +package codex + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "net" + "net/http" + "os" + "os/exec" + "strings" + "syscall" + "time" +) + +const executable = "/opt/codex/node_modules/.bin/codex" + +var errOutputLimit = errors.New("Codex output limit exceeded") + +type outputBuffer struct { + bytes.Buffer + exceeded bool +} + +func (b *outputBuffer) Write(p []byte) (int, error) { + if len(p) > maxOutput-b.Len() { + b.exceeded = true + return 0, errOutputLimit + } + return b.Buffer.Write(p) +} + +func ServeWorker(socket string) error { + syscall.Umask(0077) + if err := os.Remove(socket); err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + listener, err := net.Listen("unix", socket) + if err != nil { + return err + } + defer listener.Close() + if err := os.Chmod(socket, 0600); err != nil { + return err + } + slot := make(chan struct{}, 1) + mux := http.NewServeMux() + mux.HandleFunc("POST /exec", func(w http.ResponseWriter, r *http.Request) { + select { + case slot <- struct{}{}: + defer func() { <-slot }() + default: + w.Header().Set("Retry-After", "5") + fail(w, 429, "codex_busy") + return + } + input, ok := readTask(w, r, 6*maxRequest) + if ok { + execute(w, r, input.Prompt) + } + }) + server := &http.Server{Handler: mux, ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 10 * time.Second, WriteTimeout: timeout + 15*time.Second} + return server.Serve(listener) +} + +func execute(w http.ResponseWriter, r *http.Request, prompt string) { + ctx, cancel := context.WithTimeout(r.Context(), timeout) + defer cancel() + cmd := exec.CommandContext(ctx, executable, "--ask-for-approval", "never", + "--disable", "shell_tool", "--disable", "apps", "--disable", "plugins", + "--disable", "multi_agent", "--disable", "hooks", "--disable", "memories", + "-c", `web_search="disabled"`, "exec", "--json", "--sandbox", "read-only", "--skip-git-repo-check", "--ephemeral", "-") + cmd.Dir = "/work" + cmd.Env = []string{"PATH=/usr/local/bin:/usr/bin:/bin", "HOME=/home/node", "CODEX_HOME=/home/node/.codex", "SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt"} + cmd.Stdin = strings.NewReader(prompt) + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + cmd.Cancel = func() error { + err := syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) + if errors.Is(err, syscall.ESRCH) { + return os.ErrProcessDone + } + return err + } + cmd.WaitDelay = 2 * time.Second + var output outputBuffer + cmd.Stdout, cmd.Stderr = &output, io.Discard + err := cmd.Run() + switch { + case output.exceeded: + fail(w, 502, "codex_output_too_large") + case errors.Is(ctx.Err(), context.DeadlineExceeded): + fail(w, 504, "codex_timeout") + case err != nil: + fail(w, 502, "codex_failed") + default: + var result string + var completed bool + for _, line := range bytes.Split(bytes.TrimSpace(output.Bytes()), []byte("\n")) { + var event struct { + Type string `json:"type"` + Item struct { + Type string `json:"type"` + Text string `json:"text"` + } `json:"item"` + } + if json.Unmarshal(line, &event) != nil { + fail(w, 502, "invalid_codex_response") + return + } + if event.Type == "item.completed" && event.Item.Type == "agent_message" { + result = event.Item.Text + } + if event.Type == "turn.failed" || event.Type == "error" { + fail(w, 502, "codex_failed") + return + } + if event.Type == "turn.completed" { + completed = true + } + } + if !completed || result == "" { + fail(w, 502, "codex_failed") + return + } + response, _ := json.Marshal(map[string]string{"result": result}) + if len(response) >= maxOutput { + fail(w, 502, "codex_output_too_large") + return + } + writeJSON(w, 200, json.RawMessage(response)) + } +}