diff --git a/apps/codex-claw/src/routes/api/send.ts b/apps/codex-claw/src/routes/api/send.ts index 12546af..ab2c823 100644 --- a/apps/codex-claw/src/routes/api/send.ts +++ b/apps/codex-claw/src/routes/api/send.ts @@ -126,6 +126,9 @@ export const Route = createFileRoute('/api/send')({ const message = String(body.message ?? '') const thinking = typeof body.thinking === 'string' ? body.thinking : undefined + const runProfile = + typeof body.runProfile === 'string' ? body.runProfile : undefined + const confirmedRisk = body.confirmedRisk === true const parsedAttachments = parseAttachments(body.attachments) if (!parsedAttachments.ok) { @@ -179,6 +182,8 @@ export const Route = createFileRoute('/api/send')({ thinking, attachments, contextBlock, + runProfile, + confirmedRisk, idempotencyKey: typeof body.idempotencyKey === 'string' ? body.idempotencyKey diff --git a/apps/codex-claw/src/routes/api/workspaces.ts b/apps/codex-claw/src/routes/api/workspaces.ts index accb373..fdc2dd1 100644 --- a/apps/codex-claw/src/routes/api/workspaces.ts +++ b/apps/codex-claw/src/routes/api/workspaces.ts @@ -20,6 +20,12 @@ function workspaceInput(body: Record) { typeof body.codexSandbox === 'string' ? body.codexSandbox.trim() : undefined, + codexApproval: + typeof body.codexApproval === 'string' + ? body.codexApproval.trim() + : undefined, + runProfile: + typeof body.runProfile === 'string' ? body.runProfile.trim() : undefined, codexWorkdir: typeof body.codexWorkdir === 'string' ? body.codexWorkdir.trim() diff --git a/apps/codex-claw/src/screens/chat/chat-screen.tsx b/apps/codex-claw/src/screens/chat/chat-screen.tsx index a0dc647..ffd8d37 100644 --- a/apps/codex-claw/src/screens/chat/chat-screen.tsx +++ b/apps/codex-claw/src/screens/chat/chat-screen.tsx @@ -44,7 +44,7 @@ import { shouldRedirectToConnect } from './hooks/use-chat-error-state' import { useChatRedirect } from './hooks/use-chat-redirect' import type { AttachmentFile } from '@/components/attachment-button' import type { ChatComposerHelpers } from './components/chat-composer' -import type { RepoContextSelection } from './types' +import type { RepoContextSelection, RunProfileId } from './types' import { useExport } from '@/hooks/use-export' import { useChatSettings } from '@/hooks/use-chat-settings' import { cn, randomUUID } from '@/lib/utils' @@ -234,6 +234,8 @@ export function ChatScreen({ skipOptimistic = false, attachments?: Array, contextSelections?: Array, + runProfile?: RunProfileId, + confirmedRisk?: boolean, ) { let optimisticClientId = '' if (!skipOptimistic) { @@ -279,6 +281,8 @@ export function ChatScreen({ idempotencyKey: randomUUID(), attachments: attachmentsPayload, contextSelections, + runProfile, + confirmedRisk, }), }) .then(async (res) => { @@ -357,6 +361,8 @@ export function ChatScreen({ (body: string, helpers: ChatComposerHelpers) => { const attachments = helpers.attachments const contextSelections = helpers.contextSelections + const runProfile = helpers.runProfile + const confirmedRisk = helpers.confirmedRisk if ( body.length === 0 && (!attachments || attachments.length === 0) && @@ -384,6 +390,8 @@ export function ChatScreen({ optimisticMessage, attachments, contextSelections, + runProfile, + confirmedRisk, }) if (onSessionResolved) { onSessionResolved({ sessionKey, friendlyId }) @@ -424,6 +432,8 @@ export function ChatScreen({ false, attachments, contextSelections, + runProfile, + confirmedRisk, ) }, [ diff --git a/apps/codex-claw/src/screens/chat/components/chat-composer.tsx b/apps/codex-claw/src/screens/chat/components/chat-composer.tsx index 94ddb28..1cb8873 100644 --- a/apps/codex-claw/src/screens/chat/components/chat-composer.tsx +++ b/apps/codex-claw/src/screens/chat/components/chat-composer.tsx @@ -1,13 +1,14 @@ -import { memo, useCallback, useRef, useState } from 'react' +import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { HugeiconsIcon } from '@hugeicons/react' import { ArrowUp02Icon } from '@hugeicons/core-free-icons' +import { fetchWorkspaces, updateWorkspace } from '../chat-queries' import { RepoContextButton, RepoContextPanel, RepoContextSummary, } from './repo-context-picker' import type { Ref } from 'react' -import type { RepoContextSelection } from '../types' +import type { RepoContextSelection, RunProfileId } from '../types' import type { AttachmentFile } from '@/components/attachment-button' import { @@ -34,8 +35,34 @@ type ChatComposerHelpers = { setValue: (value: string) => void attachments?: Array contextSelections?: Array + runProfile?: RunProfileId + confirmedRisk?: boolean } +const runProfiles = [ + { + id: 'read-only-inspect', + label: 'Inspect', + sandbox: 'read-only', + approval: 'untrusted', + requiresConfirmation: false, + }, + { + id: 'workspace-write', + label: 'Write', + sandbox: 'workspace-write', + approval: 'on-request', + requiresConfirmation: true, + }, + { + id: 'elevated-manual-review', + label: 'Elevated', + sandbox: 'danger-full-access', + approval: 'untrusted', + requiresConfirmation: true, + }, +] as const + function ChatComposerComponent({ onSubmit, isLoading, @@ -47,6 +74,10 @@ function ChatComposerComponent({ const [contextSelections, setContextSelections] = useState< Array >([]) + const [runProfile, setRunProfile] = + useState('read-only-inspect') + const [confirmedRisk, setConfirmedRisk] = useState(false) + const [activeWorkspaceId, setActiveWorkspaceId] = useState('') const promptRef = useRef(null) const valueRef = useRef('') const setValueRef = useRef<((value: string) => void) | null>(null) @@ -70,8 +101,29 @@ function ChatComposerComponent({ }) setContextSelections([]) setContextOpen(false) + setConfirmedRisk(false) focusPrompt() }, [focusPrompt]) + useEffect(() => { + let cancelled = false + fetchWorkspaces() + .then((data) => { + if (cancelled) return + const activeWorkspace = data.workspaces.find( + (workspace) => workspace.id === data.activeWorkspaceId, + ) + setActiveWorkspaceId(data.activeWorkspaceId) + if (activeWorkspace?.runProfile) { + setRunProfile(activeWorkspace.runProfile) + } + }) + .catch(() => { + // ignore + }) + return () => { + cancelled = true + } + }, []) const handleFileSelect = useCallback((file: AttachmentFile) => { setAttachments((prev) => [...prev, file]) }, []) @@ -89,6 +141,28 @@ function ChatComposerComponent({ prev.filter((selection) => selection.path !== path), ) }, []) + const activeRunProfile = useMemo(() => { + return ( + runProfiles.find((profile) => profile.id === runProfile) ?? runProfiles[0] + ) + }, [runProfile]) + const handleRunProfileChange = useCallback( + (value: string) => { + const nextProfile = + runProfiles.find((profile) => profile.id === value) ?? runProfiles[0] + setRunProfile(nextProfile.id) + setConfirmedRisk(false) + if (activeWorkspaceId) { + void updateWorkspace({ + id: activeWorkspaceId, + runProfile: nextProfile.id, + codexSandbox: nextProfile.sandbox, + codexApproval: nextProfile.approval, + }) + } + }, + [activeWorkspaceId], + ) const setComposerValue = useCallback( (nextValue: string) => { if (setValueRef.current) { @@ -109,11 +183,14 @@ function ChatComposerComponent({ contextSelections.length === 0 ) return + if (activeRunProfile.requiresConfirmation && !confirmedRisk) return onSubmit(body, { reset, setValue: setComposerValue, attachments: validAttachments, contextSelections, + runProfile, + confirmedRisk, }) focusPrompt() }, [ @@ -124,8 +201,12 @@ function ChatComposerComponent({ setComposerValue, attachments, contextSelections, + runProfile, + confirmedRisk, + activeRunProfile.requiresConfirmation, ]) - const submitDisabled = disabled + const submitDisabled = + disabled || (activeRunProfile.requiresConfirmation && !confirmedRisk) return (
+
+ + + sandbox: {activeRunProfile.sandbox} · approval:{' '} + {activeRunProfile.approval} + + {activeRunProfile.requiresConfirmation ? ( + + ) : null} +
@@ -97,6 +99,27 @@ function WorkspaceField({ label, children }: WorkspaceFieldProps) { const newWorkspaceId = '__new__' const sandboxOptions = ['read-only', 'workspace-write', 'danger-full-access'] +const approvalOptions = ['untrusted', 'on-request', 'never'] +const runProfileOptions = [ + { + id: 'read-only-inspect', + label: 'Read-only inspect', + sandbox: 'read-only', + approval: 'untrusted', + }, + { + id: 'workspace-write', + label: 'Workspace write', + sandbox: 'workspace-write', + approval: 'on-request', + }, + { + id: 'elevated-manual-review', + label: 'Elevated manual review', + sandbox: 'danger-full-access', + approval: 'untrusted', + }, +] as const function emptyWorkspaceDraft( workspace?: WorkspaceSummary | null, @@ -105,6 +128,8 @@ function emptyWorkspaceDraft( name: workspace ? `${workspace.name} copy` : 'New workspace', codexCommand: workspace?.codexCommand ?? 'codex', codexSandbox: workspace?.codexSandbox ?? 'read-only', + codexApproval: workspace?.codexApproval ?? 'untrusted', + runProfile: workspace?.runProfile ?? 'read-only-inspect', codexWorkdir: workspace?.codexWorkdir ?? '', stateDir: workspace?.stateDir ?? '', } @@ -210,6 +235,8 @@ export function SettingsDialog({ name: selectedWorkspace.name, codexCommand: selectedWorkspace.codexCommand, codexSandbox: selectedWorkspace.codexSandbox, + codexApproval: selectedWorkspace.codexApproval, + runProfile: selectedWorkspace.runProfile, codexWorkdir: selectedWorkspace.codexWorkdir, stateDir: selectedWorkspace.stateDir, }) @@ -219,6 +246,16 @@ export function SettingsDialog({ setDraft((current) => ({ ...current, [field]: value })) } + function updateRunProfile(value: string) { + const profile = runProfileOptions.find((option) => option.id === value) + setDraft((current) => ({ + ...current, + runProfile: value as WorkspaceDraft['runProfile'], + codexSandbox: profile?.sandbox ?? current.codexSandbox, + codexApproval: profile?.approval ?? current.codexApproval, + })) + } + function handleNewWorkspace() { setSelectedWorkspaceId(newWorkspaceId) setDraft(emptyWorkspaceDraft(activeWorkspace ?? null)) @@ -376,6 +413,34 @@ export function SettingsDialog({ ))} + + + + + +
diff --git a/apps/codex-claw/src/screens/chat/hooks/use-chat-pending-send.ts b/apps/codex-claw/src/screens/chat/hooks/use-chat-pending-send.ts index c6bb936..edda96e 100644 --- a/apps/codex-claw/src/screens/chat/hooks/use-chat-pending-send.ts +++ b/apps/codex-claw/src/screens/chat/hooks/use-chat-pending-send.ts @@ -8,7 +8,11 @@ import { import type { QueryClient } from '@tanstack/react-query' import type { AttachmentFile } from '@/components/attachment-button' -import type { HistoryResponse, RepoContextSelection } from '../types' +import type { + HistoryResponse, + RepoContextSelection, + RunProfileId, +} from '../types' type UseChatPendingSendInput = { activeFriendlyId: string @@ -26,6 +30,8 @@ type UseChatPendingSendInput = { skipOptimistic: boolean, attachments?: Array, contextSelections?: Array, + runProfile?: RunProfileId, + confirmedRisk?: boolean, ) => void } @@ -100,6 +106,8 @@ export function useChatPendingSend({ true, pending.attachments, pending.contextSelections, + pending.runProfile, + pending.confirmedRisk, ) }, [ activeFriendlyId, diff --git a/apps/codex-claw/src/screens/chat/pending-send.ts b/apps/codex-claw/src/screens/chat/pending-send.ts index d335f48..a67eb1a 100644 --- a/apps/codex-claw/src/screens/chat/pending-send.ts +++ b/apps/codex-claw/src/screens/chat/pending-send.ts @@ -1,4 +1,8 @@ -import type { GatewayMessage, RepoContextSelection } from './types' +import type { + GatewayMessage, + RepoContextSelection, + RunProfileId, +} from './types' import type { AttachmentFile } from '@/components/attachment-button' export type PendingSendPayload = { @@ -8,6 +12,8 @@ export type PendingSendPayload = { optimisticMessage: GatewayMessage attachments?: Array contextSelections?: Array + runProfile?: RunProfileId + confirmedRisk?: boolean } let pendingSend: PendingSendPayload | null = null diff --git a/apps/codex-claw/src/screens/chat/types.ts b/apps/codex-claw/src/screens/chat/types.ts index f5b1e4b..d1e90a1 100644 --- a/apps/codex-claw/src/screens/chat/types.ts +++ b/apps/codex-claw/src/screens/chat/types.ts @@ -102,12 +102,27 @@ export type WorkspaceSummary = { name: string codexCommand: string codexSandbox: string + codexApproval: string + runProfile: RunProfileId codexWorkdir: string stateDir: string createdAt: number updatedAt: number } +export type RunProfileId = + | 'read-only-inspect' + | 'workspace-write' + | 'elevated-manual-review' + +export type RunProfileSummary = { + id: RunProfileId + label: string + sandbox: string + approval: string + requiresConfirmation: boolean +} + export type WorkspaceHealthStatus = 'ok' | 'warning' | 'error' export type WorkspaceHealthCheck = { diff --git a/apps/codex-claw/src/server/codex-cli.test.ts b/apps/codex-claw/src/server/codex-cli.test.ts index b3e4044..b988665 100644 --- a/apps/codex-claw/src/server/codex-cli.test.ts +++ b/apps/codex-claw/src/server/codex-cli.test.ts @@ -7,11 +7,13 @@ import { createCodexWorkspace, deleteCodexWorkspace, getCodexPaths, + listCodexSessions, listCodexWorkspaces, mergeAssistantText, patchCodexWorkspace, processCodexJsonLine, resetCodexServerStateForTests, + sendCodexPrompt, } from './codex-cli' describe('processCodexJsonLine', function () { @@ -119,6 +121,8 @@ describe('codex workspace registry', function () { expect(getCodexPaths().workspace).toMatchObject({ id: 'default', codexCommand: 'codex-test', + codexApproval: 'untrusted', + runProfile: 'read-only-inspect', codexWorkdir: tempDir, stateDir: path.join(tempDir, 'state'), }) @@ -139,11 +143,25 @@ describe('codex workspace registry', function () { name: 'Docs repo', codexCommand: 'codex-next', codexSandbox: 'workspace-write', + codexApproval: 'on-request', + runProfile: 'workspace-write', codexWorkdir: path.join(tempDir, 'docs'), }, }) }) + it('requires confirmation before storing risky run profile messages', function () { + expect(() => + sendCodexPrompt({ + sessionKey: 'risk-test', + message: 'make a write-capable change', + runProfile: 'workspace-write', + }), + ).toThrow('Run profile requires explicit confirmation.') + + expect(listCodexSessions().sessions).toEqual([]) + }) + it('renames and removes non-default workspaces', function () { createCodexWorkspace({ name: 'Feature repo', diff --git a/apps/codex-claw/src/server/codex-cli.ts b/apps/codex-claw/src/server/codex-cli.ts index e1b6f3e..f024435 100644 --- a/apps/codex-claw/src/server/codex-cli.ts +++ b/apps/codex-claw/src/server/codex-cli.ts @@ -78,6 +78,8 @@ type SendCodexPromptInput = { thinking?: string attachments?: Array contextBlock?: string + runProfile?: string + confirmedRisk?: boolean idempotencyKey?: string } @@ -128,6 +130,8 @@ type WorkspaceRecord = { name: string codexCommand: string codexSandbox: string + codexApproval: string + runProfile: RunProfileId codexWorkdir: string stateDir: string createdAt: number @@ -145,11 +149,18 @@ type WorkspaceInput = { name?: string codexCommand?: string codexSandbox?: string + codexApproval?: string + runProfile?: string codexWorkdir?: string stateDir?: string active?: boolean } +type RunProfileId = + | 'read-only-inspect' + | 'workspace-write' + | 'elevated-manual-review' + type WorkspaceHealthStatus = 'ok' | 'warning' | 'error' type WorkspaceHealthCheck = { @@ -201,6 +212,44 @@ const supportedSandboxModes = new Set([ 'workspace-write', 'danger-full-access', ]) +const supportedApprovalPolicies = new Set([ + 'untrusted', + 'on-request', + 'on-failure', + 'never', +]) +const runProfiles: Record< + RunProfileId, + { + id: RunProfileId + label: string + sandbox: string + approval: string + requiresConfirmation: boolean + } +> = { + 'read-only-inspect': { + id: 'read-only-inspect', + label: 'Read-only inspect', + sandbox: 'read-only', + approval: 'untrusted', + requiresConfirmation: false, + }, + 'workspace-write': { + id: 'workspace-write', + label: 'Workspace write', + sandbox: 'workspace-write', + approval: 'on-request', + requiresConfirmation: true, + }, + 'elevated-manual-review': { + id: 'elevated-manual-review', + label: 'Elevated manual review', + sandbox: 'danger-full-access', + approval: 'untrusted', + requiresConfirmation: true, + }, +} let storeCache: SessionStore | null = null let workspaceStoreCache: WorkspaceStore | null = null let stateVersion = 0 @@ -231,6 +280,10 @@ function getCodexSandbox() { return getActiveWorkspace().codexSandbox } +function getCodexApproval() { + return getActiveWorkspace().codexApproval +} + function getCodexWorkdir() { return getActiveWorkspace().codexWorkdir } @@ -244,6 +297,11 @@ function defaultCodexSandbox() { return normalizeSandbox(configured) } +function defaultCodexApproval() { + const configured = process.env.CODEX_CLI_APPROVAL?.trim() + return normalizeApproval(configured) +} + function defaultCodexWorkdir() { const configured = process.env.CODEX_CLI_WORKDIR?.trim() if (configured) return path.resolve(configured) @@ -256,6 +314,8 @@ function defaultWorkspace(now = Date.now()): WorkspaceRecord { name: 'Default workspace', codexCommand: defaultCodexCommand(), codexSandbox: defaultCodexSandbox(), + codexApproval: defaultCodexApproval(), + runProfile: profileFromSandbox(defaultCodexSandbox()), codexWorkdir: defaultCodexWorkdir(), stateDir: getBaseStateDir(), createdAt: now, @@ -269,6 +329,30 @@ function normalizeSandbox(value: string | undefined) { return 'read-only' } +function normalizeApproval(value: string | undefined) { + const trimmed = value?.trim() + if (trimmed && supportedApprovalPolicies.has(trimmed)) return trimmed + return 'untrusted' +} + +function normalizeRunProfile(value: unknown): RunProfileId { + if (typeof value === 'string' && value in runProfiles) { + return value as RunProfileId + } + return 'read-only-inspect' +} + +function profileFromSandbox(sandbox: string): RunProfileId { + if (sandbox === 'workspace-write') return 'workspace-write' + if (sandbox === 'danger-full-access') return 'elevated-manual-review' + return 'read-only-inspect' +} + +function getRunProfile(id?: string) { + const workspace = getActiveWorkspace() + return runProfiles[normalizeRunProfile(id ?? workspace.runProfile)] +} + function normalizeRequiredString(value: unknown, fallback: string) { if (typeof value !== 'string') return fallback const trimmed = value.trim() @@ -309,6 +393,22 @@ function normalizeWorkspaceRecord( value: Partial, fallback: WorkspaceRecord, ): WorkspaceRecord { + const codexSandbox = normalizeSandbox( + value.codexSandbox || fallback.codexSandbox, + ) + const runProfile = + typeof value.runProfile === 'string' + ? normalizeRunProfile(value.runProfile) + : value.codexSandbox + ? profileFromSandbox(codexSandbox) + : fallback.runProfile + const codexApproval = normalizeApproval( + value.codexApproval || + (value.codexSandbox || value.runProfile + ? runProfiles[runProfile].approval + : fallback.codexApproval), + ) + return { id: normalizeRequiredString(value.id, fallback.id), name: normalizeRequiredString(value.name, fallback.name), @@ -316,7 +416,9 @@ function normalizeWorkspaceRecord( value.codexCommand, fallback.codexCommand, ), - codexSandbox: normalizeSandbox(value.codexSandbox || fallback.codexSandbox), + codexSandbox, + codexApproval, + runProfile, codexWorkdir: normalizePath(value.codexWorkdir, fallback.codexWorkdir), stateDir: normalizePath(value.stateDir, fallback.stateDir), createdAt: @@ -584,6 +686,10 @@ function emit(sessionKey: string, event: CodexStreamEvent) { function buildUserPrompt(input: SendCodexPromptInput) { const parts = [input.message.trim()].filter(Boolean) + const profile = getRunProfile(input.runProfile) + parts.push( + `Run profile: ${profile.label} (sandbox: ${profile.sandbox}, approval: ${profile.approval})`, + ) if (input.thinking) { parts.push(`Requested thinking level: ${input.thinking}`) } @@ -598,8 +704,15 @@ function buildUserPrompt(input: SendCodexPromptInput) { return parts.join('\n\n') } -function buildCodexArgs(imagePaths: Array) { - const args = ['-s', getCodexSandbox(), '-C', getCodexWorkdir()] +function buildCodexArgs(imagePaths: Array, profile = getRunProfile()) { + const args = [ + '-s', + profile.sandbox || getCodexSandbox(), + '-a', + profile.approval || getCodexApproval(), + '-C', + getCodexWorkdir(), + ] args.push('exec') args.push('--ignore-user-config') args.push('--json') @@ -935,6 +1048,7 @@ function runCodexExec( prompt: string, runId: string, attachments?: Array, + runProfile?: RunProfileId, ) { const store = readStore() const session = ensureSession(sessionKey) @@ -943,7 +1057,10 @@ function runCodexExec( ? getCodexCommand() : resolveCodexCommand(getCodexCommand()) const preparedAttachments = prepareAttachmentFiles(runId, attachments) - const args = buildCodexArgs(preparedAttachments.imagePaths) + const args = buildCodexArgs( + preparedAttachments.imagePaths, + getRunProfile(runProfile), + ) const child = spawn(command, args, { cwd: getCodexWorkdir(), env: process.env, @@ -1368,6 +1485,8 @@ export function createCodexWorkspace(input: WorkspaceInput) { name, codexCommand: input.codexCommand, codexSandbox: input.codexSandbox, + codexApproval: input.codexApproval, + runProfile: input.runProfile, codexWorkdir: input.codexWorkdir, stateDir: input.stateDir, createdAt: now, @@ -1409,6 +1528,8 @@ export function patchCodexWorkspace(input: WorkspaceInput) { name: input.name ?? existing.name, codexCommand: input.codexCommand ?? existing.codexCommand, codexSandbox: input.codexSandbox ?? existing.codexSandbox, + codexApproval: input.codexApproval ?? existing.codexApproval, + runProfile: input.runProfile ?? existing.runProfile, codexWorkdir: input.codexWorkdir ?? existing.codexWorkdir, stateDir: input.stateDir ?? existing.stateDir, updatedAt: Date.now(), @@ -1501,6 +1622,10 @@ export function subscribeCodexEvents( } export function sendCodexPrompt(input: SendCodexPromptInput) { + const profile = getRunProfile(input.runProfile) + if (profile.requiresConfirmation && !input.confirmedRisk) { + throw new Error('Run profile requires explicit confirmation.') + } const store = readStore() const session = ensureSession(input.sessionKey || 'main') const prompt = buildUserPrompt(input) @@ -1510,6 +1635,11 @@ export function sendCodexPrompt(input: SendCodexPromptInput) { clientId: input.idempotencyKey, role: 'user', timestamp: Date.now(), + details: { + runProfile: profile.id, + sandbox: profile.sandbox, + approval: profile.approval, + }, content: contentFromUserInput(input), } appendMessage(session, userMessage) @@ -1527,7 +1657,7 @@ export function sendCodexPrompt(input: SendCodexPromptInput) { }, }) - runCodexExec(session.key, prompt, runId, input.attachments) + runCodexExec(session.key, prompt, runId, input.attachments, profile.id) return { runId, sessionKey: session.key } }