Compare commits

...

9 Commits

Author SHA1 Message Date
codex-bot 08aa0d69b6 Set worker directory ownership by numeric container UID
Foundry Go checks / check (push) Successful in 1m30s
Foundry Go checks / check (pull_request) Successful in 1m34s
2026-11-05 11:40:09 +08:00
codex-bot 81c2d2e6df Enforce the output limit on subprocess stdout copies
Foundry Go checks / check (push) Successful in 1m52s
Foundry Go checks / check (pull_request) Successful in 1m28s
2026-11-05 11:36:54 +08:00
codex-bot d80854deae Start the configured Codex worker during Foundry deployment 2026-11-05 11:36:46 +08:00
codex-bot 9e5d805f5a 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
2026-11-05 02:29:49 +08:00
Zayden-Jung 4d5e6e2512 Merge pull request '接受 Actions Variables 中格式化的项目回调 JSON' (#3) from codex/foundry-project-config into main
Foundry Go checks / check (push) Successful in 1m44s
Deploy Foundry Core / deploy (push) Successful in 2m18s
Reviewed-on: #3
2026-11-04 17:51:55 +00:00
codex-bot cc8ddba7aa Accept formatted Foundry project configuration from Actions
Foundry Go checks / check (pull_request) Successful in 1m21s
Foundry Go checks / check (push) Successful in 1m22s
2026-11-05 01:44:44 +08:00
Zayden-Jung c7ec5f7eb2 Merge pull request '部署检查只验证 Foundry 后端就绪' (#2) from codex/foundry-backend-smoke into main
Foundry Go checks / check (push) Successful in 1m23s
Deploy Foundry Core / deploy (push) Failing after 1m24s
Reviewed-on: #2
2026-11-04 17:03:24 +00:00
codex-bot 33719a549b Keep deployment smoke checks backend-only
Foundry Go checks / check (pull_request) Successful in 1m22s
Foundry Go checks / check (push) Successful in 1m21s
2026-11-05 00:57:53 +08:00
Zayden-Jung 17cec9ff06 Merge pull request '从 main 部署 Foundry Core 并注入 OAuth 配置' (#1) from codex/foundry-actions-deploy into main
Foundry Go checks / check (push) Successful in 1m23s
Deploy Foundry Core / deploy (push) Failing after 1m47s
Reviewed-on: #1
2026-11-04 16:48:52 +00:00
8 changed files with 467 additions and 15 deletions
+1 -1
View File
@@ -31,7 +31,7 @@ jobs:
test -z "$(gofmt -l cmd internal)" test -z "$(gofmt -l cmd internal)"
go test -mod=readonly ./... go test -mod=readonly ./...
CGO_ENABLED=0 go build -mod=readonly -o bin/foundry ./cmd/foundry CGO_ENABLED=0 go build -mod=readonly -o bin/foundry ./cmd/foundry
- name: Deploy and verify OAuth entry - name: Deploy and verify backend readiness
env: env:
GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }} GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }}
GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }} GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }}
+34 -1
View File
@@ -14,7 +14,7 @@
在仓库的 Actions Secrets 配置 `GOOGLE_CLIENT_ID` 和 `GOOGLE_CLIENT_SECRET`,在 Actions Variables 配置 `FOUNDRY_PROJECTS`。变量值为 JSON 对象,例如 `{"prompt":"http://localhost:17321/auth/callback"}`。部署读取服务器上预先配置的 `/opt/endoleap-infra/foundry/database.env`,OAuth 值由 Actions 在启动容器时注入。 在仓库的 Actions Secrets 配置 `GOOGLE_CLIENT_ID` 和 `GOOGLE_CLIENT_SECRET`,在 Actions Variables 配置 `FOUNDRY_PROJECTS`。变量值为 JSON 对象,例如 `{"prompt":"http://localhost:17321/auth/callback"}`。部署读取服务器上预先配置的 `/opt/endoleap-infra/foundry/database.env`,OAuth 值由 Actions 在启动容器时注入。
Google OAuth 的授权回调地址为 `https://api.foundry.entroia.com/auth/google/callback`。工作流会验证数据库就绪、匿名请求返回 401、Google 授权跳转参数和 HTTPS 就绪。完整登录还需在浏览器用 Google 测试账号走完回调、兑换和会话验证。 Google OAuth 的授权回调地址为 `https://api.foundry.entroia.com/auth/google/callback`。工作流会验证数据库就绪、匿名请求返回 401 和 HTTPS 就绪。前端接入后,可在浏览器用 Google 测试账号验证完整登录、回调、兑换和会话。
## 前端接入 ## 前端接入
@@ -28,3 +28,36 @@ Google OAuth 的授权回调地址为 `https://api.foundry.entroia.com/auth/goog
## 验收 ## 验收
用同一个 Google 测试账号分别登录两个项目,确认用户 ID 不同。用 A 的会话请求 B 的 `me` 应返回 401。验证兑换码重放被拒绝,并确认退出 A 后 B 的会话仍有效。 用同一个 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.
With that directory configured, the next normal Foundry deployment starts the
worker using the Linux binary built by ces-control and mounts the worker socket
and grants directory read-only. To replace an existing worker, stop and remove
its container, then run `bash deploy/codex-worker.sh /absolute/path/to/foundry`
with the desired ces-control build. 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" "time"
"gitea.entroia.com/Zayden-Jung/foundry-core/internal/auth" "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"
"github.com/jackc/pgx/v5/stdlib" "github.com/jackc/pgx/v5/stdlib"
) )
@@ -22,6 +23,9 @@ func env(key, fallback string) string {
} }
func main() { 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")) origin, err := url.Parse(os.Getenv("PUBLIC_ORIGIN"))
if err != nil || origin.Scheme != "https" || origin.Hostname() == "" || origin.User != nil || if err != nil || origin.Scheme != "https" || origin.Hostname() == "" || origin.User != nil ||
(origin.Path != "" && origin.Path != "/") || origin.RawQuery != "" || origin.Fragment != "" { (origin.Path != "" && origin.Path != "/") || origin.RawQuery != "" || origin.Fragment != "" {
@@ -45,10 +49,15 @@ func main() {
if err != nil { if err != nil {
log.Fatal(err) 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{ server := &http.Server{
Addr: net.JoinHostPort(env("HOST", "127.0.0.1"), env("PORT", "8080")), Addr: net.JoinHostPort(env("HOST", "127.0.0.1"), env("PORT", "8080")),
Handler: handler, ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 10 * time.Second, Handler: mux, ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 10 * time.Second,
WriteTimeout: 30 * time.Second, IdleTimeout: 60 * time.Second, WriteTimeout: 110 * time.Second, IdleTimeout: 60 * time.Second,
} }
log.Fatal(server.ListenAndServe()) log.Fatal(server.ListenAndServe())
} }
+24
View File
@@ -0,0 +1,24 @@
#!/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 "$dir/run"
chown 1000: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
+51 -11
View File
@@ -8,20 +8,63 @@ origin=https://api.foundry.entroia.com
candidate=foundry-core-candidate candidate=foundry-core-candidate
network=endoleap-dev_default network=endoleap-dev_default
for name in GOOGLE_CLIENT_ID GOOGLE_CLIENT_SECRET FOUNDRY_PROJECTS; do for name in GOOGLE_CLIENT_ID GOOGLE_CLIENT_SECRET; do
value=${!name-} value=${!name-}
if [[ -z "$value" || "$value" == *$'\n'* || "$value" == *$'\r'* ]]; then if [[ -z "$value" || "$value" == *$'\n'* || "$value" == *$'\r'* ]]; then
printf 'Missing or invalid %s\n' "$name" >&2 printf 'Missing or invalid %s\n' "$name" >&2
exit 1 exit 1
fi fi
done done
# Accept formatted JSON from Actions Variables and never print its contents.
FOUNDRY_PROJECTS=$(python3 - <<'PYCONFIG'
import json
import os
import sys
raw = os.environ.get("FOUNDRY_PROJECTS", "")
if not raw.strip():
sys.exit("FOUNDRY_PROJECTS was not injected from Actions Variables")
try:
projects = json.loads(raw)
except ValueError:
sys.exit("FOUNDRY_PROJECTS must be valid JSON")
if not isinstance(projects, dict) or not projects or any(
not isinstance(callback, str) or not callback.strip()
for callback in projects.values()
):
sys.exit("FOUNDRY_PROJECTS must map project names to callback URLs")
print(json.dumps(projects, separators=(",", ":")))
PYCONFIG
)
export FOUNDRY_PROJECTS
test -s "$foundry/database.env" test -s "$foundry/database.env"
docker network inspect "$network" >/dev/null docker network inspect "$network" >/dev/null
release=$(mktemp -d "$foundry/release.XXXXXXXX") release=$(mktemp -d "$foundry/release.XXXXXXXX")
install -m 755 "$1" "$release/foundry" install -m 755 "$1" "$release/foundry"
export HOST=0.0.0.0 PORT=8080 PUBLIC_ORIGIN="$origin" 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
if ! docker inspect -f '{{.State.Running}}' foundry-codex-worker 2>/dev/null | grep -qx true; then
bash "$(dirname "$0")/codex-worker.sh" "$release/foundry" >/dev/null
fi
for attempt in {1..20}; do
[[ ! -S "$codex_dir/run/worker.sock" ]] || break
sleep 0.25
done
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 \ --env-file "$foundry/database.env" -e HOST -e PORT -e PUBLIC_ORIGIN \
-e GOOGLE_CLIENT_ID -e GOOGLE_CLIENT_SECRET -e FOUNDRY_PROJECTS \ -e GOOGLE_CLIENT_ID -e GOOGLE_CLIENT_SECRET -e FOUNDRY_PROJECTS \
--user 1000:1000 --cap-drop ALL --security-opt no-new-privileges \ --user 1000:1000 --cap-drop ALL --security-opt no-new-privileges \
@@ -51,15 +94,12 @@ docker exec "$candidate" node -e '
const project = Object.keys(JSON.parse(process.env.FOUNDRY_PROJECTS))[0]; const project = Object.keys(JSON.parse(process.env.FOUNDRY_PROJECTS))[0];
const route = base + "/projects/" + encodeURIComponent(project) + "/auth"; const route = base + "/projects/" + encodeURIComponent(project) + "/auth";
assert.equal((await fetch(route + "/me")).status, 401); assert.equal((await fetch(route + "/me")).status, 401);
const challenge = require("node:crypto").randomBytes(32).toString("base64url"); if (process.env.FOUNDRY_CODEX_SOCKET) {
const start = await fetch(route + "/google?challenge=" + challenge, {redirect: "manual"}); const exec = base + "/projects/" + encodeURIComponent(project) + "/codex/exec";
assert.ok([302, 303].includes(start.status)); assert.equal((await fetch(exec, {method: "POST"})).status, 401);
const location = new URL(start.headers.get("location"));
assert.equal(location.hostname, "accounts.google.com");
assert.equal(location.searchParams.get("client_id"), process.env.GOOGLE_CLIENT_ID);
assert.equal(location.searchParams.get("redirect_uri"), process.env.PUBLIC_ORIGIN + "/auth/google/callback");
} }
verify().catch(() => { console.error("Foundry readiness or OAuth entry check failed"); process.exit(1); }); }
verify().catch(() => { console.error("Foundry readiness or anonymous access check failed"); process.exit(1); });
' '
# Keep the previous running container until the new candidate has passed checks. # Keep the previous running container until the new candidate has passed checks.
@@ -70,4 +110,4 @@ docker rename "$candidate" "$live"
docker update --restart unless-stopped "$live" >/dev/null docker update --restart unless-stopped "$live" >/dev/null
bash "$(dirname "$0")/route.sh" "$live" bash "$(dirname "$0")/route.sh" "$live"
trap - EXIT trap - EXIT
printf 'Foundry readiness, unauthenticated rejection and Google redirect verified.\n' printf 'Foundry readiness and unauthenticated rejection verified.\n'
+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 {
buffer bytes.Buffer
exceeded bool
}
func (b *outputBuffer) Write(p []byte) (int, error) {
if len(p) > maxOutput-b.buffer.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.buffer.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))
}
}