سرور اجرای کدکس بدون هیچ احراز هویتی به هر کلاینتی که به درگاهش برسد پاسخ می‌دهد. در نسخه‌ی 0.158.0 منتشرشده در ۲۸ سپتامبر ۲۰۲۶ شش پرچم --ws-auth اضافه شد که این درگاه را با یک توکن ساده یا یک JWT امضاشده می‌بندد. در این نوشته هر دو حالت را بالا می‌آوریم، هشت پاسخ واقعی ۴۰۱ می‌سنجیم و یک فرایند زنده را تا پایان می‌بریم.

سرور پیش‌فرض روی چه چیزی گوش می‌دهد

بدون هیچ آرگومانی، سرور اجرای کدکس روی یک درگاه تصادفی گوش می‌دهد و نشانی را چاپ می‌کند. روی این ماشین درگاه 44697 انتخاب شد و یک اتصال از نشانی رابط شبکه‌ی میزبان رد شد. سوکت فقط روی 127.0.0.1 شنیده می‌شد، یعنی پیش‌فرض روی لوپ‌بک بسته است.

# نسخه‌ی این نوشته را اول ببینید تا بدانید اعداد با کدام ساختار سنجیده شده‌اند
$ codex --version
codex-cli 0.158.0
# بدون هیچ آرگومانی، درگاه تصادفی روی لوپ‌بک باز می‌شود و نشانی را چاپ می‌کند
$ codex exec-server
ws://127.0.0.1:44697
$ ss -tlnp | grep codex
LISTEN 0  128  127.0.0.1:44697  0.0.0.0:*  users:(("codex",pid=1119924,fd=10))
# آزمون اصلی: از نشانی رابط شبکه‌ی میزبان به همان درگاه نمی‌رسیم
$ bash -c 'echo > /dev/tcp/185.202.113.241/44697'
Connection refused

آخرین خط همان چیزی است که پیش‌فرض امن را می‌سازد. تا وقتی نشانی روی لوپ‌بک است، هر کلاینتی که به آن برسد از همان ماشین است. با یک پرچم این بسته باز می‌شود و دیگر هیچ قفلی روی درگاه نمی‌ماند.

$ codex exec-server --listen ws://0.0.0.0:45861
ws://0.0.0.0:45861

همین یک پرچم تمام دفاعی را که پیش‌فرض داشت حذف می‌کند. از این لحظه هر کسی که به درگاه برسد می‌تواند دستور اجرا کند، پس یا باید احراز هویت را روشن کنید یا نباید روی همه‌ی رابط‌ها گوش بدهید.

حالت نخست: توکن ساده

حالت capability-token یک رشته‌ی راز است که در هدر Authorization می‌آید. یکی از دو منبع توکن الزامی است، چون سرور باید بداند توکن درست را با چه چیزی مقایسه کند. یا فایل را می‌خواند، یا هش آن را می‌گیرد. دادن هر دو خطاست و خطایش صریح است.

$ codex exec-server --ws-auth capability-token
Error: `--ws-token-file` or `--ws-token-sha256` is required when `--ws-auth capability-token` is set

$ codex exec-server --ws-auth capability-token --ws-token-file ./cap.token --ws-token-sha256 abc
Error: `--ws-token-file` and `--ws-token-sha256` are mutually exclusive

$ codex exec-server --ws-auth capability-token --ws-token-sha256 0123456789abcdef
Error: --ws-token-sha256 must be a 64-character hex SHA-256 digest

$ codex exec-server --ws-auth capability-token --ws-token-file ./nope.token
Error: failed to read websocket auth secret ./nope.token: No such file or directory (os error 2)

هر چهار خطا پیش از باز شدن درگاه و پیش از پذیرش هر اتصالی می‌آیند. خطای ۶۴ نویسه‌ای روی طول هش ارزش دارد، چون هش کوتاه‌تر از ۶۴ نویسه دیگر یک هش SHA-256 نیست و یک اشتباه تایپی به‌جای خطای مبهم، خطای دقیق می‌دهد.

برای اجرای واقعی یک فایل توکن با مجوز محدود می‌سازیم. فایل توکن برای همین کار است، پس دسترسی ۶۰۰ یعنی فقط صاحب فایل می‌تواند بخواندش.

$ printf 'hx-cap-token-0123456789abcdef' > cap.token
$ chmod 600 cap.token
$ codex exec-server --listen ws://127.0.0.1:45752 \
    --ws-auth capability-token --ws-token-file ./cap.token
ws://127.0.0.1:45752

حالا همان درگاه را می‌سنجیم. اول با توکن غلط، که باید رد شود، و بعد با توکن درست، که باید پذیرفته شود. تفاوت فقط یک عدد است: کد ۴۰۱ در برابر یک پاسخ JSON-RPC.

$ python3 wsprobe.py ws://127.0.0.1:45752 capability "hx-cap-token-WRONG-TOKEN"
HANDSHAKE=rejected  InvalidStatus: server rejected WebSocket connection: HTTP 401

$ python3 wsprobe.py ws://127.0.0.1:45752 capability "hx-cap-token-0123456789abcdef"
HANDSHAKE=accepted  first-frame: {"error":{"code":-32601,"message":"exec-server stub does not implement `__probe__` yet"},"id":1}

خطای -32601 در پاسخ پذیرفته‌شده تکرارشونده است و به معنی خرابی نیست. کلاینت یک متد ساختگی به نام __probe__ فرستاد و سرور گفت چنین متدی ندارد. یعنی درگاه باز شده و احراز هویت عبور کرده، و همان خطا ثابت می‌کند که درگاه پیش از احراز هویت همین پاسخ را می‌داد.

حالت دوم: JWT امضاشده

حالت signed-bearer-token یک JWT با الگوریتم HS256 می‌خواهد. این حالت یک کف اندازه دارد که در هیچ متنی نیامده و فقط خود سرور آن را می‌گوید: فایل راز مشترک باید دست‌کم ۳۲ بایت باشد.

$ codex exec-server --ws-auth signed-bearer-token --ws-shared-secret-file ./hs.secret
Error: signed websocket bearer secret ./hs.secret must be at least 32 bytes

$ codex exec-server --ws-auth signed-bearer-token --ws-issuer codex-infra --ws-audience devbox
Error: `--ws-shared-secret-file` is required when `--ws-auth signed-bearer-token` is set

نخستین خطا سه بار پیش آمد تا راز درست ساخته شود. رازی که در نوشته می‌بینید ۲۸ بایت بود و رد شد. برای پرهیز از حدس زدن طول، راز را در پایتون ساختیم و طولش را راستی‌آزمایی کردیم.

$ python3 - <<'PY'
base = "hoosh-exec-secret-32b"
secret = (base + "-" * (32 - len(base))).encode()
assert len(secret) == 32, len(secret)
open("hs.secret", "wb").write(secret)
print("secret bytes =", len(secret))
PY
secret bytes = 32

حالا سرور را با دو ادعای اجباری بالا می‌آوریم. شش توکن امضا کردیم: یکی درست بود و پنج تای دیگر هر کدام یک ایراد داشتند.

$ codex exec-server --listen ws://127.0.0.1:45761 \
    --ws-auth signed-bearer-token --ws-shared-secret-file ./hs.secret \
    --ws-issuer codex-infra --ws-audience devbox-pool
ws://127.0.0.1:45761

$ # توکن‌های واقعی، همه با HMAC-SHA256 و همان راز
valid         HANDSHAKE=accepted
wrong-secret  HANDSHAKE=rejected  HTTP 401
alg-none      HANDSHAKE=rejected  HTTP 401
no-exp        HANDSHAKE=rejected  HTTP 401
expired       HANDSHAKE=rejected  HTTP 401
wrong-aud     HANDSHAKE=rejected  HTTP 401

دو نتیجه از این جدول بیرون می‌آید. نخست اینکه الگوریتم alg=none رد می‌شود، یعنی یک مهاجم نمی‌تواند امضا را خالی بگذارد و توکن بسازد. دوم اینکه نبودن ادعای exp هم خطاست، پس توکن بی‌انقضا یک دریچه‌ی همیشگه نیست.

رفتار ادعاها فقط وقتی که هر دو پرچم را داده باشیم فعال می‌شود. با سروری که هیچ ادعایی نداشت، هر هفت توکن پذیرفته شدند، از جمله توکنی که مخاطب اشتباه داشت.

$ # سرور بدون --ws-issuer و --ws-audience
iss-and-aud  HANDSHAKE=accepted
wrong-aud    HANDSHAKE=accepted
wrong-iss    HANDSHAKE=accepted

$ # همان سرور با هر دو پرچم
iss-and-aud  HANDSHAKE=accepted
no-iss       HANDSHAKE=rejected  HTTP 401
no-aud       HANDSHAKE=rejected  HTTP 401
iss-only     HANDSHAKE=rejected  HTTP 401
aud-only     HANDSHAKE=rejected  HTTP 401
wrong-iss    HANDSHAKE=rejected  HTTP 401
wrong-aud    HANDSHAKE=rejected  HTTP 401

پس اگر می‌خواهید ادعاها واقعاً بررسی شوند باید هر دو پرچم را بدهید. یکی از آن دو به‌تنهایی کافی نیست، چون در آن حالت توکن باید هر دو ادعا را داشته باشد و هر توکنی که یکی را ندارد رد می‌شود.

پنجره‌ی انقضای توکن هم یک مقدار پیش‌فرض دارد که با آزمون مرز پیدا شد. توکن با انقضای ۳۰ ثانیه‌ی گذشته هنوز پذیرفته شد و ۳۱ ثانیه رد شد، یعنی پنجره‌ی پیش‌فرض دقیقا ۳۰ ثانیه است. با --ws-max-clock-skew-seconds این عدد را کم یا زیاد می‌کنید.

exp  -30  HANDSHAKE=accepted
exp  -31  HANDSHAKE=rejected  HTTP 401
exp  -60  HANDSHAKE=rejected  HTTP 401

چرخه‌ی کار پس از باز شدن درگاه

عبور از احراز هویت تازه اول کار است. درگاه یک سرور JSON-RPC است و چهار فراخوانی واقعی پاسخ دادند. هشت نام دیگر را آزمودیم و هر هشت خطای یکسان دادند که یعنی هنوز ساخته نشده‌اند.

initialize  {"error":{"code":-32602,"message":"invalid type: null, expected struct InitializeParams"},"id":1}
exec        {"error":{"code":-32601,"message":"exec-server stub does not implement `exec` yet"},"id":2}
spawn       {"error":{"code":-32601,"message":"exec-server stub does not implement `spawn` yet"},"id":3}
ping        {"error":{"code":-32601,"message":"exec-server stub does not implement `ping` yet"},"id":4}
health      {"error":{"code":-32601,"message":"exec-server stub does not implement `health` yet"},"id":5}

متن رسمی کدکس چرخه‌ی درست را می‌نویسد: نخست initialize و منتظر پاسخ، سپس initialized به‌صورت اعلان، و بعد فراخوانی‌های فرایند. رعایت همین ترتیب مهم است، چون initialized نباید شماره داشته باشد.

{"id":1,"method":"initialize","params":{"clientName":"my-client"}}
{"method":"initialized","params":{}}
{"id":2,"method":"process/start","params":{"processId":"proc-1","argv":["bash","-lc","printf 'ready\n'"],"tty":true}}

وقتی initialized را با شماره فرستادیم، سرور آن را یک فراخوانی دانست و خطا داد. اعلان بدون شماره بی‌صدا پذیرفته شد. همین تفاوت یک حرف است و همان حرف تعیین می‌کند نشست بالا می‌آید یا نه.

حالا یک فرایند زنده را بالا می‌آوریم و تا پایان می‌بریم. فرایند یک حلقه‌ی خواندن از ورودی است که هر خط را برمی‌گرداند.

$ python3 lifecycle3.py ws://127.0.0.1:45821 hx-cap-token-0123456789abcdef
initialize        -> {"id":1,"result":{"sessionId":"ccbf97e7-5200-4a4c-a9e3-67811d460688",
                 "environmentInfo":{"shell":{"name":"bash","path":"/bin/bash"},
                 "executorVersion":"0.158.0","providerId":"sha256:5335b38a...",
                 "platformOs":"linux", ...}}}
initialized       -> (بدون فریم)
process/start     -> {"id":3,"result":{"processId":"proc-1","sandboxType":"none"}}
process/output    -> {"method":"process/output","params":{"processId":"proc-1","seq":1,
                 "stream":"pty","chunk":"cmVhZHkNCg=="}}
process/write     -> {"id":4,"result":{"status":"accepted"}}
process/output    -> {"method":"process/output","params":{"processId":"proc-1","seq":2,
                 "stream":"pty","chunk":"aGVsbG8NCg=="}}
process/output    -> {"method":"process/output","params":{"processId":"proc-1","seq":3,
                 "stream":"pty","chunk":"ZWNobzpoZWxsbw=="}}
process/terminate -> {"id":5,"result":{"running":true}}
process/exited    -> {"method":"process/exited","params":{"processId":"proc-1","seq":5,
                 "exitCode":1,"sandboxDenied":false}}
process/closed    -> {"method":"process/closed","params":{"processId":"proc-1","seq":6}}

سه چیز در این خروجی ارزش دارد. نخست sandboxType که none است، یعنی این فرایند بیرون از سندباکس اجرا می‌شود و همین یک فیلد تعیین می‌کند چقدر به آن اعتماد کنید. دوم اینکه خروجی به‌صورت base64 می‌آید و cmVhZHkNCg== همان ready است. سوم اینکه کد خروج ۱ است نه صفر، چون حلقه با SIGTERM کشته شد.

یک فیلد دیگر هم هست که نبودش نوشتن را می‌شکند. نخستین تلاش این خطا را داد.

$ # بدون فیلد writeId
{"error":{"code":-32602,"message":"missing field `writeId`"},"id":4}

$ # با فیلد درست
{"id":4,"result":{"status":"accepted"}}

پس برای نوشتن در ورودی فرایند، processId و writeId و chunk هر سه لازم‌اند. رشته‌ی ۶۴ نویسه‌ای که در پاسخ initialize می‌بینید، هش providerId است و از سازنده‌ی درگاه می‌آید، نه از دستگاه شما.

چه چیزی را نتوانستم راستی‌آزمایی کنم

متن رسمی کدکس برای سرور اجرا در شاخه‌ی main هیچ‌کدام از شش پرچم احراز هویت را نام نمی‌برد. تنها اشاره‌اش به یک توکن bearer برای ثبت در CODEX_API_KEY است و آن هم مربوط به حالت راه دور، نه درگاه محلی. پس مرجع همه‌ی اعداد این نوشته خروجی --help خود نسخه‌ی نصب‌شده است و اجرای همان نسخه روی همین ماشین.

سه چیز را نتوانستم راستی‌آزمایی کنم و به‌جای حدس زدن حذفشان کردم. حالت راه دور با --remote و ثبت در یک رجیستری بیرونی به حساب کاربری ChatGPT نیاز دارد. حالت SigV4 برای رجیستری‌های AWS است. همچنین رفتار forward را نسنجیدم، چون به یک exec-server مقصد نیاز دارد.

برای کارهای روزمره‌ی خودم، از سنجیدن هزینه‌ی توکن کدکس و از بردن کدکس به خط لوله‌ی CI استفاده کرده‌ام. این درگاه در ادامه‌ی همان دو نوشته است: آنجا کدکس یک فرایند را می‌نویسد، اینجا خودش یک سرور است که فرایند می‌سازد و باید خودش نگهبان درگاهش باشد.

منابع

  1. یادداشت انتشار نسخه‌ی 0.158.0 کدکس، ۲۸ سپتامبر ۲۰۲۶
  2. متن رسمی سرور اجرای کدکس در شاخه‌ی main
  3. مستندات خط فرمان کدکس
  4. فهرست تغییرات چت‌جی‌پی‌تی و کدکس
  5. PR شماره 47601: احراز هویت اختیاری WebSocket در exec-server
  6. PR شماره 47648: توکن bearer برای اتصال‌های app-server
  7. PR شماره 47447: جداسازی احراز هویت WebSocket در یک crate
  8. کد منبع exec-server در مخزن کدکس
  9. بسته‌ی @openai/codex در npm