diff --git a/.env.example b/.env.example index dc94613..680e3a7 100644 --- a/.env.example +++ b/.env.example @@ -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_MASTODON_ORIGINS=https://fedi.yutakobayashi.com # 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_MODEL=gpt-6-astra # TWITTER_LITE_REPORT_ROOT=/absolute/path/to/twitter-lite/.data/research diff --git a/README.md b/README.md index f634782..0fb2003 100644 --- a/README.md +++ b/README.md @@ -151,8 +151,15 @@ TWITTER_LITE_CODEX_MODEL=gpt-6-astra TWITTER_LITE_REPORT_ROOT=/absolute/path/to/twitter-lite/.data/research # Optional: # 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. The existing validated, account-scoped research functions are exposed through its in-process `createSdkMcpServer` bridge instead. Streaming uses `streamText` diff --git a/docs/research-runtime.md b/docs/research-runtime.md index 1dee0f7..c6495be 100644 --- a/docs/research-runtime.md +++ b/docs/research-runtime.md @@ -13,6 +13,10 @@ research definitions and executors are exposed with `createSdkMcpServer` instead Validation, connected-account scope, request budgets, evidence tracking, and 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. +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 `onDynamicToolCall` to the same validated executor. diff --git a/src/features/research/codex-provider.server.ts b/src/features/research/codex-provider.server.ts index 1b25554..18e9ee6 100644 --- a/src/features/research/codex-provider.server.ts +++ b/src/features/research/codex-provider.server.ts @@ -129,6 +129,12 @@ export async function executeCodexResearch(input: CodexResearchInput): Promise [`mcp_servers.${server}.enabled`, false])), [`mcp_servers.${name}.enabled`]: true, + ...Object.fromEntries( + input.tools.definitions.map((tool) => [ + `mcp_servers.${name}.tools.${tool.name}.approval_mode`, + "approve", + ]), + ), }, onSessionCreated: (session) => { if (!input.signal.aborted) input.onThread(session.threadId); @@ -152,8 +158,12 @@ export async function executeCodexResearch(input: CodexResearchInput): Promise ({ stream: vi.fn< (options: unknown) => { 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>(), @@ -78,6 +82,7 @@ beforeEach(() => { yield { type: "text-delta", id: "answer", text: "世界" }; })(), finalStep: Promise.resolve({ + finishReason: "stop", providerMetadata: { "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(); }); +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 () => { fake.stream.mockReturnValue({ stream: (async function* () { @@ -175,6 +204,30 @@ it("closes the provider after a stream error", async () => { 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 () => { const controller = new AbortController(); const request = { ...input(), signal: controller.signal }; diff --git a/src/features/research/runner.server.ts b/src/features/research/runner.server.ts index 8a7b330..e2c6e89 100644 --- a/src/features/research/runner.server.ts +++ b/src/features/research/runner.server.ts @@ -220,11 +220,22 @@ export function createResearchService( current.status = "complete"; current.reportPath = info ? reportPath : undefined; emit(current); - } catch { + } catch (error) { 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.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); } } finally { diff --git a/src/features/research/runner.test.ts b/src/features/research/runner.test.ts index 8b5346b..6efe3cb 100644 --- a/src/features/research/runner.test.ts +++ b/src/features/research/runner.test.ts @@ -333,6 +333,18 @@ it("retains a failed startup conversation for retry even before a Codex thread e 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 () => { vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); const run = service.start(input());