Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 08aa0d69b6 | |||
| 81c2d2e6df | |||
| d80854deae | |||
| 9e5d805f5a | |||
| 4d5e6e2512 |
@@ -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
@@ -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())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
+25
-1
@@ -44,7 +44,27 @@ 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 \
|
||||||
@@ -74,6 +94,10 @@ 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);
|
||||||
|
if (process.env.FOUNDRY_CODEX_SOCKET) {
|
||||||
|
const exec = base + "/projects/" + encodeURIComponent(project) + "/codex/exec";
|
||||||
|
assert.equal((await fetch(exec, {method: "POST"})).status, 401);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
verify().catch(() => { console.error("Foundry readiness or anonymous access check failed"); process.exit(1); });
|
verify().catch(() => { console.error("Foundry readiness or anonymous access check failed"); process.exit(1); });
|
||||||
'
|
'
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user