Add project-scoped Codex execution over a private socket
Foundry Go checks / check (push) Successful in 1m35s
Foundry Go checks / check (pull_request) Successful in 1m22s

This commit is contained in:
2026-11-05 02:29:49 +08:00
parent 4d5e6e2512
commit 9e5d805f5a
7 changed files with 426 additions and 3 deletions
+32
View File
@@ -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 <token>` 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.
+11 -2
View File
@@ -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())
}
+23
View File
@@ -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
+14 -1
View File
@@ -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 \
+143
View File
@@ -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
}
+69
View File
@@ -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")
}
+134
View File
@@ -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))
}
}