From 81845c6f58b089893d6795ad39911937a8375fcb Mon Sep 17 00:00:00 2001 From: moe Date: Sun, 31 May 2026 03:26:52 -0400 Subject: [PATCH 1/5] Add workspace registry and health checks --- apps/codex-claw/src/routes/api/ping.ts | 5 +- apps/codex-claw/src/server/codex-cli.ts | 697 ++++++++++++++++++++++-- 2 files changed, 658 insertions(+), 44 deletions(-) diff --git a/apps/codex-claw/src/routes/api/ping.ts b/apps/codex-claw/src/routes/api/ping.ts index ab145e8..61041c0 100644 --- a/apps/codex-claw/src/routes/api/ping.ts +++ b/apps/codex-claw/src/routes/api/ping.ts @@ -5,10 +5,9 @@ import { codexCliCheck } from '../../server/codex-cli' export const Route = createFileRoute('/api/ping')({ server: { handlers: { - GET: async () => { + GET: () => { try { - await codexCliCheck() - return json({ ok: true }) + return json(codexCliCheck()) } catch (err) { return json( { diff --git a/apps/codex-claw/src/server/codex-cli.ts b/apps/codex-claw/src/server/codex-cli.ts index 05d5580..f0311f6 100644 --- a/apps/codex-claw/src/server/codex-cli.ts +++ b/apps/codex-claw/src/server/codex-cli.ts @@ -118,6 +118,53 @@ type CodexPathsPayload = { stateDir: string sessionsDir: string storePath: string + workspacesStorePath: string + workspace: WorkspaceRecord +} + +type WorkspaceRecord = { + id: string + name: string + codexCommand: string + codexSandbox: string + codexWorkdir: string + stateDir: string + createdAt: number + updatedAt: number +} + +type WorkspaceStore = { + version: 1 + activeWorkspaceId: string + workspaces: Array +} + +type WorkspaceInput = { + id?: string + name?: string + codexCommand?: string + codexSandbox?: string + codexWorkdir?: string + stateDir?: string + active?: boolean +} + +type WorkspaceHealthStatus = 'ok' | 'warning' | 'error' + +type WorkspaceHealthCheck = { + id: string + label: string + status: WorkspaceHealthStatus + summary: string + detail?: string + fixCommand?: string +} + +type WorkspaceHealthPayload = { + ok: boolean + workspaceId: string + checkedAt: number + checks: Array } type PreparedAttachmentFiles = { @@ -147,33 +194,218 @@ const supportedImageMimeTypes = new Set([ ]) const listeners = new Map void>>() +const defaultWorkspaceId = 'default' +const supportedSandboxModes = new Set([ + 'read-only', + 'workspace-write', + 'danger-full-access', +]) let storeCache: SessionStore | null = null +let workspaceStoreCache: WorkspaceStore | null = null let stateVersion = 0 -function getStateDir() { +function getBaseStateDir() { const configured = process.env.CODEX_CLAW_STATE_DIR?.trim() if (configured) return path.resolve(configured) return path.join(process.cwd(), '.codex-claw') } +function getWorkspacesStorePath() { + return path.join(getBaseStateDir(), 'workspaces.json') +} + +function getStateDir() { + return getActiveWorkspace().stateDir +} + function getStorePath() { return path.join(getStateDir(), 'sessions.json') } function getCodexCommand() { - return process.env.CODEX_CLI_COMMAND?.trim() || 'codex' + return getActiveWorkspace().codexCommand } function getCodexSandbox() { - return process.env.CODEX_CLI_SANDBOX?.trim() || 'read-only' + return getActiveWorkspace().codexSandbox } function getCodexWorkdir() { + return getActiveWorkspace().codexWorkdir +} + +function defaultCodexCommand() { + return process.env.CODEX_CLI_COMMAND?.trim() || 'codex' +} + +function defaultCodexSandbox() { + const configured = process.env.CODEX_CLI_SANDBOX?.trim() + return normalizeSandbox(configured) +} + +function defaultCodexWorkdir() { const configured = process.env.CODEX_CLI_WORKDIR?.trim() if (configured) return path.resolve(configured) return process.cwd() } +function defaultWorkspace(now = Date.now()): WorkspaceRecord { + return { + id: defaultWorkspaceId, + name: 'Default workspace', + codexCommand: defaultCodexCommand(), + codexSandbox: defaultCodexSandbox(), + codexWorkdir: defaultCodexWorkdir(), + stateDir: getBaseStateDir(), + createdAt: now, + updatedAt: now, + } +} + +function normalizeSandbox(value: string | undefined) { + const trimmed = value?.trim() + if (trimmed && supportedSandboxModes.has(trimmed)) return trimmed + return 'read-only' +} + +function normalizeRequiredString(value: unknown, fallback: string) { + if (typeof value !== 'string') return fallback + const trimmed = value.trim() + return trimmed || fallback +} + +function normalizePath(value: unknown, fallback: string) { + if (typeof value !== 'string' || !value.trim()) return fallback + return path.resolve(value.trim()) +} + +function isWorkspaceRecord(value: unknown): value is WorkspaceRecord { + if (!value || typeof value !== 'object') return false + const candidate = value as Record + return ( + typeof candidate.id === 'string' && + typeof candidate.name === 'string' && + typeof candidate.codexCommand === 'string' && + typeof candidate.codexSandbox === 'string' && + typeof candidate.codexWorkdir === 'string' && + typeof candidate.stateDir === 'string' && + typeof candidate.createdAt === 'number' && + typeof candidate.updatedAt === 'number' + ) +} + +function isWorkspaceStore(value: unknown): value is WorkspaceStore { + if (!value || typeof value !== 'object') return false + const candidate = value as Record + return ( + candidate.version === 1 && + typeof candidate.activeWorkspaceId === 'string' && + Array.isArray(candidate.workspaces) + ) +} + +function normalizeWorkspaceRecord( + value: Partial, + fallback: WorkspaceRecord, +): WorkspaceRecord { + return { + id: normalizeRequiredString(value.id, fallback.id), + name: normalizeRequiredString(value.name, fallback.name), + codexCommand: normalizeRequiredString( + value.codexCommand, + fallback.codexCommand, + ), + codexSandbox: normalizeSandbox(value.codexSandbox || fallback.codexSandbox), + codexWorkdir: normalizePath(value.codexWorkdir, fallback.codexWorkdir), + stateDir: normalizePath(value.stateDir, fallback.stateDir), + createdAt: + typeof value.createdAt === 'number' && Number.isFinite(value.createdAt) + ? value.createdAt + : fallback.createdAt, + updatedAt: + typeof value.updatedAt === 'number' && Number.isFinite(value.updatedAt) + ? value.updatedAt + : fallback.updatedAt, + } +} + +function normalizeWorkspaceStore(value: unknown): WorkspaceStore { + const fallback = defaultWorkspace() + if (!isWorkspaceStore(value)) { + return { + version: 1, + activeWorkspaceId: fallback.id, + workspaces: [fallback], + } + } + + const seen = new Set() + const workspaces = value.workspaces + .filter(isWorkspaceRecord) + .map((workspace) => normalizeWorkspaceRecord(workspace, fallback)) + .filter((workspace) => { + if (seen.has(workspace.id)) return false + seen.add(workspace.id) + return true + }) + + if (!seen.has(fallback.id)) { + workspaces.unshift(fallback) + } + + const activeWorkspaceId = workspaces.some( + (workspace) => workspace.id === value.activeWorkspaceId, + ) + ? value.activeWorkspaceId + : fallback.id + + return { + version: 1, + activeWorkspaceId, + workspaces, + } +} + +function readWorkspaceStore(): WorkspaceStore { + if (workspaceStoreCache) return workspaceStoreCache + const storePath = getWorkspacesStorePath() + if (!existsSync(storePath)) { + workspaceStoreCache = normalizeWorkspaceStore(null) + return workspaceStoreCache + } + + try { + const parsed = JSON.parse(readFileSync(storePath, 'utf8')) as unknown + workspaceStoreCache = normalizeWorkspaceStore(parsed) + } catch { + workspaceStoreCache = normalizeWorkspaceStore(null) + } + + return workspaceStoreCache +} + +function writeWorkspaceStore(store: WorkspaceStore) { + const nextStore = normalizeWorkspaceStore(store) + const stateDir = getBaseStateDir() + mkdirSync(stateDir, { recursive: true }) + const storePath = getWorkspacesStorePath() + const tempPath = `${storePath}.${process.pid}.tmp` + writeFileSync(tempPath, `${JSON.stringify(nextStore, null, 2)}\n`) + renameSync(tempPath, storePath) + workspaceStoreCache = nextStore + storeCache = null + stateVersion += 1 +} + +function getActiveWorkspace() { + const store = readWorkspaceStore() + const activeWorkspace = store.workspaces.find( + (workspace) => workspace.id === store.activeWorkspaceId, + ) + if (activeWorkspace) return activeWorkspace + return store.workspaces[0] || defaultWorkspace() +} + function resolveCodexCommand(command: string) { if (process.platform !== 'win32') return command if (/[/\\]/.test(command) || /\.(cmd|exe|bat)$/i.test(command)) { @@ -376,7 +608,10 @@ function buildCodexArgs(imagePaths: Array) { return args } -function messageFromAssistantText(text: string, id = randomUUID()): CodexMessage { +function messageFromAssistantText( + text: string, + id = randomUUID(), +): CodexMessage { return { id, role: 'assistant', @@ -395,7 +630,9 @@ function errorMessage(text: string): CodexMessage { } } -function imageContentFromAttachment(attachment: AttachmentInput): MessageContent { +function imageContentFromAttachment( + attachment: AttachmentInput, +): MessageContent { return { type: 'image', source: { @@ -406,7 +643,9 @@ function imageContentFromAttachment(attachment: AttachmentInput): MessageContent } } -function contentFromUserInput(input: SendCodexPromptInput): Array { +function contentFromUserInput( + input: SendCodexPromptInput, +): Array { const content: Array = [] for (const attachment of input.attachments ?? []) { @@ -805,16 +1044,19 @@ function runCodexExec( writeStore(store) emitStreamMessage(trailing.message, 'delta') } else { - assistantText = mergeAssistantText( - assistantText, - trailing.text, - trailing.kind, - ) - if (!emittedFinal && (trailing.kind === 'assistant-final' || code === 0)) { - appendFinalText(assistantText) - return - } - if (emittedFinal && code === 0) return + assistantText = mergeAssistantText( + assistantText, + trailing.text, + trailing.kind, + ) + if ( + !emittedFinal && + (trailing.kind === 'assistant-final' || code === 0) + ) { + appendFinalText(assistantText) + return + } + if (emittedFinal && code === 0) return } } @@ -845,6 +1087,390 @@ function runCodexExec( }) } +function workspaceIdFromName(name: string, workspaces: Array) { + const base = + name + .trim() + .toLowerCase() + .replace(/[^a-z0-9_-]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 48) || 'workspace' + const taken = new Set(workspaces.map((workspace) => workspace.id)) + if (!taken.has(base)) return base + + for (let index = 2; index < 1000; index += 1) { + const candidate = `${base}-${index}` + if (!taken.has(candidate)) return candidate + } + + return randomUUID() +} + +function findWorkspace(store: WorkspaceStore, id: string) { + const workspaceId = id.trim() + return store.workspaces.find((workspace) => workspace.id === workspaceId) +} + +function quoteCommandArg(value: string) { + return `"${value.replace(/"/g, '\\"')}"` +} + +function runTool(command: string, args: Array, cwd: string) { + const resolvedCommand = resolveCodexCommand(command) + if (process.platform === 'win32' && /\.(cmd|bat)$/i.test(resolvedCommand)) { + const shellCommand = [resolvedCommand, ...args] + .map(quoteCommandArg) + .join(' ') + return spawnSync(shellCommand, { + cwd, + env: process.env, + stdio: 'pipe', + shell: true, + encoding: 'utf8', + }) + } + + return spawnSync(resolvedCommand, args, { + cwd, + env: process.env, + stdio: 'pipe', + encoding: 'utf8', + }) +} + +function commandOutput(result: ReturnType) { + return String( + result.stdout || result.stderr || result.error?.message || '', + ).trim() +} + +function checkCodexCli(workspace: WorkspaceRecord): WorkspaceHealthCheck { + const cwd = existsSync(workspace.codexWorkdir) + ? workspace.codexWorkdir + : process.cwd() + const result = runTool(workspace.codexCommand, ['--version'], cwd) + const output = commandOutput(result) + + if (result.status === 0) { + return { + id: 'codex-cli', + label: 'Codex CLI', + status: 'ok', + summary: output || 'Codex CLI is available.', + detail: workspace.codexCommand, + } + } + + return { + id: 'codex-cli', + label: 'Codex CLI', + status: 'error', + summary: 'Codex CLI command failed.', + detail: output || `Unable to run ${workspace.codexCommand} --version.`, + fixCommand: + workspace.codexCommand === 'codex' + ? 'npm install -g @openai/codex' + : 'Set this workspace to a working Codex CLI command.', + } +} + +function checkCodexAuth(): WorkspaceHealthCheck { + if (process.env.OPENAI_API_KEY || process.env.CODEX_API_KEY) { + return { + id: 'auth', + label: 'Auth', + status: 'ok', + summary: 'API key environment variable is available.', + } + } + + const authPath = path.join(os.homedir(), '.codex', 'auth.json') + if (existsSync(authPath)) { + return { + id: 'auth', + label: 'Auth', + status: 'ok', + summary: 'Codex auth file is available.', + detail: authPath, + } + } + + return { + id: 'auth', + label: 'Auth', + status: 'warning', + summary: 'Codex auth was not detected.', + detail: 'Set OPENAI_API_KEY/CODEX_API_KEY or sign in with the Codex CLI.', + fixCommand: 'codex login', + } +} + +function checkGitStatus(workspace: WorkspaceRecord): WorkspaceHealthCheck { + if (!existsSync(workspace.codexWorkdir)) { + return { + id: 'git', + label: 'Git', + status: 'error', + summary: 'Workspace directory does not exist.', + detail: workspace.codexWorkdir, + fixCommand: `mkdir ${quoteCommandArg(workspace.codexWorkdir)}`, + } + } + + const repoCheck = runTool( + 'git', + ['rev-parse', '--is-inside-work-tree'], + workspace.codexWorkdir, + ) + if (repoCheck.status !== 0) { + return { + id: 'git', + label: 'Git', + status: 'warning', + summary: 'Workspace is not a git repository.', + detail: commandOutput(repoCheck), + fixCommand: `git -C ${quoteCommandArg(workspace.codexWorkdir)} init`, + } + } + + const status = runTool('git', ['status', '--short'], workspace.codexWorkdir) + const output = commandOutput(status) + return { + id: 'git', + label: 'Git', + status: output ? 'warning' : 'ok', + summary: output + ? 'Git repository has local changes.' + : 'Git repository is clean.', + detail: output, + } +} + +function checkNodeVersion(): WorkspaceHealthCheck { + const version = process.version + const major = Number(version.replace(/^v/, '').split('.')[0] ?? 0) + const supported = Number.isFinite(major) && major >= 20 + + return { + id: 'node', + label: 'Node', + status: supported ? 'ok' : 'error', + summary: supported ? `${version} is supported.` : `${version} is too old.`, + detail: 'CodexClaw expects Node.js 20 or newer.', + fixCommand: supported ? undefined : 'Install Node.js 20 or newer.', + } +} + +function checkPnpm(workspace: WorkspaceRecord): WorkspaceHealthCheck { + const cwd = existsSync(workspace.codexWorkdir) + ? workspace.codexWorkdir + : process.cwd() + const result = runTool('pnpm', ['--version'], cwd) + const output = commandOutput(result) + + if (result.status === 0) { + return { + id: 'pnpm', + label: 'pnpm', + status: 'ok', + summary: `pnpm ${output} is available.`, + } + } + + return { + id: 'pnpm', + label: 'pnpm', + status: 'warning', + summary: 'pnpm was not found on PATH.', + detail: output, + fixCommand: 'corepack enable pnpm', + } +} + +function checkStatePath(workspace: WorkspaceRecord): WorkspaceHealthCheck { + const tempPath = path.join( + workspace.stateDir, + `.health-${process.pid}-${Date.now()}.tmp`, + ) + + try { + mkdirSync(workspace.stateDir, { recursive: true }) + writeFileSync(tempPath, 'ok') + rmSync(tempPath, { force: true }) + return { + id: 'state-path', + label: 'State path', + status: 'ok', + summary: 'State path is writable.', + detail: workspace.stateDir, + } + } catch (err) { + return { + id: 'state-path', + label: 'State path', + status: 'error', + summary: 'State path is not writable.', + detail: err instanceof Error ? err.message : String(err), + fixCommand: `mkdir ${quoteCommandArg(workspace.stateDir)}`, + } + } finally { + rmSync(tempPath, { force: true }) + } +} + +export function getCodexWorkspaceHealth( + workspaceId?: string, +): WorkspaceHealthPayload { + const store = readWorkspaceStore() + const workspace = workspaceId + ? findWorkspace(store, workspaceId) + : getActiveWorkspace() + + if (!workspace) { + throw new Error(`Workspace not found: ${workspaceId}`) + } + + const checks = [ + checkCodexCli(workspace), + checkCodexAuth(), + checkGitStatus(workspace), + checkNodeVersion(), + checkPnpm(workspace), + checkStatePath(workspace), + ] + + return { + ok: checks.every((check) => check.status !== 'error'), + workspaceId: workspace.id, + checkedAt: Date.now(), + checks, + } +} + +export function listCodexWorkspaces() { + const store = readWorkspaceStore() + return { + activeWorkspaceId: store.activeWorkspaceId, + workspaces: store.workspaces, + health: getCodexWorkspaceHealth(store.activeWorkspaceId), + } +} + +export function createCodexWorkspace(input: WorkspaceInput) { + const store = readWorkspaceStore() + const now = Date.now() + const activeWorkspace = getActiveWorkspace() + const name = normalizeRequiredString(input.name, 'Workspace') + const id = workspaceIdFromName(name, store.workspaces) + const workspace = normalizeWorkspaceRecord( + { + id, + name, + codexCommand: input.codexCommand, + codexSandbox: input.codexSandbox, + codexWorkdir: input.codexWorkdir, + stateDir: input.stateDir, + createdAt: now, + updatedAt: now, + }, + { + ...activeWorkspace, + id, + name, + createdAt: now, + updatedAt: now, + }, + ) + + store.workspaces.push(workspace) + if (input.active) store.activeWorkspaceId = workspace.id + writeWorkspaceStore(store) + + return { + ok: true, + activeWorkspaceId: store.activeWorkspaceId, + workspace, + health: getCodexWorkspaceHealth(store.activeWorkspaceId), + } +} + +export function patchCodexWorkspace(input: WorkspaceInput) { + const id = input.id?.trim() + if (!id) throw new Error('workspace id required') + + const store = readWorkspaceStore() + const index = store.workspaces.findIndex((workspace) => workspace.id === id) + if (index < 0) throw new Error(`Workspace not found: ${id}`) + + const existing = store.workspaces[index] + const nextWorkspace = normalizeWorkspaceRecord( + { + ...existing, + name: input.name ?? existing.name, + codexCommand: input.codexCommand ?? existing.codexCommand, + codexSandbox: input.codexSandbox ?? existing.codexSandbox, + codexWorkdir: input.codexWorkdir ?? existing.codexWorkdir, + stateDir: input.stateDir ?? existing.stateDir, + updatedAt: Date.now(), + }, + existing, + ) + + store.workspaces[index] = nextWorkspace + if (input.active) store.activeWorkspaceId = nextWorkspace.id + writeWorkspaceStore(store) + + return { + ok: true, + activeWorkspaceId: store.activeWorkspaceId, + workspace: nextWorkspace, + health: getCodexWorkspaceHealth(store.activeWorkspaceId), + } +} + +export function deleteCodexWorkspace(id: string) { + const workspaceId = id.trim() + if (!workspaceId) throw new Error('workspace id required') + if (workspaceId === defaultWorkspaceId) { + throw new Error('Default workspace cannot be removed.') + } + + const store = readWorkspaceStore() + const index = store.workspaces.findIndex( + (workspace) => workspace.id === workspaceId, + ) + if (index < 0) throw new Error(`Workspace not found: ${workspaceId}`) + + store.workspaces.splice(index, 1) + if (store.activeWorkspaceId === workspaceId) { + store.activeWorkspaceId = defaultWorkspaceId + } + writeWorkspaceStore(store) + + return { + ok: true, + activeWorkspaceId: store.activeWorkspaceId, + health: getCodexWorkspaceHealth(store.activeWorkspaceId), + } +} + +export function activateCodexWorkspace(id: string) { + const workspaceId = id.trim() + const store = readWorkspaceStore() + const workspace = findWorkspace(store, workspaceId) + if (!workspace) throw new Error(`Workspace not found: ${workspaceId}`) + + store.activeWorkspaceId = workspace.id + writeWorkspaceStore(store) + + return { + ok: true, + activeWorkspaceId: store.activeWorkspaceId, + workspace, + health: getCodexWorkspaceHealth(store.activeWorkspaceId), + } +} + export function getCodexPaths(): CodexPathsPayload { const stateDir = getStateDir() return { @@ -852,6 +1478,8 @@ export function getCodexPaths(): CodexPathsPayload { stateDir, sessionsDir: stateDir, storePath: getStorePath(), + workspacesStorePath: getWorkspacesStorePath(), + workspace: getActiveWorkspace(), } } @@ -931,10 +1559,7 @@ export function listCodexSessions() { } } -export function patchCodexSession(input: { - key?: string - label?: string -}) { +export function patchCodexSession(input: { key?: string; label?: string }) { const session = ensureSession(input.key || randomUUID(), input.label) return { ok: true, @@ -965,31 +1590,21 @@ export function deleteCodexSession(key: string) { } export function codexCliCheck() { - const command = getCodexCommand() - const result = - process.platform === 'win32' - ? spawnSync(`${command} --version`, { - cwd: getCodexWorkdir(), - env: process.env, - stdio: 'pipe', - shell: true, - encoding: 'utf8', - }) - : spawnSync(resolveCodexCommand(getCodexCommand()), ['--version'], { - cwd: getCodexWorkdir(), - env: process.env, - stdio: 'pipe', - encoding: 'utf8', - }) + const health = getCodexWorkspaceHealth() + const codexCheck = health.checks.find((check) => check.id === 'codex-cli') - if (result.status === 0) { - return { ok: true } + if (codexCheck?.status !== 'error') { + return { ok: true, health } } - const detail = result.stderr || result.stdout || result.error?.message || '' throw new Error( - detail.trim() || - `Codex CLI command failed. Set CODEX_CLI_COMMAND if ${command} is not correct.`, + codexCheck.detail || + 'Codex CLI command failed. Set this workspace to a working Codex CLI command.', ) } +export function resetCodexServerStateForTests() { + storeCache = null + workspaceStoreCache = null + stateVersion = 0 +} From 8dab3d862b9f5614684fea03e1419b80329bd718 Mon Sep 17 00:00:00 2001 From: moe Date: Sun, 31 May 2026 03:27:03 -0400 Subject: [PATCH 2/5] Wire workspace API into chat settings --- apps/codex-claw/src/routeTree.gen.ts | 21 +++ apps/codex-claw/src/routes/api/workspaces.ts | 98 +++++++++++ .../src/screens/chat/chat-queries.ts | 56 ++++++ .../screens/chat/hooks/use-chat-settings.ts | 160 ++++++++++++++++-- apps/codex-claw/src/screens/chat/types.ts | 37 ++++ 5 files changed, 359 insertions(+), 13 deletions(-) create mode 100644 apps/codex-claw/src/routes/api/workspaces.ts diff --git a/apps/codex-claw/src/routeTree.gen.ts b/apps/codex-claw/src/routeTree.gen.ts index 9fc7637..facec96 100644 --- a/apps/codex-claw/src/routeTree.gen.ts +++ b/apps/codex-claw/src/routeTree.gen.ts @@ -13,6 +13,7 @@ import { Route as NewRouteImport } from './routes/new' import { Route as ConnectRouteImport } from './routes/connect' import { Route as IndexRouteImport } from './routes/index' import { Route as ChatSessionKeyRouteImport } from './routes/chat/$sessionKey' +import { Route as ApiWorkspacesRouteImport } from './routes/api/workspaces' import { Route as ApiStreamRouteImport } from './routes/api/stream' import { Route as ApiSessionsRouteImport } from './routes/api/sessions' import { Route as ApiSendRouteImport } from './routes/api/send' @@ -40,6 +41,11 @@ const ChatSessionKeyRoute = ChatSessionKeyRouteImport.update({ path: '/chat/$sessionKey', getParentRoute: () => rootRouteImport, } as any) +const ApiWorkspacesRoute = ApiWorkspacesRouteImport.update({ + id: '/api/workspaces', + path: '/api/workspaces', + getParentRoute: () => rootRouteImport, +} as any) const ApiStreamRoute = ApiStreamRouteImport.update({ id: '/api/stream', path: '/api/stream', @@ -81,6 +87,7 @@ export interface FileRoutesByFullPath { '/api/send': typeof ApiSendRoute '/api/sessions': typeof ApiSessionsRoute '/api/stream': typeof ApiStreamRoute + '/api/workspaces': typeof ApiWorkspacesRoute '/chat/$sessionKey': typeof ChatSessionKeyRoute } export interface FileRoutesByTo { @@ -93,6 +100,7 @@ export interface FileRoutesByTo { '/api/send': typeof ApiSendRoute '/api/sessions': typeof ApiSessionsRoute '/api/stream': typeof ApiStreamRoute + '/api/workspaces': typeof ApiWorkspacesRoute '/chat/$sessionKey': typeof ChatSessionKeyRoute } export interface FileRoutesById { @@ -106,6 +114,7 @@ export interface FileRoutesById { '/api/send': typeof ApiSendRoute '/api/sessions': typeof ApiSessionsRoute '/api/stream': typeof ApiStreamRoute + '/api/workspaces': typeof ApiWorkspacesRoute '/chat/$sessionKey': typeof ChatSessionKeyRoute } export interface FileRouteTypes { @@ -120,6 +129,7 @@ export interface FileRouteTypes { | '/api/send' | '/api/sessions' | '/api/stream' + | '/api/workspaces' | '/chat/$sessionKey' fileRoutesByTo: FileRoutesByTo to: @@ -132,6 +142,7 @@ export interface FileRouteTypes { | '/api/send' | '/api/sessions' | '/api/stream' + | '/api/workspaces' | '/chat/$sessionKey' id: | '__root__' @@ -144,6 +155,7 @@ export interface FileRouteTypes { | '/api/send' | '/api/sessions' | '/api/stream' + | '/api/workspaces' | '/chat/$sessionKey' fileRoutesById: FileRoutesById } @@ -157,6 +169,7 @@ export interface RootRouteChildren { ApiSendRoute: typeof ApiSendRoute ApiSessionsRoute: typeof ApiSessionsRoute ApiStreamRoute: typeof ApiStreamRoute + ApiWorkspacesRoute: typeof ApiWorkspacesRoute ChatSessionKeyRoute: typeof ChatSessionKeyRoute } @@ -190,6 +203,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ChatSessionKeyRouteImport parentRoute: typeof rootRouteImport } + '/api/workspaces': { + id: '/api/workspaces' + path: '/api/workspaces' + fullPath: '/api/workspaces' + preLoaderRoute: typeof ApiWorkspacesRouteImport + parentRoute: typeof rootRouteImport + } '/api/stream': { id: '/api/stream' path: '/api/stream' @@ -245,6 +265,7 @@ const rootRouteChildren: RootRouteChildren = { ApiSendRoute: ApiSendRoute, ApiSessionsRoute: ApiSessionsRoute, ApiStreamRoute: ApiStreamRoute, + ApiWorkspacesRoute: ApiWorkspacesRoute, ChatSessionKeyRoute: ChatSessionKeyRoute, } export const routeTree = rootRouteImport diff --git a/apps/codex-claw/src/routes/api/workspaces.ts b/apps/codex-claw/src/routes/api/workspaces.ts new file mode 100644 index 0000000..accb373 --- /dev/null +++ b/apps/codex-claw/src/routes/api/workspaces.ts @@ -0,0 +1,98 @@ +import { createFileRoute } from '@tanstack/react-router' +import { json } from '@tanstack/react-start' +import { + activateCodexWorkspace, + createCodexWorkspace, + deleteCodexWorkspace, + listCodexWorkspaces, + patchCodexWorkspace, +} from '../../server/codex-cli' + +function workspaceInput(body: Record) { + return { + id: typeof body.id === 'string' ? body.id.trim() : undefined, + name: typeof body.name === 'string' ? body.name.trim() : undefined, + codexCommand: + typeof body.codexCommand === 'string' + ? body.codexCommand.trim() + : undefined, + codexSandbox: + typeof body.codexSandbox === 'string' + ? body.codexSandbox.trim() + : undefined, + codexWorkdir: + typeof body.codexWorkdir === 'string' + ? body.codexWorkdir.trim() + : undefined, + stateDir: + typeof body.stateDir === 'string' ? body.stateDir.trim() : undefined, + active: body.active === true, + } +} + +export const Route = createFileRoute('/api/workspaces')({ + server: { + handlers: { + GET: () => { + return json(listCodexWorkspaces()) + }, + POST: async ({ request }) => { + try { + const body = (await request.json().catch(() => ({}))) as Record< + string, + unknown + > + createCodexWorkspace(workspaceInput(body)) + return json(listCodexWorkspaces()) + } catch (err) { + return json( + { + ok: false, + error: err instanceof Error ? err.message : String(err), + }, + { status: 400 }, + ) + } + }, + PATCH: async ({ request }) => { + try { + const body = (await request.json().catch(() => ({}))) as Record< + string, + unknown + > + const input = workspaceInput(body) + if (body.action === 'activate' && input.id) { + activateCodexWorkspace(input.id) + return json(listCodexWorkspaces()) + } + patchCodexWorkspace(input) + return json(listCodexWorkspaces()) + } catch (err) { + return json( + { + ok: false, + error: err instanceof Error ? err.message : String(err), + }, + { status: 400 }, + ) + } + }, + DELETE: ({ request }) => { + try { + const url = new URL(request.url) + const id = url.searchParams.get('id') ?? '' + deleteCodexWorkspace(id) + return json(listCodexWorkspaces()) + } catch (err) { + return json( + { + ok: false, + error: err instanceof Error ? err.message : String(err), + }, + { status: 400 }, + ) + } + }, + }, + }, +}) diff --git a/apps/codex-claw/src/screens/chat/chat-queries.ts b/apps/codex-claw/src/screens/chat/chat-queries.ts index df1cb87..2a3b357 100644 --- a/apps/codex-claw/src/screens/chat/chat-queries.ts +++ b/apps/codex-claw/src/screens/chat/chat-queries.ts @@ -5,6 +5,8 @@ import type { HistoryResponse, SessionListResponse, SessionMeta, + WorkspaceListResponse, + WorkspaceSummary, } from './types' type GatewayStatusResponse = { @@ -14,6 +16,7 @@ type GatewayStatusResponse = { export const chatQueryKeys = { sessions: ['chat', 'sessions'] as const, + workspaces: ['chat', 'workspaces'] as const, history: function history(friendlyId: string, sessionKey: string) { return ['chat', 'history', friendlyId, sessionKey] as const }, @@ -56,6 +59,59 @@ export async function fetchGatewayStatus(): Promise { } } +export async function fetchWorkspaces(): Promise { + const res = await fetch('/api/workspaces') + if (!res.ok) throw new Error(await readError(res)) + return (await res.json()) as WorkspaceListResponse +} + +export async function createWorkspace( + workspace: Partial & { active?: boolean }, +): Promise { + const res = await fetch('/api/workspaces', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(workspace), + }) + if (!res.ok) throw new Error(await readError(res)) + return (await res.json()) as WorkspaceListResponse +} + +export async function updateWorkspace( + workspace: Partial & { id: string; active?: boolean }, +): Promise { + const res = await fetch('/api/workspaces', { + method: 'PATCH', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(workspace), + }) + if (!res.ok) throw new Error(await readError(res)) + return (await res.json()) as WorkspaceListResponse +} + +export async function activateWorkspace( + id: string, +): Promise { + const res = await fetch('/api/workspaces', { + method: 'PATCH', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ id, action: 'activate' }), + }) + if (!res.ok) throw new Error(await readError(res)) + return (await res.json()) as WorkspaceListResponse +} + +export async function deleteWorkspace( + id: string, +): Promise { + const query = new URLSearchParams({ id }) + const res = await fetch(`/api/workspaces?${query.toString()}`, { + method: 'DELETE', + }) + if (!res.ok) throw new Error(await readError(res)) + return (await res.json()) as WorkspaceListResponse +} + export function updateHistoryMessages( queryClient: QueryClient, friendlyId: string, diff --git a/apps/codex-claw/src/screens/chat/hooks/use-chat-settings.ts b/apps/codex-claw/src/screens/chat/hooks/use-chat-settings.ts index b128d22..b0b2913 100644 --- a/apps/codex-claw/src/screens/chat/hooks/use-chat-settings.ts +++ b/apps/codex-claw/src/screens/chat/hooks/use-chat-settings.ts @@ -1,42 +1,110 @@ import { useCallback, useState } from 'react' +import { useQueryClient } from '@tanstack/react-query' +import { + activateWorkspace, + chatQueryKeys, + createWorkspace, + deleteWorkspace, + fetchWorkspaces, + updateWorkspace, +} from '../chat-queries' import { readError } from '../utils' -import type { PathsPayload } from '../types' +import type { + PathsPayload, + WorkspaceListResponse, + WorkspaceSummary, +} from '../types' + +type WorkspaceDraft = Partial< + Pick< + WorkspaceSummary, + | 'id' + | 'name' + | 'codexCommand' + | 'codexSandbox' + | 'codexWorkdir' + | 'stateDir' + > +> & { + active?: boolean +} export function useChatSettings() { + const queryClient = useQueryClient() const [settingsOpen, setSettingsOpen] = useState(false) const [pathsLoading, setPathsLoading] = useState(false) const [pathsError, setPathsError] = useState(null) const [paths, setPaths] = useState(null) + const [workspacesLoading, setWorkspacesLoading] = useState(false) + const [workspacesError, setWorkspacesError] = useState(null) + const [workspaceActionPending, setWorkspaceActionPending] = useState(false) + const [workspaceActionError, setWorkspaceActionError] = useState< + string | null + >(null) + const [workspaceData, setWorkspaceData] = + useState(null) - const openSettings = useCallback(async () => { - setSettingsOpen(true) - setPathsError(null) - - if (pathsLoading || paths) return + const invalidateWorkspaceConsumers = useCallback(() => { + queryClient.invalidateQueries({ queryKey: chatQueryKeys.sessions }) + queryClient.invalidateQueries({ queryKey: chatQueryKeys.workspaces }) + queryClient.invalidateQueries({ queryKey: ['chat', 'history'] }) + queryClient.invalidateQueries({ queryKey: ['gateway', 'status'] }) + }, [queryClient]) + const loadPaths = useCallback(async () => { setPathsLoading(true) try { const res = await fetch('/api/paths') if (!res.ok) throw new Error(await readError(res)) - const data = (await res.json()) as { - agentId?: string - stateDir?: string - sessionsDir?: string - storePath?: string - } + const data = (await res.json()) as Partial setPaths({ agentId: String(data.agentId ?? 'main'), stateDir: String(data.stateDir ?? ''), sessionsDir: String(data.sessionsDir ?? ''), storePath: String(data.storePath ?? ''), + workspacesStorePath: String(data.workspacesStorePath ?? ''), + workspace: data.workspace ?? { + id: 'default', + name: 'Default workspace', + codexCommand: 'codex', + codexSandbox: 'read-only', + codexWorkdir: '', + stateDir: '', + createdAt: 0, + updatedAt: 0, + }, }) } catch (err) { setPathsError(err instanceof Error ? err.message : String(err)) } finally { setPathsLoading(false) } - }, [paths, pathsLoading]) + }, []) + + const loadWorkspaces = useCallback(async () => { + setWorkspacesLoading(true) + try { + setWorkspacesError(null) + setWorkspaceData(await fetchWorkspaces()) + } catch (err) { + setWorkspacesError(err instanceof Error ? err.message : String(err)) + } finally { + setWorkspacesLoading(false) + } + }, []) + + const loadSettingsData = useCallback(async () => { + setPathsError(null) + setWorkspacesError(null) + await Promise.all([loadPaths(), loadWorkspaces()]) + }, [loadPaths, loadWorkspaces]) + + const openSettings = useCallback(async () => { + setSettingsOpen(true) + if (pathsLoading || workspacesLoading) return + await loadSettingsData() + }, [loadSettingsData, pathsLoading, workspacesLoading]) const handleOpenSettings = useCallback(() => { void openSettings() @@ -46,6 +114,54 @@ export function useChatSettings() { setSettingsOpen(false) }, []) + const runWorkspaceAction = useCallback( + async (action: () => Promise) => { + setWorkspaceActionPending(true) + setWorkspaceActionError(null) + try { + const nextData = await action() + setWorkspaceData(nextData) + await loadPaths() + invalidateWorkspaceConsumers() + } catch (err) { + setWorkspaceActionError( + err instanceof Error ? err.message : String(err), + ) + } finally { + setWorkspaceActionPending(false) + } + }, + [invalidateWorkspaceConsumers, loadPaths], + ) + + const handleCreateWorkspace = useCallback( + async (workspace: WorkspaceDraft) => { + await runWorkspaceAction(() => createWorkspace(workspace)) + }, + [runWorkspaceAction], + ) + + const handleUpdateWorkspace = useCallback( + async (workspace: WorkspaceDraft & { id: string }) => { + await runWorkspaceAction(() => updateWorkspace(workspace)) + }, + [runWorkspaceAction], + ) + + const handleActivateWorkspace = useCallback( + async (id: string) => { + await runWorkspaceAction(() => activateWorkspace(id)) + }, + [runWorkspaceAction], + ) + + const handleDeleteWorkspace = useCallback( + async (id: string) => { + await runWorkspaceAction(() => deleteWorkspace(id)) + }, + [runWorkspaceAction], + ) + const copySessionsDir = useCallback(() => { if (!paths?.sessionsDir) return try { @@ -64,15 +180,33 @@ export function useChatSettings() { } }, [paths]) + const copyFixCommand = useCallback((command: string) => { + try { + void navigator.clipboard.writeText(command) + } catch { + // ignore + } + }, []) + return { settingsOpen, setSettingsOpen, pathsLoading, pathsError, paths, + workspacesLoading, + workspacesError, + workspaceActionPending, + workspaceActionError, + workspaceData, handleOpenSettings, closeSettings, copySessionsDir, copyStorePath, + copyFixCommand, + createWorkspace: handleCreateWorkspace, + updateWorkspace: handleUpdateWorkspace, + activateWorkspace: handleActivateWorkspace, + deleteWorkspace: handleDeleteWorkspace, } } diff --git a/apps/codex-claw/src/screens/chat/types.ts b/apps/codex-claw/src/screens/chat/types.ts index 099d973..1d986b9 100644 --- a/apps/codex-claw/src/screens/chat/types.ts +++ b/apps/codex-claw/src/screens/chat/types.ts @@ -93,4 +93,41 @@ export type PathsPayload = { stateDir: string sessionsDir: string storePath: string + workspacesStorePath: string + workspace: WorkspaceSummary +} + +export type WorkspaceSummary = { + id: string + name: string + codexCommand: string + codexSandbox: string + codexWorkdir: string + stateDir: string + createdAt: number + updatedAt: number +} + +export type WorkspaceHealthStatus = 'ok' | 'warning' | 'error' + +export type WorkspaceHealthCheck = { + id: string + label: string + status: WorkspaceHealthStatus + summary: string + detail?: string + fixCommand?: string +} + +export type WorkspaceHealthPayload = { + ok: boolean + workspaceId: string + checkedAt: number + checks: Array +} + +export type WorkspaceListResponse = { + activeWorkspaceId: string + workspaces: Array + health: WorkspaceHealthPayload } From c98120f7c8f1a912baa489824f89f9e931b5f2ca Mon Sep 17 00:00:00 2001 From: moe Date: Sun, 31 May 2026 03:27:13 -0400 Subject: [PATCH 3/5] Add workspace controls to settings dialog --- .../screens/chat/components/chat-sidebar.tsx | 20 + .../chat/components/settings-dialog.tsx | 390 +++++++++++++++++- 2 files changed, 399 insertions(+), 11 deletions(-) diff --git a/apps/codex-claw/src/screens/chat/components/chat-sidebar.tsx b/apps/codex-claw/src/screens/chat/components/chat-sidebar.tsx index 0f4705c..d2885b0 100644 --- a/apps/codex-claw/src/screens/chat/components/chat-sidebar.tsx +++ b/apps/codex-claw/src/screens/chat/components/chat-sidebar.tsx @@ -55,10 +55,20 @@ function ChatSidebarComponent({ pathsLoading, pathsError, paths, + workspacesLoading, + workspacesError, + workspaceActionPending, + workspaceActionError, + workspaceData, handleOpenSettings, closeSettings, copySessionsDir, copyStorePath, + copyFixCommand, + createWorkspace, + updateWorkspace, + activateWorkspace, + deleteWorkspace, } = useChatSettings() const { deleteSession } = useDeleteSession() const { renameSession } = useRenameSession() @@ -352,9 +362,19 @@ function ChatSidebarComponent({ pathsLoading={pathsLoading} pathsError={pathsError} paths={paths} + workspacesLoading={workspacesLoading} + workspacesError={workspacesError} + workspaceActionPending={workspaceActionPending} + workspaceActionError={workspaceActionError} + workspaceData={workspaceData} onClose={closeSettings} onCopySessionsDir={copySessionsDir} onCopyStorePath={copyStorePath} + onCopyFixCommand={copyFixCommand} + onCreateWorkspace={createWorkspace} + onUpdateWorkspace={updateWorkspace} + onActivateWorkspace={activateWorkspace} + onDeleteWorkspace={deleteWorkspace} /> +> & { + active?: boolean +} type SettingsSectionProps = { title: string @@ -41,35 +67,119 @@ type SettingsRowProps = { function SettingsRow({ label, description, children }: SettingsRowProps) { return ( -
-
+
+
{label}
{description && ( -
{description}
+
+ {description} +
)}
-
{children}
+
{children}
) } +type WorkspaceFieldProps = { + label: string + children: React.ReactNode +} + +function WorkspaceField({ label, children }: WorkspaceFieldProps) { + return ( + + ) +} + +const newWorkspaceId = '__new__' +const sandboxOptions = ['read-only', 'workspace-write', 'danger-full-access'] + +function emptyWorkspaceDraft( + workspace?: WorkspaceSummary | null, +): WorkspaceDraft { + return { + name: workspace ? `${workspace.name} copy` : 'New workspace', + codexCommand: workspace?.codexCommand ?? 'codex', + codexSandbox: workspace?.codexSandbox ?? 'read-only', + codexWorkdir: workspace?.codexWorkdir ?? '', + stateDir: workspace?.stateDir ?? '', + } +} + +function healthTone(check: WorkspaceHealthCheck) { + if (check.status === 'ok') return 'bg-green-500 text-green-700' + if (check.status === 'warning') return 'bg-amber-500 text-amber-700' + return 'bg-red-500 text-red-700' +} + type SettingsDialogProps = { open: boolean onOpenChange: (open: boolean) => void pathsLoading: boolean pathsError: string | null paths: PathsPayload | null + workspacesLoading: boolean + workspacesError: string | null + workspaceActionPending: boolean + workspaceActionError: string | null + workspaceData: WorkspaceListResponse | null onClose: () => void onCopySessionsDir: () => void onCopyStorePath: () => void + onCopyFixCommand: (command: string) => void + onCreateWorkspace: (workspace: WorkspaceDraft) => Promise + onUpdateWorkspace: ( + workspace: WorkspaceDraft & { id: string }, + ) => Promise + onActivateWorkspace: (id: string) => Promise + onDeleteWorkspace: (id: string) => Promise } export function SettingsDialog({ open, onOpenChange, + pathsLoading, + pathsError, + paths, + workspacesLoading, + workspacesError, + workspaceActionPending, + workspaceActionError, + workspaceData, onClose, + onCopySessionsDir, + onCopyStorePath, + onCopyFixCommand, + onCreateWorkspace, + onUpdateWorkspace, + onActivateWorkspace, + onDeleteWorkspace, }: SettingsDialogProps) { const { settings, updateSettings } = useChatSettings() + const [selectedWorkspaceId, setSelectedWorkspaceId] = useState('') + const [draft, setDraft] = useState(() => + emptyWorkspaceDraft(null), + ) + const activeWorkspace = useMemo(() => { + return workspaceData?.workspaces.find( + (workspace) => workspace.id === workspaceData.activeWorkspaceId, + ) + }, [workspaceData]) + const selectedWorkspace = useMemo(() => { + return workspaceData?.workspaces.find( + (workspace) => workspace.id === selectedWorkspaceId, + ) + }, [selectedWorkspaceId, workspaceData]) + const isNewWorkspace = selectedWorkspaceId === newWorkspaceId + const canSaveWorkspace = + Boolean(draft.name?.trim()) && + Boolean(draft.codexCommand?.trim()) && + Boolean(draft.codexWorkdir?.trim()) && + Boolean(draft.stateDir?.trim()) const themeOptions = [ { value: 'system', label: 'System', icon: ComputerIcon }, { value: 'light', label: 'Light', icon: Sun01Icon }, @@ -80,6 +190,54 @@ export function SettingsDialog({ { value: 'medium', label: 'Medium' }, { value: 'high', label: 'High' }, ] as const + + useEffect(() => { + if (!workspaceData) return + if (selectedWorkspaceId === newWorkspaceId) return + const selectedStillExists = workspaceData.workspaces.some( + (workspace) => workspace.id === selectedWorkspaceId, + ) + if (!selectedWorkspaceId || !selectedStillExists) { + setSelectedWorkspaceId(workspaceData.activeWorkspaceId) + } + }, [selectedWorkspaceId, workspaceData]) + + useEffect(() => { + if (isNewWorkspace) return + if (!selectedWorkspace) return + setDraft({ + id: selectedWorkspace.id, + name: selectedWorkspace.name, + codexCommand: selectedWorkspace.codexCommand, + codexSandbox: selectedWorkspace.codexSandbox, + codexWorkdir: selectedWorkspace.codexWorkdir, + stateDir: selectedWorkspace.stateDir, + }) + }, [isNewWorkspace, selectedWorkspace]) + + function updateDraft(field: keyof WorkspaceDraft, value: string) { + setDraft((current) => ({ ...current, [field]: value })) + } + + function handleNewWorkspace() { + setSelectedWorkspaceId(newWorkspaceId) + setDraft(emptyWorkspaceDraft(activeWorkspace ?? null)) + } + + async function handleSaveWorkspace() { + if (!canSaveWorkspace) return + if (isNewWorkspace) { + await onCreateWorkspace({ ...draft, active: true }) + setSelectedWorkspaceId('') + return + } + if (!selectedWorkspace) return + await onUpdateWorkspace({ + ...draft, + id: selectedWorkspace.id, + }) + } + function applyTheme(theme: ThemeMode) { if (typeof document === 'undefined') return const root = document.documentElement @@ -93,7 +251,7 @@ export function SettingsDialog({ return ( - +
@@ -120,13 +278,223 @@ export function SettingsDialog({ />
- - - - - Connected + +
+ +
+ + {selectedWorkspace && + selectedWorkspace.id !== workspaceData?.activeWorkspaceId ? ( + + ) : null} +
+
+ +
+ + updateDraft('name', event.target.value)} + /> + + + + updateDraft('codexCommand', event.target.value) + } + /> + + + + updateDraft('codexWorkdir', event.target.value) + } + /> + + + + updateDraft('stateDir', event.target.value) + } + /> + + + + +
+ +
+
+ {workspaceActionError || workspacesError || pathsError || ''} +
+
+ {selectedWorkspace && selectedWorkspace.id !== 'default' ? ( + + ) : null} + +
+
+
+ + + {workspacesLoading ? ( +
Checking...
+ ) : null} + {workspaceData?.health.checks.map((check) => ( +
+
+
+ + + {check.label} + + + {check.status} + +
+
+ {check.summary} +
+ {check.detail ? ( +
+ {check.detail} +
+ ) : null} +
+ {check.fixCommand ? ( + + ) : null} +
+ ))} +
+ + + {pathsLoading ? ( +
Loading paths...
+ ) : null} + + + {paths?.workspace.id ?? workspaceData?.activeWorkspaceId ?? ''} + + + + + +
From abd13a33c0cd4598bf8df5e0f14b4edf3628c728 Mon Sep 17 00:00:00 2001 From: moe Date: Sun, 31 May 2026 03:27:23 -0400 Subject: [PATCH 4/5] Cover workspace registry behavior --- apps/codex-claw/src/server/codex-cli.test.ts | 116 ++++++++++++++++++- 1 file changed, 114 insertions(+), 2 deletions(-) diff --git a/apps/codex-claw/src/server/codex-cli.test.ts b/apps/codex-claw/src/server/codex-cli.test.ts index 5482769..b3e4044 100644 --- a/apps/codex-claw/src/server/codex-cli.test.ts +++ b/apps/codex-claw/src/server/codex-cli.test.ts @@ -1,6 +1,18 @@ -import { describe, expect, it } from 'vitest' +import { mkdtempSync, rmSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { mergeAssistantText, processCodexJsonLine } from './codex-cli' +import { + createCodexWorkspace, + deleteCodexWorkspace, + getCodexPaths, + listCodexWorkspaces, + mergeAssistantText, + patchCodexWorkspace, + processCodexJsonLine, + resetCodexServerStateForTests, +} from './codex-cli' describe('processCodexJsonLine', function () { it('parses assistant deltas before the final completed item', function () { @@ -61,3 +73,103 @@ describe('mergeAssistantText', function () { expect(repeated).toBe('Hello') }) }) + +describe('codex workspace registry', function () { + const originalStateDir = process.env.CODEX_CLAW_STATE_DIR + const originalCommand = process.env.CODEX_CLI_COMMAND + const originalWorkdir = process.env.CODEX_CLI_WORKDIR + const originalSandbox = process.env.CODEX_CLI_SANDBOX + let tempDir = '' + + beforeEach(function () { + tempDir = mkdtempSync(path.join(os.tmpdir(), 'codex-claw-test-')) + process.env.CODEX_CLAW_STATE_DIR = path.join(tempDir, 'state') + process.env.CODEX_CLI_COMMAND = 'codex-test' + process.env.CODEX_CLI_WORKDIR = tempDir + process.env.CODEX_CLI_SANDBOX = 'read-only' + resetCodexServerStateForTests() + }) + + afterEach(function () { + if (originalStateDir === undefined) { + delete process.env.CODEX_CLAW_STATE_DIR + } else { + process.env.CODEX_CLAW_STATE_DIR = originalStateDir + } + if (originalCommand === undefined) { + delete process.env.CODEX_CLI_COMMAND + } else { + process.env.CODEX_CLI_COMMAND = originalCommand + } + if (originalWorkdir === undefined) { + delete process.env.CODEX_CLI_WORKDIR + } else { + process.env.CODEX_CLI_WORKDIR = originalWorkdir + } + if (originalSandbox === undefined) { + delete process.env.CODEX_CLI_SANDBOX + } else { + process.env.CODEX_CLI_SANDBOX = originalSandbox + } + resetCodexServerStateForTests() + rmSync(tempDir, { recursive: true, force: true }) + }) + + it('stores independent active workspace configuration', function () { + expect(getCodexPaths().workspace).toMatchObject({ + id: 'default', + codexCommand: 'codex-test', + codexWorkdir: tempDir, + stateDir: path.join(tempDir, 'state'), + }) + + createCodexWorkspace({ + name: 'Docs repo', + codexCommand: 'codex-next', + codexSandbox: 'workspace-write', + codexWorkdir: path.join(tempDir, 'docs'), + stateDir: path.join(tempDir, 'docs-state'), + active: true, + }) + + expect(getCodexPaths()).toMatchObject({ + stateDir: path.join(tempDir, 'docs-state'), + workspace: { + id: 'docs-repo', + name: 'Docs repo', + codexCommand: 'codex-next', + codexSandbox: 'workspace-write', + codexWorkdir: path.join(tempDir, 'docs'), + }, + }) + }) + + it('renames and removes non-default workspaces', function () { + createCodexWorkspace({ + name: 'Feature repo', + codexWorkdir: path.join(tempDir, 'feature'), + stateDir: path.join(tempDir, 'feature-state'), + active: true, + }) + patchCodexWorkspace({ + id: 'feature-repo', + name: 'Feature branch repo', + }) + + expect(listCodexWorkspaces().workspaces).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: 'feature-repo', + name: 'Feature branch repo', + }), + ]), + ) + + deleteCodexWorkspace('feature-repo') + + expect(listCodexWorkspaces()).toMatchObject({ + activeWorkspaceId: 'default', + workspaces: [expect.objectContaining({ id: 'default' })], + }) + }) +}) From 16f240844f05db6c60a1819008d888aac7402b02 Mon Sep 17 00:00:00 2001 From: moe Date: Sun, 31 May 2026 03:31:16 -0400 Subject: [PATCH 5/5] Avoid shell quoting in health checks --- apps/codex-claw/src/server/codex-cli.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/apps/codex-claw/src/server/codex-cli.ts b/apps/codex-claw/src/server/codex-cli.ts index f0311f6..cad34bb 100644 --- a/apps/codex-claw/src/server/codex-cli.ts +++ b/apps/codex-claw/src/server/codex-cli.ts @@ -1112,20 +1112,16 @@ function findWorkspace(store: WorkspaceStore, id: string) { } function quoteCommandArg(value: string) { - return `"${value.replace(/"/g, '\\"')}"` + return JSON.stringify(value) } function runTool(command: string, args: Array, cwd: string) { const resolvedCommand = resolveCodexCommand(command) if (process.platform === 'win32' && /\.(cmd|bat)$/i.test(resolvedCommand)) { - const shellCommand = [resolvedCommand, ...args] - .map(quoteCommandArg) - .join(' ') - return spawnSync(shellCommand, { + return spawnSync('cmd.exe', ['/d', '/s', '/c', resolvedCommand, ...args], { cwd, env: process.env, stdio: 'pipe', - shell: true, encoding: 'utf8', }) }