Przejdź do głównej zawartości

Codex SDK w praktyce: wątki, ustrukturyzowany wynik i weryfikacja

Codex SDK (@openai/codex-sdk) to biblioteka TypeScript od OpenAI do uruchamiania agenta Codex z własnego kodu. Uruchamia CLI codex, zakłada lub wznawia wątki i zwraca dla każdej tury końcową odpowiedź, zmiany w plikach, wykonane polecenia oraz zużycie tokenów. Sprawdza się tam, gdzie między turami potrzebna jest logika programu; do jednorazowego skryptu wystarczy codex exec.

Twój zespół chce przycisku „Napraw to” przy czerwonych buildach: serwis pobiera gałąź, prosi Codexa o przyczynę i minimalną poprawkę, a pull request otwiera tylko wtedy, gdy testy przechodzą. Pętla w bashu wokół codex exec doprowadzi cię do połowy drogi. Potem potrzebujesz typowanych wyników, ponowienia w tej samej rozmowie i bramki, która odrzuci łatkę dotykającą plików, których nie powinna. Ta strona jest dla dewelopera, który buduje taki serwis, i dla tech leada, który musi ufać temu, co serwis wypuszcza.

  • Działającą integrację w TypeScript z jawnie ustawionym sandboxem, polityką zatwierdzeń i dostępem do sieci, zamiast tego, co akurat jest w config.toml na danej maszynie.
  • Schemat ustrukturyzowanego wyniku, który zamienia odpowiedź agenta w dane możliwe do sprawdzenia w kodzie.
  • Bramkę weryfikacyjną, która sama ponownie uruchamia testy i odrzuca edycje poza zakresem, więc nikt nie musi czytać każdego diffa.
  • Wzorce streamingu postępu, wznawiania wątku po ID i przerywania tury, która wymknęła się spod kontroli.
  • Tabelę awarii z dokładnymi komunikatami błędów rzucanymi przez SDK i sposobem wyjścia z każdej sytuacji.

SDK to nakładka, a nie osobny agent. README pakietu mówi, że SDK „spawns the CLI and exchanges JSONL events over stdin/stdout”: każde wywołanie run() uruchamia codex exec --experimental-json z flagami zbudowanymi z twoich opcji. Czego nie potrafi CLI, tego nie potrafi też SDK. SDK dokłada typowane zdarzenia, obiekty wątków i obsługę błędów w twoim języku.

PotrzebujeszUżyjDlaczego
Jednego promptu ze skryptu powłoki lub kroku CIcodex exec--json, --output-schema i codex exec resume już to pokrywają, bez serwisu w Node do utrzymania
Codexa przy każdym pull requeście w GitHub Actionsopenai/codex-action@v1Gotowe uprawnienia, zero kodu
Cyklicznej pracy według harmonogramuAutomatyzacje CodexaHarmonogram, historia i widok przeglądu są wbudowane
Długich zadań poza twoją maszyną, w kontenerach OpenAICodex cloudŚrodowiskiem i kontenerem zarządza OpenAI
Logiki programu między turami: rozgałęzienia po wyniku, ponowienia, bramki, kolejki, dashboarduCodex SDKTypowane elementy, opcje per wątek, ustrukturyzowany wynik, AbortSignal
Rozbudowanego klienta z interaktywnymi zatwierdzeniamicodex app-server (eksperymentalny w 0.157.1)Protokół JSON-RPC; SDK nie ma callbacku do zatwierdzeń

Codex nie może już działać jako serwer MCP (podpolecenie mcp-server usunięto w 0.154.0), więc z kodu sterujesz nim przez SDK, codex exec albo codex app-server. Jeśli wybierasz między dostawcami, strona sterowanie agentami z kodu porównuje Codex SDK z Claude Agent SDK i Cursor SDK na tym samym zadaniu.

Wszystko poniżej sprawdzono 2026-09-26 na @openai/codex-sdk 0.157.1 (README i definicje typów) oraz na codex exec --help z Codex CLI 0.157.1.

  1. Zainstaluj pakiet w projekcie na Node.js 18+. Zależy on od @openai/codex w tej samej wersji (dziś oba to 0.157.1), więc przypnij wersję dokładnie i aktualizuj oba razem:

    Okno terminala
    npm install --save-exact @openai/codex-sdk

    W Pythonie oficjalny pakiet to openai-codex na PyPI (0.157.1, wersjonowany razem z CLI). Pakiet o podobnej nazwie openai-codex-sdk nie pochodzi od OpenAI; nie instaluj go.

  2. Uwierzytelnij maszynę, na której działa SDK. Na stacji roboczej codex login zapisuje dane logowania, które odczytuje uruchomione CLI. Na serwerze pobierz klucz API z magazynu sekretów i przekaż go jako apiKey; SDK wstrzykuje go do procesu CLI jako CODEX_API_KEY. Opcje kont opisuje strona uwierzytelnianie w Codexie.

  3. Daj każdemu zadaniu własny checkout Gita. CLI odmawia pracy poza repozytorium Gita, chyba że przekażesz skipGitRepoCheck: true, a dwa wątki edytujące ten sam katalog nadpisują sobie nawzajem zmiany. Osobny worktree Gita na zadanie rozwiązuje oba problemy.

  4. Ustaw opcje bezpieczeństwa wątku jawnie, jak w następnej sekcji. Pominięte opcje biorą wartości z ~/.codex/config.toml na danej maszynie, a ten plik na laptopie i na runnerze CI wygląda inaczej. networkAccessEnabled: false obejmuje tylko polecenia powłoki agenta: ustaw też webSearchMode: "disabled" i daj zadaniu CODEX_HOME, którego config.toml nie deklaruje żadnych serwerów MCP, bo wyszukiwanie w sieci i narzędzia MCP to osobne drogi do sieci.

Uruchom pierwszy wątek z jawnymi ustawieniami bezpieczeństwa

Dział zatytułowany „Uruchom pierwszy wątek z jawnymi ustawieniami bezpieczeństwa”
import { execFileSync } from "node:child_process";
import { Codex } from "@openai/codex-sdk";
// Zapisz commit bazowy przed pierwszą turą: bramka weryfikacji liczy diff względem niego.
const base = execFileSync("git", ["rev-parse", "HEAD"], { cwd: "/srv/jobs/4812/repo" }).toString().trim();
const codex = new Codex({ apiKey: process.env.CODEX_API_KEY });
const thread = codex.startThread({
workingDirectory: "/srv/jobs/4812/repo", // worktree tego zadania
sandboxMode: "workspace-write", // edycje tylko wewnątrz checkoutu
approvalPolicy: "never", // nikt nie zatwierdza; zablokowane akcje kończą się błędem
networkAccessEnabled: false, // bez sieci dla poleceń powłoki agenta
webSearchMode: "disabled", // wyszukiwanie w sieci to osobny przełącznik
});
const turn = await thread.run(
"Run `npm test`. For each failing test, find the root cause and describe it. Do not edit files yet."
);
console.log(turn.finalResponse); // ostatnia wiadomość agenta
console.log(turn.usage); // tokeny: wejściowe, z cache, zapisane do cache, wyjściowe, na rozumowanie
console.log(thread.id); // zapisz, by później wznowić wątek

run() czeka na koniec całej tury i zwraca { items, finalResponse, usage }. items zawiera wszystko, co zrobił agent, jako typowane obiekty: command_execution (z command, aggregated_output i exit_code), file_change (ze ścieżkami zmienionych plików), mcp_tool_call, web_search, todo_list, reasoning, error i agent_message. To właśnie te elementy sprawdza twój kod weryfikujący.

Nie ustawiaj model, a wątek użyje domyślnego modelu Codexa (GPT-6 Astra w 0.157.1). Zanim zmienisz model, dostrój modelReasoningEffort; opcje i ceny znajdziesz w przeglądzie modeli. Jeśli chodzi o sandbox, SDK udostępnia starszą flagę sandboxMode (read-only, workspace-write, danger-full-access). OpenAI zaleca teraz profile uprawnień (beta), które przekażesz przez opcję config konstruktora Codex, na przykład new Codex({ config: { default_permissions: ":workspace" } }). Opcja config istnieje tylko w konstruktorze, więc profil obowiązuje we wszystkich wątkach danej instancji Codex. Na jedną instancję Codex wybierz jeden system: według OpenAI profile i starszy sandbox „do not compose”, więc jeśli używasz profilu, usuń sandboxMode i networkAccessEnabled z opcji wątku. Dostęp do sieci zależy wtedy od tego, na co pozwala wybrany profil, więc sprawdź jego definicję (profil nazwany znajduje się w sekcji [permissions] w config.toml). Przykłady na tej stronie zostają przy starszej parze sandboxMode i networkAccessEnabled, bo daje ona każdemu wątkowi jawny przełącznik wyłączający sieć. Nigdy nie używaj danger-full-access poza jednorazowym kontenerem z ograniczoną siecią.

Wywołaj run() ponownie na tym samym obiekcie Thread, aby kontynuować rozmowę. Agent pamięta kontekst wcześniejszych tur, więc drugi prompt może mówić „poprawka, którą opisałeś” bez powtarzania jej treści.

Swobodny tekst wystarczy człowiekowi, ale bramce nic nie daje. Przekaż schemat JSON jako outputSchema, a końcowa wiadomość agenta stanie się JSON-em w tym kształcie. Rzutowanie typu w TypeScripcie nie sprawdza tego w czasie działania, więc zweryfikuj sparsowany obiekt, zanim odczyta go jakakolwiek bramka: gdy tura skończy się nietypowo, brakujące risk byłoby undefined i przeszłoby jako „nie wysokie”. SDK zapisuje schemat do pliku tymczasowego i przekazuje go do CLI jako --output-schema. Schemat musi być zwykłym obiektem, inaczej run() rzuca outputSchema must be a plain JSON object.

const fixReport = {
type: "object",
properties: {
root_cause: { type: "string" },
files_changed: { type: "array", items: { type: "string" } },
tests_passed: { type: "boolean" },
risk: { type: "string", enum: ["low", "medium", "high"] },
},
required: ["root_cause", "files_changed", "tests_passed", "risk"],
additionalProperties: false,
} as const;
const fix = await thread.run(
"Implement the smallest fix for the root cause you found. Run `npm test` after the change. " +
"Report the files you changed and whether the full suite passed.",
{ outputSchema: fixReport, signal: AbortSignal.timeout(20 * 60 * 1000) }
);
type FixReport = {
root_cause: string; files_changed: string[]; tests_passed: boolean; risk: "low" | "medium" | "high";
};
// Rzutowanie niczego nie sprawdza w runtime: zweryfikuj kształt, zanim bramka się na nim oprze.
function parseReport(text: string): FixReport {
const r = JSON.parse(text) as Partial<FixReport> | null; // rzuca, gdy tekst nie jest JSON-em
if (
typeof r?.root_cause !== "string" ||
!Array.isArray(r.files_changed) || !r.files_changed.every((f) => typeof f === "string") ||
typeof r.tests_passed !== "boolean" ||
!["low", "medium", "high"].includes(String(r.risk))
) {
throw new Error("Rejected: final response does not match fixReport");
}
return r as FixReport;
}
const report = parseReport(fix.finalResponse);

Opcja signal przerywa turę, która przekroczy twój budżet czasu. Dwadzieścia minut to przykład; ustal limit na podstawie historii własnych zadań.

Raport to deklaracja agenta. Bramka konfrontuje ją z dowodami, których agent nie podrobi: z diffem Gita w worktree, z typowanymi elementami tury i z przebiegiem testów, który uruchamia twój własny kod. Uruchom ją, zanim cokolwiek opuści worktree zadania.

import { execFileSync } from "node:child_process";
import path from "node:path";
import type { FileChangeItem, CommandExecutionItem } from "@openai/codex-sdk";
const repo = "/srv/jobs/4812/repo";
const ALLOWED = ["src/", "test/"];
const git = (...args: string[]) => execFileSync("git", args, { cwd: repo }).toString();
const lines = (s: string) => s.split("\n").filter(Boolean);
const rel = (p: string) => path.relative(repo, path.resolve(repo, p));
// `base` to commit zapisany przed pierwszą turą (zob. przykład pierwszego wątku): jeśli agent
// albo hook zrobi commit w trakcie przebiegu, HEAD się przesunie, a diff względem niego ukryje te zmiany.
// Źródłem prawdy jest Git, nie elementy tury: zmiany zrobione poleceniami powłoki
// (sed -i, npm install przepisujący lockfile, skrypt generujący kod) nigdy nie są file_change.
git("add", "-A");
const touched = lines(git("diff", "--cached", "--name-only", base));
const outOfScope = touched.filter((f) => !ALLOWED.some((dir) => f.startsWith(dir)));
const unreported = touched.filter((f) => !report.files_changed.map(rel).includes(f));
if (outOfScope.length || unreported.length) {
throw new Error(`Rejected: out of scope ${outOfScope}, unreported ${unreported}`);
}
// Ścieżki z diffu, których nie zgłosił żaden element file_change, zmieniło polecenie: zapisz je.
const viaFileChange = new Set(
fix.items
.filter((i): i is FileChangeItem => i.type === "file_change" && i.status === "completed")
.flatMap((i) => i.changes.map((c) => rel(c.path)))
);
const viaShell = touched.filter((f) => !viaFileChange.has(f));
const failedCommands = fix.items.filter(
(i): i is CommandExecutionItem => i.type === "command_execution" && i.status === "failed"
);
// Istniejący test zmieniony, usunięty lub przemianowany przez agenta może ukryć błąd: do człowieka.
const editedTests = lines(git("diff", "--cached", "--name-status", base))
.map((l) => l.split("\t"))
.filter(([status, file]) => /^[MDR]/.test(status) && file.startsWith("test/"))
.map(([, file]) => file);
// Nie ufaj tests_passed: uruchom testy sam, stałym poleceniem.
// Testy wykonują kod napisany przez agenta, więc usuń klucz API z ich środowiska.
const { CODEX_API_KEY: _key, ...testEnv } = process.env;
execFileSync("npm", ["test"], { cwd: repo, stdio: "inherit", env: testEnv }); // rzuca przy niezerowym kodzie wyjścia
// Limit rozmiaru: „minimalna poprawka” na 400 linii trafia do człowieka.
const stat = git("diff", "--cached", "--shortstat", base);
const MAX_LINES = 150;
const linesChanged = (s: string) =>
[...s.matchAll(/(\d+) (?:insertion|deletion)/g)].reduce((n, m) => n + Number(m[1]), 0);
const needsHuman =
linesChanged(stat) > MAX_LINES || report.risk === "high" || editedTests.length > 0; // te kieruj do recenzenta

Co udowadnia ta bramka i kto zatwierdza wynik:

  • Zakres. Każda ścieżka z diffu Gita w indeksie leży w dozwolonych katalogach i figuruje w raporcie. Źródłem jest diff, a nie elementy file_change, bo plik zmieniony poleceniem powłoki (sed -i, npm install przepisujący package-lock.json, skrypt generujący kod) nigdy nie pojawia się jako file_change. Diff liczysz względem base, czyli commita zapisanego przed pierwszą turą, a nie względem HEAD: agent albo hook Gita, który zrobi commit w trakcie przebiegu, przesuwa HEAD, a diff względem niego ukryłby te zmiany. Niezgłoszona edycja oznacza twarde odrzucenie, bo raport przestaje być wiernym podsumowaniem. Zapisuj viaShell, czyli ścieżki bez pokrycia w file_change, żeby recenzent widział, które zmiany pochodzą z poleceń.
  • Zachowanie. Twój kod uruchamia stałe polecenie testowe, nigdy polecenie zasugerowane przez agenta, i robi to bez CODEX_API_KEY w środowisku, bo testy wykonują kod, który agent właśnie napisał. Jeśli zadanie ma jakikolwiek inny sekret, uruchom testy w tym samym kontenerze z ograniczoną siecią co agenta. Zielone tests_passed przy czerwonym przebiegu na hoście to odrzucenie i sygnał, by przyjrzeć się promptowi. Zielony przebieg na hoście nic nie dowodzi, jeśli agent osłabił asercję, która nie przechodziła, dlatego każdy istniejący plik w test/, który diff pokazuje jako zmieniony, usunięty lub przemianowany, ustawia needsHuman; nowo dodane pliki testów tego nie robią. Dla ostrzejszej kontroli uruchom też pierwotnie nieprzechodzące testy w wersji plików testowych z commita bazowego.
  • Rozmiar. git add -A najpierw dodaje nowe pliki do indeksu, bo git diff pomija pliki nieśledzone, a łatka, która tylko dodaje pliki, pokazałaby zero linii. Zapisz wynik --shortstat i kieruj do recenzenta zadania z needsHuman (powyżej MAX_LINES, z risk: "high" albo ze zmienionym istniejącym testem); 150 linii to przykładowy próg. Zapisuj też failedCommands: przebieg, który pięć razy ponawiał nieudaną migrację, zasługuje na uwagę, nawet jeśli skończył się na zielono.
  • Zatwierdzenie. Łatki o niskim ryzyku, w zakresie i z zielonymi testami otwierają pull request, który nadal przechodzi zwykły code review i CI. Bramka decyduje, co trafia do recenzenta; nie zastępuje zgody na merge. Dołącz do pull requesta raport, log elementów i wynik testów z hosta jako pakiet dowodów.

Śledź postęp na bieżąco i zapisuj zużycie tokenów

Dział zatytułowany „Śledź postęp na bieżąco i zapisuj zużycie tokenów”

run() wraca dopiero po zakończeniu tury. Na potrzeby dashboardu lub pipeline’u logów runStreamed() zwraca { events }, asynchroniczny generator tych samych zdarzeń JSONL, które wypisuje codex exec --json:

const { events } = await thread.runStreamed("Update the changelog for the fix you made.");
for await (const event of events) {
if (event.type === "thread.started") log({ threadId: event.thread_id });
if (event.type === "item.completed") log({ item: event.item.type, id: event.item.id });
if (event.type === "turn.completed") log({ usage: event.usage }); // tokeny na turę
if (event.type === "turn.failed") log({ error: event.error.message });
}

Wysyłaj usage z turn.completed tam, gdzie trafiają inne metryki zadań. Tokeny wejściowe, wejściowe z cache, zapisane do cache, wyjściowe i wyjściowe na rozumowanie w podziale na zadania to dokładnie to, czego potrzebuje zarządzanie kosztami Codexa, by przypisać wydatki do pipeline’u, a nie do osoby.

thread.id jest uzupełniane, gdy startuje pierwsza tura. Zapisz je razem z zadaniem, a kolejny etap (na przykład po tym, jak człowiek zatwierdzi diagnozę) może kontynuować tę samą rozmowę:

const resumed = codex.resumeThread(savedThreadId, {
workingDirectory: "/srv/jobs/4812/repo",
sandboxMode: "workspace-write",
approvalPolicy: "never",
networkAccessEnabled: false,
webSearchMode: "disabled",
});
await resumed.run("The reviewer approved your diagnosis. Implement the fix.");

Przekaż opcje wątku jeszcze raz. To flagi CLI podawane przy każdym uruchomieniu, a nie ustawienia zapisane w wątku, więc wznowienie bez nich działa na domyślnej konfiguracji hosta. Stan wątków leży w CODEX_HOME (domyślnie ~/.codex: katalog sessions/ i bazy danych stanu) na maszynie, która je uruchomiła; wznowienie w innym kontenerze się nie powiedzie, chyba że cały CODEX_HOME leży na trwałym wolumenie.

Jak bezpiecznie udostępnić Codexa narzędziom wewnętrznym

Dział zatytułowany „Jak bezpiecznie udostępnić Codexa narzędziom wewnętrznym”

Endpoint HTTP, który przekazuje req.body.prompt do thread.run(), pozwala każdemu, kto do niego dotrze, uruchamiać polecenia na twoim serwerze. Jeśli budujesz wewnętrzny przycisk „Napraw to”, zostaw kontrolę w swoim kodzie:

  • Wywołujący wybiera typ zadania i ID zgłoszenia, nigdy dowolny prompt ani ścieżkę. Twój kod mapuje typ zadania na stały szablon promptu i repozytorium z listy dozwolonych.
  • Traktuj treść zgłoszeń, opisy issues i logi CI jako niezaufane dane. Cytuj je w prompcie jako materiał do analizy i trzymaj approvalPolicy: "never". Przy networkAccessEnabled: false, webSearchMode: "disabled" i braku serwerów MCP w konfiguracji CODEX_HOME zadania wstrzyknięta instrukcja nie ma żadnej drogi do sieci, którą mogłaby coś pobrać albo wyprowadzić.
  • Zadania czysto analityczne uruchamiaj z sandboxMode: "read-only", a workspace-write dawaj tylko etapowi poprawki.
  • Kolejkuj zadania i dawaj każdemu osobny worktree; nie uruchamiaj dwóch wątków w jednym katalogu.
  • Uwierzytelniaj endpoint, loguj thread.id razem z użytkownikiem, który zlecił zadanie, i pozwól bramce opisanej wyżej decydować, co staje się pull requestem.

Pełny przepływ od zgłoszenia do pull requesta we wszystkich narzędziach opisuje pipeline od issue do PR.

ObjawPrzyczynaJak wyjść
run() rzuca Codex Exec exited with code 1: …Zawiodło samo CLI: brak danych logowania, katalog poza repozytorium Gita albo klucz konfiguracji odrzucony przez CLI. Komunikat kończy się stderr z CLIPrzeczytaj koniec stderr. Uruchom codex login status na hoście, sprawdź, czy workingDirectory to checkout Gita, i przetestuj te same opcje przez codex exec w terminalu
run() rzuca błąd z komunikatem modeluTura wyemitowała turn.failed; SDK zamienia to na ErrorZaloguj thread.id, ponów raz w tym samym wątku albo wznów go później. Powtarzające się błędy na jednym prompcie to zwykle problem z limitem lub kontekstem: zobacz zarządzanie kosztami
Po ustawieniu env CLI nie widzi danych logowania ani narzędziOpcja env całkowicie zastępuje dziedziczone środowisko (SDK tylko dopisuje na początek PATH własne katalogi z dołączonymi plikami binarnymi)Przekaż PATH i HOME (albo CODEX_HOME) razem z własnymi zmiennymi; SDK nadal dodaje CODEX_API_KEY, gdy ustawisz apiKey
Tura trwa godzinęBrak limitu czasu i pętla nieudanych poleceńPrzekaż signal: AbortSignal.timeout(…); znajdź pętlę w elementach command_execution ze status: "failed"
parseReport() rzuca wyjątek: JSON.parse się wywraca albo raport nie pasuje do fixReportTura zakończyła się komunikatem o błędzie albo w tej turze nie przekazano outputSchemaZłap wyjątek, wyślij jedno doprecyzowanie w tym samym wątku („Return only the JSON object for the schema”) i odrzuć zadanie, jeśli znowu się nie uda
resumeThread() zawodzi albo zaczyna od zeraPliki sesji są na innej maszynie albo zadanie działało z ulotnym CODEX_HOMEUtrwalaj CODEX_HOME per zadanie albo zacznij nowy wątek od podsumowania poprzedniego etapu
Agent edytuje nie te pliki albo dwa zadania psują sobie nawzajem pracęWspólny katalog roboczy albo nieustawione workingDirectory (SDK używa wtedy bieżącego katalogu procesu)Jeden worktree na zadanie, zawsze przekazuj workingDirectory i zostaw sprawdzanie zakresu w bramce
Instrukcja wstrzyknięta w treść zgłoszenia i tak sięga do siecinetworkAccessEnabled: false blokuje tylko polecenia powłoki agenta; wyszukiwanie w sieci (webSearchMode) i serwery MCP z config.toml hosta to osobne drogiUstaw webSearchMode: "disabled" w każdym wątku i przy każdym wznowieniu, a CODEX_HOME skieruj na konfigurację zadania bez serwerów MCP
Zachowanie zmienia się po npm updateWersje SDK i CLI zmieniają się razem, a nowe wydania CLI zmieniają wartości domyślnePrzypnij SDK dokładnie i aktualizuj go w osobnym pull requeście, z uruchomionymi testami bramki
  • Tryb headless i nieinteraktywny opisuje flagi codex exec, na których opiera się SDK; zacznij tam, jeśli wystarczy ci skrypt.
  • Codex w CI/CD pokazuje drogę przez GitHub Action, gdy nie potrzebujesz własnego serwisu.
  • Worktrees w Codexie wyjaśniają izolację, w której powinno działać każde zadanie SDK.
  • Sterowanie agentami z kodu porównuje Codex SDK z Claude Agent SDK i Cursor SDK na tym samym zadaniu.
  • Pakiety dowodów określają, co powinien zawierać pull request otwarty przez pipeline, by recenzent miał na czym się oprzeć.