fix: restore authenticated Codex research tool execution

This commit is contained in:
2026-09-28 20:59:59 +09:00
parent f1914a5d0a
commit 423d9edbfa
7 changed files with 103 additions and 4 deletions
+2
View File
@@ -6,6 +6,8 @@ TWITTER_LITE_DB_PATH=/absolute/path/to/twitter-lite/.data/workspace.sqlite
# TWITTER_LITE_CREDENTIAL_KEY_FILE=/absolute/path/to/credential-key # TWITTER_LITE_CREDENTIAL_KEY_FILE=/absolute/path/to/credential-key
TWITTER_LITE_MASTODON_ORIGINS=https://fedi.yutakobayashi.com TWITTER_LITE_MASTODON_ORIGINS=https://fedi.yutakobayashi.com
# Optional Codex research. The AI SDK provider manages its local app-server. # Optional Codex research. The AI SDK provider manages its local app-server.
# If your signed-in CLI uses a custom home, use the same path on the server.
# CODEX_HOME=/absolute/path/to/codex-home
# TWITTER_LITE_CODEX_PATH=/absolute/path/to/codex # TWITTER_LITE_CODEX_PATH=/absolute/path/to/codex
# TWITTER_LITE_CODEX_MODEL=gpt-6-astra # TWITTER_LITE_CODEX_MODEL=gpt-6-astra
# TWITTER_LITE_REPORT_ROOT=/absolute/path/to/twitter-lite/.data/research # TWITTER_LITE_REPORT_ROOT=/absolute/path/to/twitter-lite/.data/research
+7
View File
@@ -151,8 +151,15 @@ TWITTER_LITE_CODEX_MODEL=gpt-6-astra
TWITTER_LITE_REPORT_ROOT=/absolute/path/to/twitter-lite/.data/research TWITTER_LITE_REPORT_ROOT=/absolute/path/to/twitter-lite/.data/research
# Optional: # Optional:
# TWITTER_LITE_CODEX_PATH=/absolute/path/to/codex # TWITTER_LITE_CODEX_PATH=/absolute/path/to/codex
# CODEX_HOME=/absolute/path/to/codex-home
``` ```
If your CLI sets `CODEX_HOME`, set the same path in the server environment
(`.env.local` for development). A systemd service does not inherit your shell's
environment. Without it, research uses `~/.codex`, which may have different or
expired credentials. Authentication failures appear separately in chat; the
underlying error and conversation ID are logged on the server.
This provider does not register AI SDK `tools` as Codex dynamic tools. This provider does not register AI SDK `tools` as Codex dynamic tools.
The existing validated, account-scoped research functions are exposed through The existing validated, account-scoped research functions are exposed through
its in-process `createSdkMcpServer` bridge instead. Streaming uses `streamText` its in-process `createSdkMcpServer` bridge instead. Streaming uses `streamText`
+4
View File
@@ -13,6 +13,10 @@ research definitions and executors are exposed with `createSdkMcpServer` instead
Validation, connected-account scope, request budgets, evidence tracking, and Validation, connected-account scope, request budgets, evidence tracking, and
read-only SNS behavior remain in `agent-tools.server.ts`. Provider execution is read-only SNS behavior remain in `agent-tools.server.ts`. Provider execution is
one agent turn; there is no second AI SDK tool loop replaying those calls. one agent turn; there is no second AI SDK tool loop replaying those calls.
Only the registered research tools receive per-tool MCP approval overrides for
non-interactive execution. Shell approvals and external MCP servers remain
disabled. Failed or truncated Codex turns are treated as failures even when the
provider emits a finish event without an error event.
Resumed threads that already registered dynamic tools route their calls through Resumed threads that already registered dynamic tools route their calls through
`onDynamicToolCall` to the same validated executor. `onDynamicToolCall` to the same validated executor.
+11 -1
View File
@@ -129,6 +129,12 @@ export async function executeCodexResearch(input: CodexResearchInput): Promise<v
web_search: "disabled", web_search: "disabled",
...Object.fromEntries(inherited.map((server) => [`mcp_servers.${server}.enabled`, false])), ...Object.fromEntries(inherited.map((server) => [`mcp_servers.${server}.enabled`, false])),
[`mcp_servers.${name}.enabled`]: true, [`mcp_servers.${name}.enabled`]: true,
...Object.fromEntries(
input.tools.definitions.map((tool) => [
`mcp_servers.${name}.tools.${tool.name}.approval_mode`,
"approve",
]),
),
}, },
onSessionCreated: (session) => { onSessionCreated: (session) => {
if (!input.signal.aborted) input.onThread(session.threadId); if (!input.signal.aborted) input.onThread(session.threadId);
@@ -152,8 +158,12 @@ export async function executeCodexResearch(input: CodexResearchInput): Promise<v
if (part.type === "error") throw part.error; if (part.type === "error") throw part.error;
} }
input.signal.throwIfAborted(); input.signal.throwIfAborted();
const metadata = (await result.finalStep).providerMetadata?.["codex-app-server"]; const step = await result.finalStep;
const metadata = step.providerMetadata?.["codex-app-server"];
if (typeof metadata?.threadId === "string") input.onThread(metadata.threadId); if (typeof metadata?.threadId === "string") input.onThread(metadata.threadId);
if (step.finishReason === "error" || step.finishReason === "length") {
throw new Error(step.rawFinishReason || "Codex could not complete the turn.");
}
} finally { } finally {
await provider.close(); await provider.close();
} }
+54 -1
View File
@@ -24,7 +24,11 @@ const fake = vi.hoisted(() => ({
stream: vi.fn< stream: vi.fn<
(options: unknown) => { (options: unknown) => {
stream: AsyncIterable<{ type: string; id?: string; text?: string; error?: Error }>; stream: AsyncIterable<{ type: string; id?: string; text?: string; error?: Error }>;
finalStep?: Promise<{ providerMetadata: { "codex-app-server": { threadId: string } } }>; finalStep?: Promise<{
finishReason: "stop" | "error" | "length";
rawFinishReason?: string;
providerMetadata: { "codex-app-server": { threadId: string } };
}>;
} }
>(), >(),
smooth: vi.fn<(options: unknown) => string>(), smooth: vi.fn<(options: unknown) => string>(),
@@ -78,6 +82,7 @@ beforeEach(() => {
yield { type: "text-delta", id: "answer", text: "世界" }; yield { type: "text-delta", id: "answer", text: "世界" };
})(), })(),
finalStep: Promise.resolve({ finalStep: Promise.resolve({
finishReason: "stop",
providerMetadata: { providerMetadata: {
"codex-app-server": { threadId: "persistent-thread" }, "codex-app-server": { threadId: "persistent-thread" },
}, },
@@ -165,6 +170,30 @@ it("fails closed on an unreadable or malformed inherited MCP configuration", asy
expect(fake.create).not.toHaveBeenCalled(); expect(fake.create).not.toHaveBeenCalled();
}); });
it("preapproves only this run's supplied MCP tools while preserving global restrictions", async () => {
const request = input();
request.tools.definitions.push({
name: "fetch_column_posts",
description: "Read scoped posts",
inputSchema: { type: "object" },
});
await executeCodexResearch(request);
const settings = fake.create.mock.calls[0]?.[0].defaultSettings;
expect(settings).toMatchObject({
approvalPolicy: "never",
autoApprove: false,
sandboxPolicy: "workspace-write",
});
const overrides = settings?.configOverrides ?? {};
expect(
Object.fromEntries(Object.entries(overrides).filter(([key]) => key.endsWith("approval_mode"))),
).toEqual({
"mcp_servers.workspace_research_testrun.tools.list_decks.approval_mode": "approve",
"mcp_servers.workspace_research_testrun.tools.fetch_column_posts.approval_mode": "approve",
});
expect(overrides["mcp_servers.external.enabled"]).toBe(false);
});
it("closes the provider after a stream error", async () => { it("closes the provider after a stream error", async () => {
fake.stream.mockReturnValue({ fake.stream.mockReturnValue({
stream: (async function* () { stream: (async function* () {
@@ -175,6 +204,30 @@ it("closes the provider after a stream error", async () => {
expect(fake.model.close).toHaveBeenCalledOnce(); expect(fake.model.close).toHaveBeenCalledOnce();
}); });
it.each([
["error", "workspace routing discovery unauthorized (401)"],
["length", "usage_limit_exceeded"],
["length", "context_window_exceeded"],
] as const)(
"rejects an incomplete %s finish without an error event: %s",
async (finishReason, rawFinishReason) => {
const request = input();
fake.stream.mockReturnValue({
stream: (async function* () {
yield { type: "finish" };
})(),
finalStep: Promise.resolve({
finishReason,
rawFinishReason,
providerMetadata: { "codex-app-server": { threadId: "failed-thread" } },
}),
});
await expect(executeCodexResearch(request)).rejects.toThrow(rawFinishReason);
expect(request.onThread).toHaveBeenCalledWith("failed-thread");
expect(fake.model.close).toHaveBeenCalledOnce();
},
);
it("propagates cancellation and prevents late bridge calls or metadata changes", async () => { it("propagates cancellation and prevents late bridge calls or metadata changes", async () => {
const controller = new AbortController(); const controller = new AbortController();
const request = { ...input(), signal: controller.signal }; const request = { ...input(), signal: controller.signal };
+13 -2
View File
@@ -220,11 +220,22 @@ export function createResearchService(
current.status = "complete"; current.status = "complete";
current.reportPath = info ? reportPath : undefined; current.reportPath = info ? reportPath : undefined;
emit(current); emit(current);
} catch { } catch (error) {
if (active(execution)) { if (active(execution)) {
process.stderr.write(
`${JSON.stringify({
event: "research_failed",
runId: current.id,
error:
error instanceof Error ? { name: error.name, message: error.message } : String(error),
})}\n`,
);
current.status = "failed"; current.status = "failed";
current.error = current.error =
"Unable to complete research. Check Codex, its login, selected accounts, and the report location."; error instanceof Error &&
/unauthorized|\b401\b|authentication|not logged in/i.test(error.message)
? "Codex authentication failed. Make sure the server uses the CODEX_HOME of your signed-in Codex CLI."
: "Unable to complete research. Check Codex, its login, selected accounts, and the report location.";
emit(current); emit(current);
} }
} finally { } finally {
+12
View File
@@ -333,6 +333,18 @@ it("retains a failed startup conversation for retry even before a Codex thread e
expect(turns[0]?.input.threadId).toBeUndefined(); expect(turns[0]?.input.threadId).toBeUndefined();
}); });
it("identifies Codex authentication failures without exposing the raw provider error", async () => {
vi.spyOn(process.stderr, "write").mockImplementation(() => true);
launch = async () => {
throw new Error("workspace routing discovery unauthorized (401)");
};
const run = service.start(input());
await vi.waitFor(() => expect(service.status(run.id).run?.status).toBe("failed"));
expect(service.status(run.id).run?.error).toBe(
"Codex authentication failed. Make sure the server uses the CODEX_HOME of your signed-in Codex CLI.",
);
});
it("bounds long-running work and aborts it at the time limit", async () => { it("bounds long-running work and aborts it at the time limit", async () => {
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
const run = service.start(input()); const run = service.start(input());