Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions src/codex_app_server_debug.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { assertEquals } from "@std/assert";
import {
codexAppServerStderrDebugDetails,
summarizeCodexAppServerDebugValue,
} from "./codex_app_server_debug.ts";

Deno.test("codex app-server stderr debug parses JSON lines before summarizing", () => {
const details = codexAppServerStderrDebugDetails(
JSON.stringify({
timestamp: "2026-05-15T02:41:48.000Z",
level: "DEBUG",
target: "codex_core::client",
fields: {
message: "starting turn",
authorization: "Bearer secret-token",
prompt: "summarize this private prompt",
},
}),
);

assertEquals(summarizeCodexAppServerDebugValue(details), {
json: {
timestamp: "2026-05-15T02:41:48.000Z",
level: "DEBUG",
target: "codex_core::client",
fields: {
message: "starting turn",
authorization: "<redacted len=19>",
prompt: "<string len=29>",
},
},
});
});

Deno.test("codex app-server stderr debug keeps non-JSON lines opaque", () => {
const details = codexAppServerStderrDebugDetails(
"plain stderr with a token-like value",
);

assertEquals(summarizeCodexAppServerDebugValue(details), {
line: "<string len=36>",
});
});
20 changes: 20 additions & 0 deletions src/codex_app_server_debug.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,21 @@ const CODEX_APP_SERVER_DEBUG_ENV = "GAMBIT_CODEX_APP_SERVER_DEBUG";

const STRUCTURAL_STRING_KEYS = new Set([
"error",
"file",
"filename",
"level",
"method",
"message",
"module_path",
"name",
"phase",
"reason",
"role",
"server",
"status",
"target",
"timestamp",
"time",
"tool",
"type",
]);
Expand Down Expand Up @@ -99,4 +107,16 @@ export function summarizeCodexAppServerDebugValue(value: unknown): DebugValue {
return summarizeDebugValue(value);
}

export function codexAppServerStderrDebugDetails(
line: string,
): Record<string, unknown> {
const trimmed = line.trim();
if (!trimmed) return { line: "" };
try {
return { json: JSON.parse(trimmed) };
} catch {
return { line: trimmed };
}
}

export { CODEX_APP_SERVER_DEBUG_ENV };
213 changes: 212 additions & 1 deletion src/codex_preflight.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { assertEquals, assertStringIncludes } from "@std/assert";
import { assert, assertEquals, assertStringIncludes } from "@std/assert";
import { join } from "@std/path";
import { CODEX_HOST_AUTH_BUNDLE_ENV } from "./codex_auth.ts";
import {
Expand Down Expand Up @@ -111,6 +111,94 @@ done
}
});

Deno.test("codex preflight does not hang when app-server ignores SIGTERM after account read", async () => {
const priorBin = Deno.env.get("GAMBIT_CODEX_BIN");
const priorBundle = Deno.env.get(CODEX_HOST_AUTH_BUNDLE_ENV);
const root = await Deno.makeTempDir({
prefix: "codex-preflight-shutdown-",
});
const fakeCodexPath = join(root, "fake-codex");

await Deno.writeTextFile(
fakeCodexPath,
`#!/bin/sh
set -eu
${fakeCodexVersionBlock()}

extract_id() {
printf '%s\\n' "$1" | sed -n 's/.*"id":"\\([^"]*\\)".*/\\1/p'
}

mode=""
for arg in "$@"; do
if [ "$arg" = "app-server" ]; then
mode="app-server"
fi
done

[ "$mode" = "app-server" ] || exit 64

while IFS= read -r line; do
case "$line" in
*'"method":"initialize"'*)
id="$(extract_id "$line")"
printf '{"id":"%s","result":{"capabilities":{"experimentalApi":true}}}\\n' "$id"
;;
*'"method":"initialized"'*)
;;
*'"method":"account/login/start"'*)
id="$(extract_id "$line")"
printf '{"id":"%s","result":{"account":{"id":"acct-preflight"}}}\\n' "$id"
;;
*'"method":"account/read"'*)
id="$(extract_id "$line")"
printf '{"id":"%s","result":{"account":{"id":"acct-preflight","planType":"pro"},"requiresOpenaiAuth":false}}\\n' "$id"
;;
esac
done

trap '' TERM
while :; do :; done
`,
);
await Deno.chmod(fakeCodexPath, 0o755);

Deno.env.set("GAMBIT_CODEX_BIN", fakeCodexPath);
Deno.env.set(
CODEX_HOST_AUTH_BUNDLE_ENV,
JSON.stringify({
accessToken: "preflight-access-token",
refreshToken: "refresh-token",
idToken: "id-token",
chatgptAccountId: "acct-preflight",
chatgptPlanType: "pro",
lastRefresh: "2026-04-17T00:00:00Z",
}),
);

try {
const startedAt = performance.now();
const status = await readCodexLoginStatus();
assertEquals(status.codexLoggedIn, true);
assert(
performance.now() - startedAt < 2_000,
"preflight should not wait indefinitely for app-server shutdown",
);
} finally {
if (priorBin == null) {
Deno.env.delete("GAMBIT_CODEX_BIN");
} else {
Deno.env.set("GAMBIT_CODEX_BIN", priorBin);
}
if (priorBundle == null) {
Deno.env.delete(CODEX_HOST_AUTH_BUNDLE_ENV);
} else {
Deno.env.set(CODEX_HOST_AUTH_BUNDLE_ENV, priorBundle);
}
await Deno.remove(root, { recursive: true }).catch(() => undefined);
}
});

Deno.test("codex preflight responds to app-server refresh RPCs before account/read completes", async () => {
const priorBin = Deno.env.get("GAMBIT_CODEX_BIN");
const priorBundle = Deno.env.get(CODEX_HOST_AUTH_BUNDLE_ENV);
Expand Down Expand Up @@ -328,6 +416,129 @@ done
}
});

Deno.test("codex preflight does not hang when stale-account host refresh is unresponsive", async () => {
const priorBin = Deno.env.get("GAMBIT_CODEX_BIN");
const priorBundle = Deno.env.get(CODEX_HOST_AUTH_BUNDLE_ENV);
const priorHostServiceSocket = Deno.env.get(RUNTIME_HOST_SERVICE_SOCKET_ENV);
const priorHostServiceToken = Deno.env.get(RUNTIME_HOST_SERVICE_TOKEN_ENV);
const root = await Deno.makeTempDir({
prefix: "codex-preflight-host-refresh-timeout-",
});
const fakeCodexPath = join(root, "fake-codex");
const listener = Deno.listen({ hostname: "127.0.0.1", port: 0 });
const listenerAddress = listener.addr as Deno.NetAddr;
const heldConnections: Array<Deno.Conn> = [];
const acceptLoop = (async () => {
for await (const conn of listener) {
heldConnections.push(conn);
}
})().catch(() => undefined);

await Deno.writeTextFile(
fakeCodexPath,
`#!/bin/sh
set -eu
${fakeCodexVersionBlock()}

extract_id() {
printf '%s\\n' "$1" | sed -n 's/.*"id":"\\([^"]*\\)".*/\\1/p'
}

mode=""
for arg in "$@"; do
if [ "$arg" = "app-server" ]; then
mode="app-server"
fi
done

[ "$mode" = "app-server" ] || exit 64

while IFS= read -r line; do
case "$line" in
*'"method":"initialize"'*)
id="$(extract_id "$line")"
printf '{"id":"%s","result":{"capabilities":{"experimentalApi":true}}}\\n' "$id"
;;
*'"method":"initialized"'*)
;;
*'"method":"account/login/start"'*)
id="$(extract_id "$line")"
printf '{"id":"%s","result":{"account":{"id":"acct-preflight"}}}\\n' "$id"
;;
*'"method":"account/read"'*)
id="$(extract_id "$line")"
printf '{"id":"%s","result":{"account":{"id":"acct-preflight","planType":"pro"},"requiresOpenaiAuth":true}}\\n' "$id"
;;
esac
done
`,
);
await Deno.chmod(fakeCodexPath, 0o755);

Deno.env.set("GAMBIT_CODEX_BIN", fakeCodexPath);
Deno.env.set(
RUNTIME_HOST_SERVICE_SOCKET_ENV,
`tcp://127.0.0.1:${listenerAddress.port}`,
);
Deno.env.set(RUNTIME_HOST_SERVICE_TOKEN_ENV, "host-service-token");
Deno.env.set(
CODEX_HOST_AUTH_BUNDLE_ENV,
JSON.stringify({
accessToken: "preflight-access-token",
refreshToken: "refresh-token",
idToken: "id-token",
chatgptAccountId: "acct-preflight",
chatgptPlanType: "pro",
lastRefresh: "2026-04-17T00:00:00Z",
}),
);

try {
const startedAt = performance.now();
const status = await readCodexLoginStatus();
assertEquals(status.codexLoggedIn, true);
assertStringIncludes(
status.codexLoginStatus,
"account/read still reports requiresOpenaiAuth",
);
assert(
performance.now() - startedAt < 2_500,
"preflight should not wait indefinitely for stale-account host refresh",
);
} finally {
listener.close();
for (const conn of heldConnections) {
try {
conn.close();
} catch {
// ignore
}
}
await acceptLoop;
if (priorBin == null) {
Deno.env.delete("GAMBIT_CODEX_BIN");
} else {
Deno.env.set("GAMBIT_CODEX_BIN", priorBin);
}
if (priorBundle == null) {
Deno.env.delete(CODEX_HOST_AUTH_BUNDLE_ENV);
} else {
Deno.env.set(CODEX_HOST_AUTH_BUNDLE_ENV, priorBundle);
}
if (priorHostServiceSocket == null) {
Deno.env.delete(RUNTIME_HOST_SERVICE_SOCKET_ENV);
} else {
Deno.env.set(RUNTIME_HOST_SERVICE_SOCKET_ENV, priorHostServiceSocket);
}
if (priorHostServiceToken == null) {
Deno.env.delete(RUNTIME_HOST_SERVICE_TOKEN_ENV);
} else {
Deno.env.set(RUNTIME_HOST_SERVICE_TOKEN_ENV, priorHostServiceToken);
}
await Deno.remove(root, { recursive: true }).catch(() => undefined);
}
});

Deno.test("codex preflight does not mark login ready when account/read requires auth and returns no account id", async () => {
const priorBin = Deno.env.get("GAMBIT_CODEX_BIN");
const priorBundle = Deno.env.get(CODEX_HOST_AUTH_BUNDLE_ENV);
Expand Down
Loading
Loading