Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions server/agent-api/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {
} from '../coding-cli/codex-launch-config.js'
import { INVALID_RAW_CODEX_RESUME_MESSAGE } from '../coding-cli/codex-app-server/restore-decision.js'
import { makeSessionKey } from '../coding-cli/types.js'
import { terminalIdFromCreateError, type ProviderSettings, type TerminalInputResult } from '../terminal-registry.js'
import { terminalIdFromCreateError, UnknownTerminalModeError, type ProviderSettings, type TerminalInputResult } from '../terminal-registry.js'
import { MAX_TERMINAL_TITLE_OVERRIDE_LENGTH } from '../terminals-router.js'
import { logger } from '../logger.js'
import { ok, approx, fail } from './response.js'
Expand All @@ -36,7 +36,11 @@ class AgentRouteInputError extends Error {
}

function agentRouteErrorStatus(error: unknown): number {
return error instanceof CodexLaunchConfigError || error instanceof AgentRouteInputError ? 400 : 500
return error instanceof CodexLaunchConfigError
|| error instanceof AgentRouteInputError
|| error instanceof UnknownTerminalModeError
? 400
: 500
}

function errorMessage(error: unknown): string {
Expand Down
34 changes: 34 additions & 0 deletions server/terminal-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,33 @@ export function registerCodingCliCommands(specs: Map<string, CodingCliCommandSpe
codingCliCommands = specs
}

/**
* The set of terminal modes that can actually be spawned: the built-in 'shell'
* plus every registered coding-CLI mode (claude, codex, opencode, ...).
*/
export function getKnownTerminalModes(): TerminalMode[] {
return ['shell', ...codingCliCommands.keys()]
}

/** Whether `mode` can be spawned (the built-in shell or a registered coding CLI). */
export function isKnownTerminalMode(mode: TerminalMode): boolean {
return mode === 'shell' || codingCliCommands.has(mode)
}

/**
* Thrown when a spawn is requested for a mode that is neither the built-in
* 'shell' nor a registered coding-CLI extension. Guards against buildSpawnSpec's
* old behaviour of falling back to exec-ing the mode name itself, which produced
* a terminal that immediately died with "execvp(3) failed: No such file or
* directory" (e.g. new-tab({ mode: 'terminal' })).
*/
export class UnknownTerminalModeError extends Error {
constructor(public readonly mode: string) {
super(`Invalid terminal mode: '${mode}'. Valid: ${getKnownTerminalModes().join(', ')}`)
this.name = 'UnknownTerminalModeError'
}
}

/**
* Check if a terminal mode supports session resume.
* Only modes with configured resumeArgs in CODING_CLI_COMMANDS support resume.
Expand Down Expand Up @@ -901,6 +928,13 @@ export function buildSpawnSpec(
envOverrides?: Record<string, string>,
terminalId?: string,
) {
// Reject modes that aren't the built-in shell or a registered coding CLI.
// Otherwise the fallbacks below (`cli?.command || mode`, and the WSL bash
// fallback) try to exec the mode name itself, spawning a terminal that dies
// instantly with "execvp(3) failed: No such file or directory".
if (mode !== 'shell' && !codingCliCommands.has(mode)) {
throw new UnknownTerminalModeError(mode)
}
// Strip inherited env vars that interfere with child terminal behaviour:
// - CLAUDECODE: causes child Claude processes to refuse to start ("nested session" error)
// - CI/NO_COLOR/FORCE_COLOR/COLOR: disables interactive color in user PTYs
Expand Down
30 changes: 30 additions & 0 deletions test/server/agent-tabs-write.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { describe, it, expect, vi } from 'vitest'
import express from 'express'
import request from 'supertest'
import { createAgentApiRouter } from '../../server/agent-api/router'
import { UnknownTerminalModeError } from '../../server/terminal-registry'
import { FakeCodexLaunchPlanner } from '../helpers/coding-cli/fake-codex-launch-planner.js'
import { INVALID_RAW_CODEX_RESUME_MESSAGE } from '../../server/coding-cli/codex-app-server/restore-decision.js'

Expand Down Expand Up @@ -684,4 +685,33 @@ describe('tab endpoints', () => {
expect(renameTab).toHaveBeenCalledWith('missing', 'Ghost')
expect(broadcastUiCommand).not.toHaveBeenCalled()
})

it('rejects an unknown terminal mode with a 400 and rolls back the tab', async () => {
const app = express()
app.use(express.json())
const closeTab = vi.fn()
// A spawn for an unmodelled mode (e.g. mode: 'terminal') throws from the
// registry; the route must surface a 400, not spawn a dead terminal.
const registry = {
create: vi.fn(() => { throw new UnknownTerminalModeError('terminal') }),
}
const layoutStore = {
createTab: () => ({ tabId: 'tab_1', paneId: 'pane_1' }),
attachPaneContent: vi.fn(),
closeTab,
}
app.use('/api', createAgentApiRouter({
layoutStore,
registry: registry as any,
wsHandler: { broadcastUiCommand: vi.fn() },
}))

const res = await request(app).post('/api/tabs').send({ name: 'x', mode: 'terminal' })

expect(res.status).toBe(400)
expect(res.body.status).toBe('error')
expect(res.body.message).toMatch(/Invalid terminal mode/)
// The half-created tab is cleaned up rather than left dangling.
expect(closeTab).toHaveBeenCalledWith('tab_1')
})
})
18 changes: 17 additions & 1 deletion test/unit/server/terminal-registry.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { isLinuxPath, getSystemShell, escapeCmdExe, buildSpawnSpec, TerminalRegistry, isWsl, isWindowsLike, modeSupportsResume, buildTerminalSessionRef } from '../../../server/terminal-registry'
import { isLinuxPath, getSystemShell, escapeCmdExe, buildSpawnSpec, TerminalRegistry, isWsl, isWindowsLike, modeSupportsResume, buildTerminalSessionRef, UnknownTerminalModeError } from '../../../server/terminal-registry'
import { isValidClaudeSessionId } from '../../../server/claude-session-id'
import { CODEX_DURABILITY_SCHEMA_VERSION } from '../../../shared/codex-durability'
import * as fs from 'fs'
Expand Down Expand Up @@ -733,6 +733,22 @@ describe('buildSpawnSpec Unix paths', () => {
process.env = originalEnv
})

describe('invalid mode', () => {
beforeEach(() => {
mockPlatform('linux')
})

it('throws instead of returning a spec that would exec the mode name', () => {
// Regression: an unknown mode used to fall through to `file: mode`, so
// new-tab({ mode: 'terminal' }) spawned a process literally named
// "terminal" that died with "execvp(3) failed: No such file or directory".
expect(() => buildSpawnSpec('terminal', '/home/user', 'system'))
.toThrow(UnknownTerminalModeError)
expect(() => buildSpawnSpec('terminal', '/home/user', 'system'))
.toThrow(/Invalid terminal mode: 'terminal'/)
})
})

describe('macOS shell mode', () => {
beforeEach(() => {
mockPlatform('darwin')
Expand Down
Loading