From fa1b75c4566657fbf5c40dfd160e8a72b08c21b2 Mon Sep 17 00:00:00 2001 From: ActivePeter <1020401660@qq.com> Date: Fri, 28 Aug 2026 15:19:35 +0800 Subject: [PATCH] feat(codex): add reusable coding agent workflows --- .../docs/en/workflows/blocks/codex.mdx | 109 + apps/sim/.env.example | 3 + .../api/workflows/[id]/codex-config/route.ts | 84 + .../api/workspaces/[id]/codex-config/route.ts | 73 + .../[workspaceId]/settings/[section]/page.tsx | 1 + .../settings/[section]/settings.tsx | 6 + .../settings/components/codex/codex.tsx | 128 + .../[workspaceId]/settings/navigation.test.ts | 2 + .../agent-session-selector.test.tsx | 168 + .../agent-session-selector.tsx | 186 + .../agent-session-selector/index.ts | 1 + .../components/sub-block/components/index.ts | 1 + .../editor/components/sub-block/sub-block.tsx | 12 + .../workflow-block/workflow-block.tsx | 62 +- apps/sim/blocks/blocks.test.ts | 1 + apps/sim/blocks/blocks/codex.test.ts | 102 + apps/sim/blocks/blocks/codex.ts | 262 + apps/sim/blocks/registry-maps.ts | 2 + apps/sim/blocks/types.ts | 6 + .../codex/codex-agent-config-modal.tsx | 196 + .../components/codex/codex-config-editor.tsx | 257 + .../components/settings/navigation.test.ts | 15 +- apps/sim/components/settings/navigation.ts | 18 +- .../lib/copy/copy-workflows.ts | 5 + .../lib/copy/deploy-bridge.ts | 3 + .../ee/workspace-forking/lib/create-fork.ts | 2 + .../lib/promote/promote-plan.ts | 3 + apps/sim/executor/constants.ts | 1 + apps/sim/executor/execution/engine.test.ts | 23 +- apps/sim/executor/execution/engine.ts | 18 +- apps/sim/executor/execution/executor.ts | 3 + .../handlers/codex/cloud/authoring.test.ts | 327 + .../handlers/codex/cloud/authoring.ts | 285 + .../handlers/codex/cloud/plan.test.ts | 103 + .../sim/executor/handlers/codex/cloud/plan.ts | 91 + .../handlers/codex/cloud/shared.test.ts | 108 + .../executor/handlers/codex/cloud/shared.ts | 234 + .../handlers/codex/codex-handler.test.ts | 316 + .../executor/handlers/codex/codex-handler.ts | 310 + .../executor/handlers/codex/core/backend.ts | 65 + .../handlers/codex/core/command.test.ts | 76 + .../executor/handlers/codex/core/command.ts | 117 + .../handlers/codex/core/config.test.ts | 90 + .../executor/handlers/codex/core/config.ts | 85 + .../handlers/codex/core/events.test.ts | 126 + .../executor/handlers/codex/core/events.ts | 294 + .../core/fixtures/reconnect-success.jsonl | 6 + .../codex/core/fixtures/success.jsonl | 9 + .../codex/core/fixtures/turn-failed.jsonl | 3 + .../executor/handlers/codex/core/redaction.ts | 48 + .../handlers/codex/core/session.test.ts | 167 + .../executor/handlers/codex/core/session.ts | 190 + apps/sim/executor/handlers/registry.ts | 2 + apps/sim/executor/types.ts | 16 + apps/sim/hooks/queries/codex-config.ts | 69 + apps/sim/hooks/use-agent-session-catalog.ts | 71 + .../lib/api/contracts/codex-config.test.ts | 37 + apps/sim/lib/api/contracts/codex-config.ts | 78 + apps/sim/lib/api/contracts/index.ts | 1 + apps/sim/lib/codex/config.test.ts | 127 + apps/sim/lib/codex/config.ts | 273 + .../lib/copilot/generated/docs-manifest.ts | 1 + apps/sim/lib/core/config/env.ts | 3 + .../remote-sandbox/codex-lifetime.test.ts | 53 + .../remote-sandbox/codex-lifetime.ts | 45 + .../remote-sandbox/conformance.test.ts | 48 +- .../lib/execution/remote-sandbox/daytona.ts | 17 +- apps/sim/lib/execution/remote-sandbox/e2b.ts | 8 +- .../sim/lib/execution/remote-sandbox/index.ts | 102 +- .../execution/remote-sandbox/resolve.test.ts | 2 +- .../lib/execution/remote-sandbox/resolve.ts | 2 +- .../sim/lib/execution/remote-sandbox/types.ts | 4 +- apps/sim/lib/workflows/agent-sessions.test.ts | 164 + apps/sim/lib/workflows/agent-sessions.ts | 208 + apps/sim/lib/workflows/editing/validation.ts | 16 + .../migrations/subblock-migrations.test.ts | 21 + .../migrations/subblock-migrations.ts | 45 +- .../lib/workflows/persistence/duplicate.ts | 7 + .../lib/workflows/search-replace/indexer.ts | 1 + apps/sim/lib/workspaces/permissions/utils.ts | 3 + apps/sim/next.config.ts | 2 +- apps/sim/providers/codex.ts | 39 + .../scripts/build-codex-daytona-snapshot.ts | 60 + apps/sim/scripts/build-codex-e2b-template.ts | 54 + .../scripts/codex-sandbox-packages.test.ts | 25 + apps/sim/scripts/codex-sandbox-packages.ts | 37 + apps/sim/stores/workflows/utils.test.ts | 43 + apps/sim/stores/workflows/utils.ts | 9 + .../stores/workflows/workflow/store.test.ts | 37 + apps/sim/stores/workflows/workflow/store.ts | 28 +- .../0310_codex_configuration_layers.sql | 2 + .../db/migrations/meta/0310_snapshot.json | 21324 ++++++++++++++++ packages/db/migrations/meta/_journal.json | 9 +- packages/db/schema.ts | 7 + packages/testing/src/mocks/schema.mock.ts | 2 + .../workflow-block/workflow-block-view.tsx | 4 + packages/workflow-types/src/blocks.ts | 1 + 97 files changed, 27850 insertions(+), 60 deletions(-) create mode 100644 apps/docs/content/docs/en/workflows/blocks/codex.mdx create mode 100644 apps/sim/app/api/workflows/[id]/codex-config/route.ts create mode 100644 apps/sim/app/api/workspaces/[id]/codex-config/route.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/settings/components/codex/codex.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/agent-session-selector/agent-session-selector.test.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/agent-session-selector/agent-session-selector.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/agent-session-selector/index.ts create mode 100644 apps/sim/blocks/blocks/codex.test.ts create mode 100644 apps/sim/blocks/blocks/codex.ts create mode 100644 apps/sim/components/codex/codex-agent-config-modal.tsx create mode 100644 apps/sim/components/codex/codex-config-editor.tsx create mode 100644 apps/sim/executor/handlers/codex/cloud/authoring.test.ts create mode 100644 apps/sim/executor/handlers/codex/cloud/authoring.ts create mode 100644 apps/sim/executor/handlers/codex/cloud/plan.test.ts create mode 100644 apps/sim/executor/handlers/codex/cloud/plan.ts create mode 100644 apps/sim/executor/handlers/codex/cloud/shared.test.ts create mode 100644 apps/sim/executor/handlers/codex/cloud/shared.ts create mode 100644 apps/sim/executor/handlers/codex/codex-handler.test.ts create mode 100644 apps/sim/executor/handlers/codex/codex-handler.ts create mode 100644 apps/sim/executor/handlers/codex/core/backend.ts create mode 100644 apps/sim/executor/handlers/codex/core/command.test.ts create mode 100644 apps/sim/executor/handlers/codex/core/command.ts create mode 100644 apps/sim/executor/handlers/codex/core/config.test.ts create mode 100644 apps/sim/executor/handlers/codex/core/config.ts create mode 100644 apps/sim/executor/handlers/codex/core/events.test.ts create mode 100644 apps/sim/executor/handlers/codex/core/events.ts create mode 100644 apps/sim/executor/handlers/codex/core/fixtures/reconnect-success.jsonl create mode 100644 apps/sim/executor/handlers/codex/core/fixtures/success.jsonl create mode 100644 apps/sim/executor/handlers/codex/core/fixtures/turn-failed.jsonl create mode 100644 apps/sim/executor/handlers/codex/core/redaction.ts create mode 100644 apps/sim/executor/handlers/codex/core/session.test.ts create mode 100644 apps/sim/executor/handlers/codex/core/session.ts create mode 100644 apps/sim/hooks/queries/codex-config.ts create mode 100644 apps/sim/hooks/use-agent-session-catalog.ts create mode 100644 apps/sim/lib/api/contracts/codex-config.test.ts create mode 100644 apps/sim/lib/api/contracts/codex-config.ts create mode 100644 apps/sim/lib/codex/config.test.ts create mode 100644 apps/sim/lib/codex/config.ts create mode 100644 apps/sim/lib/execution/remote-sandbox/codex-lifetime.test.ts create mode 100644 apps/sim/lib/execution/remote-sandbox/codex-lifetime.ts create mode 100644 apps/sim/lib/workflows/agent-sessions.test.ts create mode 100644 apps/sim/lib/workflows/agent-sessions.ts create mode 100644 apps/sim/providers/codex.ts create mode 100644 apps/sim/scripts/build-codex-daytona-snapshot.ts create mode 100644 apps/sim/scripts/build-codex-e2b-template.ts create mode 100644 apps/sim/scripts/codex-sandbox-packages.test.ts create mode 100644 apps/sim/scripts/codex-sandbox-packages.ts create mode 100644 packages/db/migrations/0310_codex_configuration_layers.sql create mode 100644 packages/db/migrations/meta/0310_snapshot.json diff --git a/apps/docs/content/docs/en/workflows/blocks/codex.mdx b/apps/docs/content/docs/en/workflows/blocks/codex.mdx new file mode 100644 index 00000000000..abe8115828c --- /dev/null +++ b/apps/docs/content/docs/en/workflows/blocks/codex.mdx @@ -0,0 +1,109 @@ +--- +title: Codex Coding Agent +description: Run OpenAI Codex in an isolated repository sandbox to produce an implementation plan or create a pull request. +--- + +import { Callout } from 'fumadocs-ui/components/callout' +import { BlockPreview } from '@/components/workflow-preview' + +The **Codex Coding Agent** block runs a pinned OpenAI Codex CLI in an isolated E2B or Daytona sandbox. A logical agent can keep its native Codex thread and repository checkout across multiple blocks or loop rounds, inspect a GitHub repository and return a plan, or implement a task and maintain a pull request. + + + +## Modes + +### Plan + +Plan clones the repository into a disposable checkout, removes the authenticated Git remote, and lets Codex read files, search, and run checks. It returns a Markdown plan and performs no commit, push, pull request, or other GitHub write. + +Later turns for the same agent continue the native Codex thread in the same checkout, so one step can investigate and another can refine or challenge the plan without rebuilding context. + +### Create PR + +Create PR clones the selected base branch, asks Codex to edit the checkout, and waits for a successful `turn.completed` event. Sim then performs the credentialed delivery steps separately: + +1. Verify that repository-local Git configuration did not change during the Codex turn. +2. Stage and commit the changes without an OpenAI or GitHub credential in scope. +3. Capture the changed files and a bounded unified diff. +4. Push the new branch with the GitHub token. +5. Create the pull request through Sim's GitHub integration. + +If Codex makes no changes, the block returns successfully without pushing a branch or opening a pull request. + +Later turns for the same agent continue editing the same local branch. Sim pushes the new commit to the existing branch and returns the existing pull request instead of creating another one. + +## Agent instances and session reuse + +Each block is its own agent by default. Repeating that block in a loop continues the same sandbox, checkout, and native Codex thread. + +Choose an existing **Agent** when multiple Codex blocks should address one logical agent. The picker uses friendly labels such as Agent 1 and Agent 2; internal IDs are generated and managed automatically. Steps with the same agent share the instance and their turns run serially. Different agents create isolated instances and may run concurrently. Stable runtime configuration belongs to that logical Agent, so every step resolves the same mode, model, repository, and base branch. + +Choose **New agent** to split a step into an independent sandbox and Codex thread. Copying a block also creates an independent agent by default. When several blocks that share an agent are copied together, the copied group keeps sharing with itself but not with the original group. + +Agent instances are execution-scoped: Sim closes all of them when the uninterrupted workflow execution succeeds, fails, pauses, or is cancelled. A later independent workflow execution starts fresh. Durable reuse across independent executions requires a persistent runner and is not inferred from a thread ID alone, because Codex resume also requires its local rollout state. + +## Configuration + +Codex configuration is a sparse overlay, similar to a Kustomize patch. Resolution runs in this order: + +1. Workspace profile +2. Workflow defaults +3. Agent settings +4. Step override + +Only keys explicitly set at a layer are stored there; missing keys inherit. A Workspace change therefore reaches every Workflow, Agent, and Step that has not overridden that field. Sim freezes the resolved layers for an uninterrupted execution, so a settings edit cannot change an Agent halfway through a run. + +- **Workspace profile** — shared defaults managed under **Settings → Codex**. +- **Workflow defaults / Agent settings** — opened from **Configure** below the Agent picker. +- **Task** — what Codex should plan or implement. +- **Agent** — choose a workflow agent to reuse, or create a new independent one. Sim manages its internal ID. +- **Mode / Model / Repository / Base Branch / Agent Shell Network** — stable layered settings, normally configured on the Agent or inherited from the Workflow and Workspace. +- **OpenAI API Key** — your key, entered on the block or stored as OpenAI BYOK. Sim never substitutes a hosted model key for this block. +- **GitHub Token** — clone access for Plan; clone, push, and pull-request write access for Create PR. +- **Reasoning Effort (Step Override)** *(advanced)* — `low`, `medium`, `high`, or `xhigh`; leave blank to inherit the Agent/Workflow/Workspace value. +- **Branch Name / Draft / PR Title / PR Body** *(advanced)* — optional step-local pull-request delivery settings, used in Create PR mode. + +## Isolation + +Every agent instance receives a private `CODEX_HOME`. Its rollout files are retained only while that workflow execution is active so later turns can use `codex exec resume`. The runtime ignores user config and execpolicy rules, disables hooks, plugins, apps, collaboration, skill discovery, and persisted goals, and runs with the `workspace-write` sandbox. Headless Codex runs never request approval. The shell environment is restricted so model-generated commands do not inherit `OPENAI_API_KEY`. + +The GitHub token is present only during clone and push or in the host-side pull-request API call. It is never placed in the Codex process environment. + + + Repository contents are untrusted instructions. Keep **Agent Shell Network** off unless the task needs it, use narrowly scoped credentials, and review every generated pull request before merging. + + +The MVP does not resume Codex threads across independent workflow executions and does not expose Sim tools, MCP servers, plugins, hooks, or mid-turn human approval. Those capabilities require the persistent app-server runner planned for a later phase. + +## Outputs + +| Output | Description | +| --- | --- | +| `` | Final Codex message or Markdown plan | +| `` | Model selected for the run | +| `` | Terminal status (`completed` for returned outputs; failures fail the block) | +| `` | Resolved logical agent instance ID | +| `` | Whether this turn continued an existing instance | +| `` | One-based turn number within the instance | +| `` | Native Codex thread ID resumed by later turns in this execution | +| `` | Bounded command, patch, and tool summaries | +| `` | Files changed in Create PR mode | +| `` | Bounded unified diff in Create PR mode | +| `` | Branch pushed in Create PR mode | +| `` | Pull request URL in Create PR mode | +| `` | Input, cache, output, and reasoning token counts | +| `` | Sim-attributed model cost; zero because this block is BYOK-only | +| `` | Start time, end time, and duration | + +## Self-hosted setup + +Build the dedicated image after setting the provider API key: + +```bash +bun run apps/sim/scripts/build-codex-e2b-template.ts --name sim-codex +bun run apps/sim/scripts/build-codex-daytona-snapshot.ts --name sim-codex: +``` + +For E2B, set `SANDBOX_PROVIDER=e2b`, `E2B_API_KEY`, and `E2B_CODEX_TEMPLATE_ID`. For Daytona, set `SANDBOX_PROVIDER=daytona`, `DAYTONA_API_KEY`, and `DAYTONA_CODEX_SNAPSHOT_ID`. + +The image pins `@openai/codex@0.146.0`. Upgrade the package contract, JSONL fixtures, parser tests, and both provider images together. diff --git a/apps/sim/.env.example b/apps/sim/.env.example index 443ff1d2da9..719dbeef783 100644 --- a/apps/sim/.env.example +++ b/apps/sim/.env.example @@ -42,6 +42,8 @@ NEXT_PUBLIC_APP_URL=http://localhost:3000 # E2B_FUNCTION_TEMPLATE_ID=: # Copy the exact ref printed by the builder # E2B_FUNCTION_TEMPLATE_GENERATION= # Copy the monotonic generation printed by the builder # MOTHERSHIP_E2B_TEMPLATE_ID= # Mothership shell template ref, required when Mothership runs code on E2B +# E2B_CODEX_TEMPLATE_ID=sim-codex # Build with apps/sim/scripts/build-codex-e2b-template.ts +# CODEX_SANDBOX_LIFETIME_MS= # Optional lower lifetime ceiling; values below 32 minutes are raised # # Daytona # Build from an accepted E2B parity manifest with: bun run apps/sim/scripts/build-function-daytona-snapshot.ts --name --parity-manifest @@ -49,6 +51,7 @@ NEXT_PUBLIC_APP_URL=http://localhost:3000 # DAYTONA_API_KEY= # DAYTONA_FUNCTION_SNAPSHOT_ID= # Copy the immutable ID printed by the Daytona builder # DAYTONA_SHELL_SNAPSHOT_ID= # Mothership shell snapshot ref, required when Mothership runs code on Daytona +# DAYTONA_CODEX_SNAPSHOT_ID=sim-codex: # Build with apps/sim/scripts/build-codex-daytona-snapshot.ts # Security (Required) ENCRYPTION_KEY=your_encryption_key # Use `openssl rand -hex 32` to generate, used to encrypt environment variables diff --git a/apps/sim/app/api/workflows/[id]/codex-config/route.ts b/apps/sim/app/api/workflows/[id]/codex-config/route.ts new file mode 100644 index 00000000000..3aabfe0dcd1 --- /dev/null +++ b/apps/sim/app/api/workflows/[id]/codex-config/route.ts @@ -0,0 +1,84 @@ +import { db } from '@sim/db' +import { workflow } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { permissionSatisfies } from '@sim/platform-authz/workspace' +import { getErrorMessage } from '@sim/utils/errors' +import { eq } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { updateWorkflowCodexConfigContract } from '@/lib/api/contracts/codex-config' +import { parseRequest } from '@/lib/api/server' +import { getSession } from '@/lib/auth' +import { compactCodexWorkflowConfig, parseCodexWorkflowConfig } from '@/lib/codex/config' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' + +const logger = createLogger('WorkflowCodexConfigAPI') + +async function loadAuthorizedWorkflow(workflowId: string, requireWrite: boolean) { + const session = await getSession() + if (!session?.user?.id) + return { response: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + + const [row] = await db + .select({ + id: workflow.id, + userId: workflow.userId, + workspaceId: workflow.workspaceId, + config: workflow.codexConfig, + }) + .from(workflow) + .where(eq(workflow.id, workflowId)) + .limit(1) + if (!row) return { response: NextResponse.json({ error: 'Workflow not found' }, { status: 404 }) } + + if (!row.workspaceId) { + if (row.userId !== session.user.id) { + return { response: NextResponse.json({ error: 'Workflow not found' }, { status: 404 }) } + } + return { row, userId: session.user.id } + } + + const permission = await getUserEntityPermissions(session.user.id, 'workspace', row.workspaceId) + if (!permission || (requireWrite && !permissionSatisfies(permission, 'write'))) { + return { response: NextResponse.json({ error: 'Workflow not found' }, { status: 404 }) } + } + return { row, userId: session.user.id } +} + +export const GET = withRouteHandler( + async (_request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + const workflowId = (await params).id + const auth = await loadAuthorizedWorkflow(workflowId, false) + if (auth.response) return auth.response + return NextResponse.json({ config: parseCodexWorkflowConfig(auth.row.config) }) + } +) + +export const PUT = withRouteHandler( + async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { + const workflowId = (await context.params).id + const auth = await loadAuthorizedWorkflow(workflowId, true) + if (auth.response) return auth.response + + const parsed = await parseRequest(updateWorkflowCodexConfigContract, request, context) + if (!parsed.success) return parsed.response + + try { + const config = compactCodexWorkflowConfig(parseCodexWorkflowConfig(parsed.data.body.config)) + const [updated] = await db + .update(workflow) + .set({ codexConfig: config, updatedAt: new Date() }) + .where(eq(workflow.id, workflowId)) + .returning({ config: workflow.codexConfig }) + if (!updated) return NextResponse.json({ error: 'Workflow not found' }, { status: 404 }) + return NextResponse.json({ config: parseCodexWorkflowConfig(updated.config) }) + } catch (error) { + logger.error('Failed to update workflow Codex configuration', { + workflowId, + userId: auth.userId, + error: getErrorMessage(error), + }) + return NextResponse.json({ error: 'Failed to update Codex configuration' }, { status: 500 }) + } + } +) diff --git a/apps/sim/app/api/workspaces/[id]/codex-config/route.ts b/apps/sim/app/api/workspaces/[id]/codex-config/route.ts new file mode 100644 index 00000000000..fe2b416c237 --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/codex-config/route.ts @@ -0,0 +1,73 @@ +import { db } from '@sim/db' +import { workspace } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { permissionSatisfies } from '@sim/platform-authz/workspace' +import { getErrorMessage } from '@sim/utils/errors' +import { eq } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { updateWorkspaceCodexConfigContract } from '@/lib/api/contracts/codex-config' +import { parseRequest } from '@/lib/api/server' +import { getSession } from '@/lib/auth' +import { parseCodexConfigPatch } from '@/lib/codex/config' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' + +const logger = createLogger('WorkspaceCodexConfigAPI') + +async function authorize(workspaceId: string, requireWrite: boolean) { + const session = await getSession() + if (!session?.user?.id) + return { response: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + + const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) + if (!permission || (requireWrite && !permissionSatisfies(permission, 'write'))) { + return { response: NextResponse.json({ error: 'Workspace not found' }, { status: 404 }) } + } + return { userId: session.user.id } +} + +export const GET = withRouteHandler( + async (_request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + const workspaceId = (await params).id + const auth = await authorize(workspaceId, false) + if (auth.response) return auth.response + + const [row] = await db + .select({ config: workspace.codexConfig }) + .from(workspace) + .where(eq(workspace.id, workspaceId)) + .limit(1) + if (!row) return NextResponse.json({ error: 'Workspace not found' }, { status: 404 }) + + return NextResponse.json({ config: parseCodexConfigPatch(row.config) }) + } +) + +export const PUT = withRouteHandler( + async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { + const workspaceId = (await context.params).id + const auth = await authorize(workspaceId, true) + if (auth.response) return auth.response + + const parsed = await parseRequest(updateWorkspaceCodexConfigContract, request, context) + if (!parsed.success) return parsed.response + + try { + const config = parseCodexConfigPatch(parsed.data.body.config) + const [updated] = await db + .update(workspace) + .set({ codexConfig: config, updatedAt: new Date() }) + .where(eq(workspace.id, workspaceId)) + .returning({ config: workspace.codexConfig }) + if (!updated) return NextResponse.json({ error: 'Workspace not found' }, { status: 404 }) + return NextResponse.json({ config: parseCodexConfigPatch(updated.config) }) + } catch (error) { + logger.error('Failed to update workspace Codex configuration', { + workspaceId, + userId: auth.userId, + error: getErrorMessage(error), + }) + return NextResponse.json({ error: 'Failed to update Codex configuration' }, { status: 500 }) + } + } +) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx b/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx index 0cf8a89a781..c6193f45455 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx @@ -36,6 +36,7 @@ const WORKSPACE_SECTION_MAP: Partial const BYOK = dynamic(() => import('@/app/workspace/[workspaceId]/settings/components/byok/byok').then((m) => m.BYOK) ) +const CodexSettings = dynamic(() => + import('@/app/workspace/[workspaceId]/settings/components/codex/codex').then( + (m) => m.CodexSettings + ) +) const Forks = dynamic(() => import('@/ee/workspace-forking/components/forks').then((m) => m.Forks)) const Secrets = dynamic(() => import('@/app/workspace/[workspaceId]/settings/components/secrets/secrets').then((m) => m.Secrets) @@ -201,6 +206,7 @@ export function SettingsPage({ section }: SettingsPageProps) { )} {effectiveSection === 'byok' && } + {effectiveSection === 'codex' && } {effectiveSection === 'sandboxes' && } {effectiveSection === 'mcp' && } {effectiveSection === 'forks' && } diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/codex/codex.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/codex/codex.tsx new file mode 100644 index 00000000000..053a0c5c04c --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/codex/codex.tsx @@ -0,0 +1,128 @@ +'use client' + +import { useEffect, useMemo, useState } from 'react' +import { Chip, toast } from '@sim/emcn' +import { getErrorMessage } from '@sim/utils/errors' +import { isEqual } from 'es-toolkit' +import { useParams } from 'next/navigation' +import { CodexConfigEditor } from '@/components/codex/codex-config-editor' +import { type CodexConfigPatch, resolveCodexConfig } from '@/lib/codex/config' +import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' +import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' +import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' +import { + useUpdateWorkspaceCodexConfig, + useWorkspaceCodexConfig, +} from '@/hooks/queries/codex-config' + +/** Workspace-wide base overlay inherited by every Codex workflow and Agent. */ +export function CodexSettings() { + const params = useParams<{ workspaceId: string }>() + const workspaceId = params.workspaceId + const permissions = useUserPermissionsContext() + const canEdit = permissions.canEdit + const query = useWorkspaceCodexConfig(workspaceId) + const update = useUpdateWorkspaceCodexConfig() + const [draft, setDraft] = useState(null) + + useEffect(() => { + if (query.data && draft === null) setDraft(query.data.config) + }, [draft, query.data]) + + const systemDefaults = useMemo(() => resolveCodexConfig({}), []) + const saved = query.data?.config ?? {} + const dirty = draft !== null && !isEqual(draft, saved) + + const handleSave = async () => { + if (!draft) return + try { + await update.mutateAsync({ workspaceId, config: draft }) + toast.success('Workspace Codex defaults saved') + } catch (error) { + toast.error(getErrorMessage(error, 'Failed to save Codex defaults')) + } + } + + if (query.isLoading || (draft === null && !query.error)) { + return ( + +
+ Loading Codex defaults… +
+
+ ) + } + + if (query.error || draft === null) { + return ( + +
+ {getErrorMessage(query.error, 'Failed to load Codex defaults')} +
+
+ ) + } + + return ( + +
+ void handleSave()} + disabled={!canEdit || !dirty || update.isPending} + > + {update.isPending ? 'Saving…' : 'Save'} + + } + > +
+

+ This is the shared base layer for every workflow in the workspace. Each workflow, + Agent, and step stores only its own overrides, so changing a value here updates all + descendants that still inherit it. +

+ +
+
+ + +
+ {['Workspace', 'Workflow', 'Agent', 'Step'].map((layer, index) => ( +
+ {layer} + {index < 3 && ( + + → + + )} +
+ ))} +
+

+ Later layers win per field. Reasoning effort is step-overridable; stable repository and + runtime settings normally stop at the Agent layer. +

+
+ + +

+ API keys are intentionally outside these overlays. Configure the OpenAI key under BYOK + and keep GitHub tokens in Secrets so configuration inheritance never copies secret + values into workflow metadata. +

+
+
+
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/navigation.test.ts b/apps/sim/app/workspace/[workspaceId]/settings/navigation.test.ts index b3c64e62c0d..b5557e3dd9d 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/navigation.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/settings/navigation.test.ts @@ -38,6 +38,7 @@ describe('unified settings navigation', () => { { id: 'apikeys', label: 'Sim API keys', section: 'workspace' }, { id: 'workflow-mcp-servers', label: 'MCP servers', section: 'workspace' }, { id: 'byok', label: 'BYOK', section: 'workspace' }, + { id: 'codex', label: 'Codex', section: 'workspace' }, { id: 'sandboxes', label: 'Sandboxes', section: 'workspace' }, { id: 'inbox', label: 'Sim Mailer', section: 'workspace' }, { id: 'recently-deleted', label: 'Recently deleted', section: 'workspace' }, @@ -73,6 +74,7 @@ describe('unified settings navigation', () => { 'mcp', 'custom-tools', 'byok', + 'codex', 'inbox', 'workflow-mcp-servers', 'apikeys', diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/agent-session-selector/agent-session-selector.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/agent-session-selector/agent-session-selector.test.tsx new file mode 100644 index 00000000000..9fbbcd51295 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/agent-session-selector/agent-session-selector.test.tsx @@ -0,0 +1,168 @@ +/** + * @vitest-environment jsdom + */ +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { AgentSessionCatalogEntry } from '@/lib/workflows/agent-sessions' + +;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + +interface CapturedOption { + label: string + value: string + onSelect?: () => void +} + +interface CapturedComboboxProps { + value?: string + groups?: Array<{ section?: string; items: CapturedOption[] }> + onChange?: (value: string) => void + overlayContent?: React.ReactNode +} + +const mocks = vi.hoisted(() => ({ + comboboxProps: { current: null as CapturedComboboxProps | null }, + batchSet: vi.fn(), + currentValues: { + agentId: '', + mode: 'cloud_plan', + model: 'gpt-5.2-codex', + owner: 'old-owner', + repo: 'old-repo', + baseBranch: null, + } as Record, + sessions: [] as AgentSessionCatalogEntry[], +})) + +vi.mock('@sim/emcn', () => ({ + Chip: ({ children }: { children: React.ReactNode }) => , + ChipCombobox: (props: CapturedComboboxProps) => { + mocks.comboboxProps.current = props + return
{props.overlayContent}
+ }, +})) + +vi.mock('@sim/emcn/icons', () => ({ Plus: () => null, Settings: () => null })) +vi.mock('@sim/utils/id', () => ({ generateId: () => 'fresh-agent-id' })) +vi.mock('next/navigation', () => ({ + useParams: () => ({ workspaceId: 'workspace-1', workflowId: 'workflow-1' }), +})) +vi.mock('@/components/codex/codex-agent-config-modal', () => ({ + CodexAgentConfigModal: () => null, +})) +vi.mock('@/hooks/use-agent-session-catalog', () => ({ + useAgentSessionCatalog: () => ({ + sessions: mocks.sessions, + currentSession: mocks.sessions[0] ?? null, + }), +})) +vi.mock('@/hooks/use-collaborative-workflow', () => ({ + useCollaborativeWorkflow: () => ({ + collaborativeBatchSetSubblockValues: mocks.batchSet, + }), +})) +vi.mock('@/stores/workflows/subblock/store', () => ({ + useSubBlockStore: { + getState: () => ({ + getValue: (_blockId: string, subBlockId: string) => mocks.currentValues[subBlockId], + }), + }, +})) +vi.mock('@/stores/workflows/workflow/store', () => ({ + useWorkflowStore: (selector: (state: { blocks: Record }) => unknown) => + selector({ blocks: { codex: { type: 'codex' } } }), +})) + +import { AgentSessionSelector } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/agent-session-selector/agent-session-selector' + +const CONFIG = { + id: 'agentId', + title: 'Agent', + type: 'agent-session-selector' as const, + agentSessionFields: ['mode', 'model', 'owner', 'repo', 'baseBranch'], +} + +let container: HTMLDivElement +let root: Root + +describe('AgentSessionSelector', () => { + beforeEach(() => { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + mocks.sessions = [ + { + id: 'current-internal-id', + label: 'Agent 1', + color: '#6366f1', + blockIds: ['codex'], + blockNames: ['Codex'], + sourceBlockId: 'codex', + values: {}, + }, + { + id: 'target-internal-id', + label: 'Agent 2', + color: '#0284c7', + blockIds: ['other'], + blockNames: ['Other Codex'], + sourceBlockId: 'other', + values: { + mode: 'cloud', + model: 'gpt-5.3-codex', + owner: 'new-owner', + repo: 'new-repo', + baseBranch: 'main', + }, + }, + ] + }) + + afterEach(() => { + act(() => root.unmount()) + container.remove() + mocks.comboboxProps.current = null + mocks.batchSet.mockReset() + }) + + it('shows friendly labels and atomically inherits an existing agent configuration', async () => { + await act(async () => { + root.render() + }) + + expect( + mocks.comboboxProps.current?.groups?.flatMap((group) => group.items.map(({ label }) => label)) + ).toEqual(['New agent', 'Agent 1', 'Agent 2']) + + act(() => mocks.comboboxProps.current?.onChange?.('target-internal-id')) + + expect(mocks.batchSet).toHaveBeenCalledOnce() + expect(mocks.batchSet).toHaveBeenCalledWith([ + { blockId: 'codex', subblockId: 'agentId', value: 'target-internal-id', expectedValue: '' }, + { blockId: 'codex', subblockId: 'mode', value: 'cloud', expectedValue: 'cloud_plan' }, + { + blockId: 'codex', + subblockId: 'model', + value: 'gpt-5.3-codex', + expectedValue: 'gpt-5.2-codex', + }, + { blockId: 'codex', subblockId: 'owner', value: 'new-owner', expectedValue: 'old-owner' }, + { blockId: 'codex', subblockId: 'repo', value: 'new-repo', expectedValue: 'old-repo' }, + { blockId: 'codex', subblockId: 'baseBranch', value: 'main', expectedValue: null }, + ]) + }) + + it('creates a new hidden identity without clearing the current configuration', async () => { + await act(async () => { + root.render() + }) + + const newAgent = mocks.comboboxProps.current?.groups?.[0].items[0] + act(() => newAgent?.onSelect?.()) + + expect(mocks.batchSet).toHaveBeenCalledWith([ + { blockId: 'codex', subblockId: 'agentId', value: 'fresh-agent-id', expectedValue: '' }, + ]) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/agent-session-selector/agent-session-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/agent-session-selector/agent-session-selector.tsx new file mode 100644 index 00000000000..358286ac4d3 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/agent-session-selector/agent-session-selector.tsx @@ -0,0 +1,186 @@ +'use client' + +import { useCallback, useMemo, useState } from 'react' +import { Chip, ChipCombobox } from '@sim/emcn' +import { Plus, Settings } from '@sim/emcn/icons' +import { generateId } from '@sim/utils/id' +import { isEqual } from 'es-toolkit' +import { useParams } from 'next/navigation' +import { CodexAgentConfigModal } from '@/components/codex/codex-agent-config-modal' +import { getAgentSessionColor, resolveAgentSessionId } from '@/lib/workflows/agent-sessions' +import type { SubBlockConfig } from '@/blocks/types' +import { useAgentSessionCatalog } from '@/hooks/use-agent-session-catalog' +import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow' +import { useSubBlockStore } from '@/stores/workflows/subblock/store' +import { useWorkflowStore } from '@/stores/workflows/workflow/store' + +interface AgentSessionSelectorProps { + blockId: string + subBlock: SubBlockConfig + disabled?: boolean + isPreview?: boolean + previewValue?: unknown +} + +function AgentColorDot({ color }: { color: string }) { + return ( +