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
2 changes: 1 addition & 1 deletion apps/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
"build:extension": "pnpm --filter roo-cline bundle",
"build:all": "pnpm --filter roo-cline bundle && tsup",
"dev": "tsup --watch",
"start": "ROO_SDK_BASE_URL=http://localhost:3001 ROO_AUTH_BASE_URL=http://localhost:3000 node dist/index.js",
"start": "ROO_AUTH_BASE_URL=http://localhost:3000 ROO_SDK_BASE_URL=http://localhost:3001 ROO_CODE_PROVIDER_URL=http://localhost:8080/proxy node dist/index.js",
"start:production": "node dist/index.js",
"release": "scripts/release.sh",
"clean": "rimraf dist .turbo"
Expand Down
2 changes: 1 addition & 1 deletion apps/cli/scripts/release.sh
Original file line number Diff line number Diff line change
Expand Up @@ -421,7 +421,7 @@ verify_local_install() {

# Run the CLI with a simple prompt
# Use timeout to prevent hanging if something goes wrong
if timeout 60 "$VERIFY_BIN_DIR/roo" --yes --exit-on-complete --prompt "1+1=?" "$VERIFY_WORKSPACE" > "$VERIFY_DIR/test-output.log" 2>&1; then
if timeout 60 "$VERIFY_BIN_DIR/roo" --yes --oneshot -w "$VERIFY_WORKSPACE" "1+1=?" > "$VERIFY_DIR/test-output.log" 2>&1; then
info "End-to-end test passed"
else
EXIT_CODE=$?
Expand Down
16 changes: 0 additions & 16 deletions apps/cli/src/agent/extension-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -437,9 +437,6 @@ export class ExtensionHost extends EventEmitter implements ExtensionHostInterfac
this.sendToExtension({ type: "newTask", text: prompt })

return new Promise((resolve, reject) => {
let timeoutId: NodeJS.Timeout | null = null
const timeoutMs: number = 110_000

const completeHandler = () => {
cleanup()
resolve()
Expand All @@ -451,23 +448,10 @@ export class ExtensionHost extends EventEmitter implements ExtensionHostInterfac
}

const cleanup = () => {
if (timeoutId) {
clearTimeout(timeoutId)
timeoutId = null
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed timeout safeguard can cause indefinite hangs

Medium Severity

The runTask method's 110-second timeout safeguard was removed. Previously, if no taskCompleted or error event was received within the timeout, the promise would reject with a clear error. Without this, if the extension fails to emit these events (due to bugs, network issues, or crashes), the CLI in non-TUI mode will hang indefinitely waiting for a promise that never resolves. Users can still Ctrl+C to exit, but there's no automatic protection against silent hangs.

Fix in Cursor Fix in Web

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is intentional. We need a better mechanism for hang detection.


this.client.off("taskCompleted", completeHandler)
this.client.off("error", errorHandler)
}

// Set timeout to prevent indefinite hanging.
timeoutId = setTimeout(() => {
cleanup()
reject(
new Error(`Task completion timeout after ${timeoutMs}ms - no completion or error event received`),
)
}, timeoutMs)

this.client.once("taskCompleted", completeHandler)
this.client.once("error", errorHandler)
})
Expand Down
66 changes: 36 additions & 30 deletions apps/cli/src/commands/auth/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,15 @@ export interface LoginOptions {
verbose?: boolean
}

export interface LoginResult {
success: boolean
error?: string
userId?: string
orgId?: string | null
}
export type LoginResult =
| {
success: true
token: string
}
| {
success: false
error: string
}

const LOCALHOST = "127.0.0.1"

Expand All @@ -29,49 +32,57 @@ export async function login({ timeout = 5 * 60 * 1000, verbose = false }: LoginO
console.log(`[Auth] Starting local callback server on port ${port}`)
}

const corsHeaders = {
"Access-Control-Allow-Origin": AUTH_BASE_URL,
"Access-Control-Allow-Methods": "POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
}

// Create promise that will be resolved when we receive the callback.
const tokenPromise = new Promise<{ token: string; state: string }>((resolve, reject) => {
const server = http.createServer((req, res) => {
const url = new URL(req.url!, host)

if (url.pathname === "/callback") {
// Handle CORS preflight request.
if (req.method === "OPTIONS") {
res.writeHead(204, corsHeaders)
res.end()
return
}

if (url.pathname === "/callback" && req.method === "POST") {
const receivedState = url.searchParams.get("state")
const token = url.searchParams.get("token")
const error = url.searchParams.get("error")

const sendJsonResponse = (status: number, body: object) => {
res.writeHead(status, {
...corsHeaders,
"Content-Type": "application/json",
})
res.end(JSON.stringify(body))
}

if (error) {
const errorUrl = new URL(`${AUTH_BASE_URL}/cli/sign-in?error=error-in-callback`)
errorUrl.searchParams.set("message", error)
res.writeHead(302, { Location: errorUrl.toString() })
res.end()
// Wait for response to be fully sent before closing server and rejecting.
// The 'close' event fires when the underlying connection is terminated,
// ensuring the browser has received the redirect before we shut down.
sendJsonResponse(400, { success: false, error })
res.on("close", () => {
server.close()
reject(new Error(error))
})
} else if (!token) {
const errorUrl = new URL(`${AUTH_BASE_URL}/cli/sign-in?error=missing-token`)
errorUrl.searchParams.set("message", "Missing token in callback")
res.writeHead(302, { Location: errorUrl.toString() })
res.end()
sendJsonResponse(400, { success: false, error: "Missing token in callback" })
res.on("close", () => {
server.close()
reject(new Error("Missing token in callback"))
})
} else if (receivedState !== state) {
const errorUrl = new URL(`${AUTH_BASE_URL}/cli/sign-in?error=invalid-state-parameter`)
errorUrl.searchParams.set("message", "Invalid state parameter (possible CSRF attack)")
res.writeHead(302, { Location: errorUrl.toString() })
res.end()
sendJsonResponse(400, { success: false, error: "Invalid state parameter" })
res.on("close", () => {
server.close()
reject(new Error("Invalid state parameter"))
})
} else {
res.writeHead(302, { Location: `${AUTH_BASE_URL}/cli/sign-in?success=true` })
res.end()
sendJsonResponse(200, { success: true })
res.on("close", () => {
server.close()
resolve({ token, state: receivedState })
Expand All @@ -90,12 +101,7 @@ export async function login({ timeout = 5 * 60 * 1000, verbose = false }: LoginO
reject(new Error("Authentication timed out"))
}, timeout)

server.on("listening", () => {
console.log(`[Auth] Callback server listening on port ${port}`)
})

server.on("close", () => {
console.log("[Auth] Callback server closed")
clearTimeout(timeoutId)
})
})
Expand All @@ -121,7 +127,7 @@ export async function login({ timeout = 5 * 60 * 1000, verbose = false }: LoginO
const { token } = await tokenPromise
await saveToken(token)
console.log("✓ Successfully authenticated!")
return { success: true }
return { success: true, token }
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
console.error(`✗ Authentication failed: ${message}`)
Expand Down
93 changes: 93 additions & 0 deletions apps/cli/src/commands/cli/__tests__/run.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import fs from "fs"
import path from "path"
import os from "os"

describe("run command --prompt-file option", () => {
let tempDir: string
let promptFilePath: string

beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "cli-test-"))
promptFilePath = path.join(tempDir, "prompt.md")
})

afterEach(() => {
fs.rmSync(tempDir, { recursive: true, force: true })
})

it("should read prompt from file when --prompt-file is provided", () => {
const promptContent = `This is a test prompt with special characters:
- Quotes: "hello" and 'world'
- Backticks: \`code\`
- Newlines and tabs
- Unicode: 你好 🎉`

fs.writeFileSync(promptFilePath, promptContent)

// Verify the file was written correctly
const readContent = fs.readFileSync(promptFilePath, "utf-8")
expect(readContent).toBe(promptContent)
})

it("should handle multi-line prompts correctly", () => {
const multiLinePrompt = `Line 1
Line 2
Line 3
Empty line above
\tTabbed line
Indented line`

fs.writeFileSync(promptFilePath, multiLinePrompt)
const readContent = fs.readFileSync(promptFilePath, "utf-8")

expect(readContent).toBe(multiLinePrompt)
expect(readContent.split("\n")).toHaveLength(7)
})

it("should handle very long prompts that would exceed ARG_MAX", () => {
// ARG_MAX is typically 128KB-2MB, so let's test with a 500KB prompt
const longPrompt = "x".repeat(500 * 1024)

fs.writeFileSync(promptFilePath, longPrompt)
const readContent = fs.readFileSync(promptFilePath, "utf-8")

expect(readContent.length).toBe(500 * 1024)
expect(readContent).toBe(longPrompt)
})

it("should preserve shell-sensitive characters", () => {
const shellSensitivePrompt = `
$HOME
$(echo dangerous)
\`rm -rf /\`
"quoted string"
'single quoted'
$((1+1))
&&
||
;
> /dev/null
< input.txt
| grep something
*
?
[abc]
{a,b}
~
!
#comment
%s
\n\t\r
`

fs.writeFileSync(promptFilePath, shellSensitivePrompt)
const readContent = fs.readFileSync(promptFilePath, "utf-8")

// All shell-sensitive characters should be preserved exactly
expect(readContent).toBe(shellSensitivePrompt)
expect(readContent).toContain("$HOME")
expect(readContent).toContain("$(echo dangerous)")
expect(readContent).toContain("`rm -rf /`")
})
})
75 changes: 49 additions & 26 deletions apps/cli/src/commands/cli/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,42 +28,68 @@ import { ExtensionHost, ExtensionHostOptions } from "@/agent/index.js"

const __dirname = path.dirname(fileURLToPath(import.meta.url))

export async function run(workspaceArg: string, flagOptions: FlagOptions) {
export async function run(promptArg: string | undefined, flagOptions: FlagOptions) {
setLogger({
info: () => {},
warn: () => {},
error: () => {},
debug: () => {},
})

let prompt = promptArg

if (flagOptions.promptFile) {
if (!fs.existsSync(flagOptions.promptFile)) {
console.error(`[CLI] Error: Prompt file does not exist: ${flagOptions.promptFile}`)
process.exit(1)
}

prompt = fs.readFileSync(flagOptions.promptFile, "utf-8")
}

// Options

let rooToken = await loadToken()
const settings = await loadSettings()

const isTuiSupported = process.stdin.isTTY && process.stdout.isTTY
const isTuiEnabled = flagOptions.tui && isTuiSupported
const rooToken = await loadToken()
const isTuiEnabled = !flagOptions.print && isTuiSupported
const isOnboardingEnabled = isTuiEnabled && !rooToken && !flagOptions.provider && !settings.provider

// Determine effective values: CLI flags > settings file > DEFAULT_FLAGS.
const effectiveMode = flagOptions.mode || settings.mode || DEFAULT_FLAGS.mode
const effectiveModel = flagOptions.model || settings.model || DEFAULT_FLAGS.model
const effectiveReasoningEffort =
flagOptions.reasoningEffort || settings.reasoningEffort || DEFAULT_FLAGS.reasoningEffort
const effectiveProvider = flagOptions.provider ?? settings.provider ?? (rooToken ? "roo" : "openrouter")
const effectiveWorkspacePath = flagOptions.workspace ? path.resolve(flagOptions.workspace) : process.cwd()
const effectiveDangerouslySkipPermissions =
flagOptions.yes || flagOptions.dangerouslySkipPermissions || settings.dangerouslySkipPermissions || false
const effectiveExitOnComplete = flagOptions.print || flagOptions.oneshot || settings.oneshot || false

const extensionHostOptions: ExtensionHostOptions = {
mode: flagOptions.mode || DEFAULT_FLAGS.mode,
reasoningEffort: flagOptions.reasoningEffort === "unspecified" ? undefined : flagOptions.reasoningEffort,
mode: effectiveMode,
reasoningEffort: effectiveReasoningEffort === "unspecified" ? undefined : effectiveReasoningEffort,
user: null,
provider: flagOptions.provider ?? (rooToken ? "roo" : "openrouter"),
model: flagOptions.model || DEFAULT_FLAGS.model,
workspacePath: path.resolve(workspaceArg),
provider: effectiveProvider,
model: effectiveModel,
workspacePath: effectiveWorkspacePath,
extensionPath: path.resolve(flagOptions.extension || getDefaultExtensionPath(__dirname)),
nonInteractive: flagOptions.yes,
nonInteractive: effectiveDangerouslySkipPermissions,
ephemeral: flagOptions.ephemeral,
debug: flagOptions.debug,
exitOnComplete: flagOptions.exitOnComplete,
exitOnComplete: effectiveExitOnComplete,
}

// Roo Code Cloud Authentication

if (isTuiEnabled) {
let { onboardingProviderChoice } = await loadSettings()
if (isOnboardingEnabled) {
let { onboardingProviderChoice } = settings

if (!onboardingProviderChoice) {
const result = await runOnboarding()
onboardingProviderChoice = result.choice
const { choice, token } = await runOnboarding()
onboardingProviderChoice = choice
rooToken = token ?? null
}

if (onboardingProviderChoice === OnboardingProviderChoice.Roo) {
Expand Down Expand Up @@ -139,15 +165,15 @@ export async function run(workspaceArg: string, flagOptions: FlagOptions) {
}

if (!isTuiEnabled) {
if (!flagOptions.prompt) {
console.error("[CLI] Error: prompt is required in plain text mode")
console.error("[CLI] Usage: roo [workspace] -P <prompt> [options]")
console.error("[CLI] Use TUI mode (without --no-tui) for interactive input")
if (!prompt) {
console.error("[CLI] Error: prompt is required in print mode")
console.error("[CLI] Usage: roo <prompt> --print [options]")
console.error("[CLI] Run without -p for interactive mode")
process.exit(1)
}

if (flagOptions.tui) {
console.warn("[CLI] TUI disabled (no TTY support), falling back to plain text mode")
if (!flagOptions.print) {
console.warn("[CLI] TUI disabled (no TTY support), falling back to print mode")
}
}

Expand All @@ -161,7 +187,7 @@ export async function run(workspaceArg: string, flagOptions: FlagOptions) {
render(
createElement(App, {
...extensionHostOptions,
initialPrompt: flagOptions.prompt,
initialPrompt: prompt,
version: VERSION,
createExtensionHost: (opts: ExtensionHostOptions) => new ExtensionHost(opts),
}),
Expand Down Expand Up @@ -200,12 +226,9 @@ export async function run(workspaceArg: string, flagOptions: FlagOptions) {

try {
await host.activate()
await host.runTask(flagOptions.prompt!)
await host.runTask(prompt!)
await host.dispose()

if (!flagOptions.waitOnComplete) {
process.exit(0)
}
process.exit(0)
} catch (error) {
console.error("[CLI] Error:", error instanceof Error ? error.message : String(error))

Expand Down
Loading
Loading