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
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 3 additions & 1 deletion packages/api/src/services/terminal-sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -976,10 +976,12 @@ export const renderTmuxAttachCommand = (
`if ! command -v tmux >/dev/null 2>&1; then printf '%s\\n' ${
shellQuote(args.missingMessage ?? tmuxMissingMessage)
} >&2; exit 127; fi`,
`tmux has-session -t ${shellQuote(args.tmuxName)} 2>/dev/null || tmux new-session -d -s ${
`tmux has-session -t ${shellQuote(args.tmuxName)} 2>/dev/null || tmux start-server \\; set-option -g history-limit 50000 \\; new-session -d -s ${
shellQuote(args.tmuxName)
} -c ${shellQuote(args.targetDir)}`,
`tmux set-option -t ${shellQuote(args.tmuxName)} status off >/dev/null 2>&1 || true`,
`tmux set-option -t ${shellQuote(args.tmuxName)} history-limit 50000 >/dev/null 2>&1 || true`,
`tmux set-option -t ${shellQuote(args.tmuxName)} mouse on >/dev/null 2>&1 || true`,
`exec tmux attach-session -t ${shellQuote(args.tmuxName)}`
].join("; ")
return `bash --noprofile --norc -lc ${shellQuote(script)}`
Expand Down
28 changes: 27 additions & 1 deletion packages/api/tests/terminal-sessions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -247,12 +247,38 @@ describe("terminal sessions service", () => {
expect(command).toContain("command -v tmux")
expect(command).toContain("bash --noprofile --norc -lc")
expect(command).toContain("tmux missing")
expect(command).toContain("tmux new-session -d -s")
expect(command).toContain("tmux start-server")
expect(command).toContain("set-option -g history-limit 50000")
expect(command).toContain("new-session -d -s")
expect(command).toContain("tmux set-option")
expect(command).toContain("status off")
expect(command).toContain("history-limit 50000")
expect(command).toContain("mouse on")
Comment thread
coderabbitai[bot] marked this conversation as resolved.
expect(command).toContain("tmux attach-session -t")
expect(command).toContain("docker-git-session-1")
expect(command).toContain("/home/dev/project with spaces")

const startServerIndex = command.indexOf("tmux start-server")
const globalHistoryLimitIndex = command.indexOf("set-option -g history-limit 50000")
const newSessionIndex = command.indexOf("new-session -d -s")
const statusOffIndex = command.indexOf("status off")
const sessionHistoryLimitIndex = command.lastIndexOf("history-limit 50000")
const mouseOnIndex = command.indexOf("mouse on")
const attachSessionIndex = command.indexOf("tmux attach-session -t")

expect(startServerIndex).toBeGreaterThanOrEqual(0)
expect(globalHistoryLimitIndex).toBeGreaterThanOrEqual(0)
expect(newSessionIndex).toBeGreaterThanOrEqual(0)
expect(statusOffIndex).toBeGreaterThanOrEqual(0)
expect(sessionHistoryLimitIndex).toBeGreaterThanOrEqual(0)
expect(mouseOnIndex).toBeGreaterThanOrEqual(0)
expect(attachSessionIndex).toBeGreaterThanOrEqual(0)
expect(startServerIndex).toBeLessThan(globalHistoryLimitIndex)
expect(globalHistoryLimitIndex).toBeLessThan(newSessionIndex)
expect(newSessionIndex).toBeLessThan(statusOffIndex)
expect(statusOffIndex).toBeLessThan(sessionHistoryLimitIndex)
expect(sessionHistoryLimitIndex).toBeLessThan(mouseOnIndex)
expect(mouseOnIndex).toBeLessThan(attachSessionIndex)
})

it("fails before creating a durable session when tmux is unavailable", async () => {
Expand Down
28 changes: 17 additions & 11 deletions packages/app/src/docker-git/program-auth.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Effect, Match, pipe } from "effect"

import {
type ApiTerminalSession,
codexImport,
codexLogin,
codexLogout,
Expand All @@ -19,7 +20,7 @@ import {
} from "./api-client.js"
import { type ControllerRuntime, ensureControllerReady } from "./controller.js"
import type { Command } from "./frontend-lib/core/domain.js"
import type { ApiRequestError, CliError } from "./host-errors.js"
import type { ApiRequestError, CliError, ControllerBootstrapError } from "./host-errors.js"
import { terminalAuthTitle } from "./menu-auth-shared.js"
import { attachTerminalSession, type TerminalSessionClientError } from "./terminal-session-client.js"

Expand All @@ -45,7 +46,9 @@ export type RoutedAuthCommand = Extract<
}
>

const withControllerReady = <E, R>(effect: Effect.Effect<void, E, R>) =>
const withControllerReady = <E extends CliError, R>(
effect: Effect.Effect<void, E, R>
): Effect.Effect<void, E | ControllerBootstrapError, R | ControllerRuntime> =>
pipe(ensureControllerReady(), Effect.zipRight(effect))

const renderAuthPayload = (payload: JsonValue) => Effect.log(renderJsonPayload(payload))
Expand All @@ -57,6 +60,17 @@ const missingAuthTerminalSessionError = (provider: "GrokOauth"): ApiRequestError
message: `Controller did not create a terminal session for ${provider}.`
})

const attachGrokTerminalSession = (
session: ApiTerminalSession | null
): Effect.Effect<void, ApiRequestError | TerminalSessionClientError> =>
session === null
? Effect.fail(missingAuthTerminalSessionError("GrokOauth"))
: attachTerminalSession({
header: terminalAuthTitle("GrokOauth"),
session,
websocketPath: `/auth/terminal-sessions/${encodeURIComponent(session.id)}/ws`
})

const routedAuthTags: Readonly<Record<string, true>> = {
AuthCodexImport: true,
AuthCodexLogin: true,
Expand Down Expand Up @@ -111,15 +125,7 @@ const handleGrokLoginCommand = (
) =>
withControllerReady(
createAuthTerminalSession("GrokOauth", command.label).pipe(
Effect.flatMap((session): Effect.Effect<void, ApiRequestError | TerminalSessionClientError> =>
session === null
? Effect.fail(missingAuthTerminalSessionError("GrokOauth"))
: attachTerminalSession({
header: terminalAuthTitle("GrokOauth"),
session,
websocketPath: `/auth/terminal-sessions/${encodeURIComponent(session.id)}/ws`
})
)
Effect.flatMap((session) => attachGrokTerminalSession(session))
)
)

Expand Down
14 changes: 11 additions & 3 deletions packages/app/src/web/terminal-panel-runtime-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,14 +30,18 @@ import type {
TerminalSocketListenerArgs,
TerminalSocketRef
} from "./terminal-panel-runtime-types.js"
import { installTerminalQuerySuppression } from "./terminal-query-suppression.js"
import { installTerminalQuerySuppression, type TerminalQuerySuppressionOptions } from "./terminal-query-suppression.js"
import { resolveTerminalReconnectDelay, terminalReconnectGraceMs } from "./terminal-reconnect.js"
import { parseTerminalServerMessage, resolveTerminalWebSocketUrl } from "./terminal.js"

type TerminalClientMessage =
| { readonly data: string; readonly type: "input" }
| { readonly cols: number; readonly rows: number; readonly type: "resize" }

type TerminalRuntimeOptions = {
readonly querySuppression?: TerminalQuerySuppressionOptions
}

type TerminalInlineImageFetchError = {
readonly _tag: "TerminalInlineImageFetchError"
readonly message: string
Expand Down Expand Up @@ -76,16 +80,20 @@ const clearReconnectTimer = (lifecycle: TerminalLifecycleState): void => {
}
}

export const createTerminalRuntime = (host: HTMLDivElement): TerminalRuntime => {
export const createTerminalRuntime = (
host: HTMLDivElement,
options: TerminalRuntimeOptions = {}
): TerminalRuntime => {
const terminal = new Terminal({
allowProposedApi: true,
convertEol: false,
cursorBlink: true,
fontFamily: "'IBM Plex Mono', 'SFMono-Regular', monospace",
fontSize: 14,
scrollback: 50_000,
theme: { background: "#080a0d", foreground: "#f4f7fb" }
})
installTerminalQuerySuppression(terminal)
installTerminalQuerySuppression(terminal, options.querySuppression)
const fitAddon = new FitAddon()
terminal.loadAddon(fitAddon)
terminal.open(host)
Expand Down
9 changes: 8 additions & 1 deletion packages/app/src/web/terminal-panel-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,9 @@ const resolveMountHost = (
return hostRef.current
}

const shouldAllowTerminalMouseTracking = (session: TerminalLifecycleArgs["session"]): boolean =>
session.browserProjectId !== undefined

const mountTerminalSession = (args: TerminalLifecycleArgs): (() => void) | undefined => {
const host = resolveMountHost(args)
if (host === null) {
Expand All @@ -177,7 +180,11 @@ const mountTerminalSession = (args: TerminalLifecycleArgs): (() => void) | undef
args.connectionRef.current = { closing: false, opened: false }
const lifecycle = createLifecycleState()
const socketRef: TerminalSocketRef = { current: null }
const { fitAddon, terminal } = createTerminalRuntime(host)
const { fitAddon, terminal } = createTerminalRuntime(host, {
querySuppression: {
allowMouseTracking: shouldAllowTerminalMouseTracking(args.session)
}
})
const terminalInputController = createTerminalInputController(terminal, socketRef)
const pasteGuard = createTerminalPasteGuard()
const sendResize = (): void => {
Expand Down
63 changes: 41 additions & 22 deletions packages/app/src/web/terminal-query-suppression.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
export type TerminalQuerySuppression = { readonly dispose: () => void }

export type TerminalQuerySuppressionOptions = {
readonly allowMouseTracking?: boolean
}

type Disposable = { readonly dispose: () => void }

type FunctionIdentifier = {
Expand Down Expand Up @@ -29,26 +33,29 @@ export type TerminalQuerySuppressionTarget = {
}
}

// DEC private modes whose `h`/`l` setter causes xterm.js to start emitting
// unsolicited reply bytes back through `onData` on later DOM events:
// 1000/1002/1003/1006/1015/1016 — mouse tracking (mouse events -> bytes)
// 1004 — focus reporting (focus/blur -> CSI I/O)
// Suppressing the SET (`h`) leaves xterm.js in the default state (no event
// emission); suppressing the RESET (`l`) is harmless and kept for symmetry.
// DEC private modes whose `h`/`l` setter can cause xterm.js to emit event bytes
// back through `onData` on later DOM events.
const MOUSE_TRACKING_PRIVATE_MODES: ReadonlySet<number> = new Set([
1000,
1002,
1003,
1006,
1015,
1016
])
const FOCUS_REPORTING_PRIVATE_MODE = 1004

// Suppressing SET leaves xterm.js in the default state (no event emission);
// suppressing RESET is harmless and kept for symmetry.
// Modes intentionally left to fall through to xterm's built-in handlers:
// 25 — cursor visibility
// 1049 — alternate screen buffer
// 2004 — bracketed paste
// 2026 — synchronized output (Ink uses BSU/ESU around every frame)
// 1007 — alternate scroll (only changes wheel semantics, no leak)
const SUPPRESSED_PRIVATE_MODES: ReadonlySet<number> = new Set([
1000,
1002,
1003,
1004,
1006,
1015,
1016
...MOUSE_TRACKING_PRIVATE_MODES,
FOCUS_REPORTING_PRIVATE_MODE
])

const isColorQuery = (data: string): boolean => {
Expand All @@ -68,10 +75,20 @@ const extractParam = (param: CsiParam): number | null => {
return typeof head === "number" ? head : null
}

const containsSuppressedPrivateMode = (params: CsiParams): boolean => {
const shouldSuppressPrivateMode = (
mode: number,
options: TerminalQuerySuppressionOptions
): boolean =>
mode === FOCUS_REPORTING_PRIVATE_MODE ||
(options.allowMouseTracking !== true && MOUSE_TRACKING_PRIVATE_MODES.has(mode))

const containsSuppressedPrivateMode = (
params: CsiParams,
options: TerminalQuerySuppressionOptions
): boolean => {
for (const param of params) {
const value = extractParam(param)
if (value !== null && SUPPRESSED_PRIVATE_MODES.has(value)) {
if (value !== null && shouldSuppressPrivateMode(value, options)) {
return true
}
}
Expand All @@ -95,15 +112,17 @@ const registerDcsSuppressor = (

const registerSelectivePrivateModeSuppressor = (
terminal: TerminalQuerySuppressionTarget,
final: "h" | "l"
final: "h" | "l",
options: TerminalQuerySuppressionOptions
): Disposable =>
terminal.parser.registerCsiHandler(
{ final, prefix: "?" },
(params) => containsSuppressedPrivateMode(params)
(params) => containsSuppressedPrivateMode(params, options)
)

export const installTerminalQuerySuppression = (
terminal: TerminalQuerySuppressionTarget
terminal: TerminalQuerySuppressionTarget,
options: TerminalQuerySuppressionOptions = {}
): TerminalQuerySuppression => {
const disposables: ReadonlyArray<Disposable> = [
// OSC 4/10/11/12 — color queries (`?` payload). Set-color payloads fall through.
Expand Down Expand Up @@ -132,11 +151,11 @@ export const installTerminalQuerySuppression = (
// CSI Pm t — window manipulation. Gated by `windowOptions` (off by default);
// suppressed so an accidental future enable does not leak size reports.
registerCsiSuppressor(terminal, { final: "t" }),
// CSI ? h / CSI ? l — block xterm from enabling focus reporting and mouse
// tracking modes that would later pump unsolicited bytes back through onData.
// CSI ? h / CSI ? l — block xterm from enabling focus reporting and,
// unless explicitly allowed for tmux project terminals, mouse tracking modes.
// Other DEC private modes fall through to xterm's built-in setters.
registerSelectivePrivateModeSuppressor(terminal, "h"),
registerSelectivePrivateModeSuppressor(terminal, "l")
registerSelectivePrivateModeSuppressor(terminal, "h", options),
registerSelectivePrivateModeSuppressor(terminal, "l", options)
]
return {
dispose: () => {
Expand Down
20 changes: 19 additions & 1 deletion packages/app/tests/docker-git/terminal-query-suppression.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,11 @@ const ADDED_CSI_IDENTIFIERS: ReadonlyArray<FunctionIdentifier> = [
{ final: "t" }
]

const SUPPRESSED_MODES: ReadonlyArray<number> = [1000, 1002, 1003, 1004, 1006, 1015, 1016]
const MOUSE_TRACKING_MODES: ReadonlyArray<number> = [1000, 1002, 1003, 1006, 1015, 1016]

const FOCUS_REPORTING_MODE = 1004

const SUPPRESSED_MODES: ReadonlyArray<number> = [...MOUSE_TRACKING_MODES, FOCUS_REPORTING_MODE]

const PASS_THROUGH_MODES: ReadonlyArray<number> = [25, 1007, 1049, 2004, 2026]

Expand Down Expand Up @@ -218,6 +222,20 @@ describe("terminal query suppression", () => {
}
})

it("allows DEC private mouse tracking when explicitly enabled for tmux project terminals", () => {
const mock = createMockTerminal()
installTerminalQuerySuppression(mock.terminal, { allowMouseTracking: true })
const setHandler = findCsi(mock, { final: "h", prefix: "?" })
const resetHandler = findCsi(mock, { final: "l", prefix: "?" })

for (const mode of MOUSE_TRACKING_MODES) {
expect(setHandler.callback([mode])).toBe(false)
expect(resetHandler.callback([mode])).toBe(false)
}
expect(setHandler.callback([FOCUS_REPORTING_MODE])).toBe(true)
expect(resetHandler.callback([FOCUS_REPORTING_MODE])).toBe(true)
})

it("lets benign DEC private modes fall through to the built-in handler", () => {
const mock = createMockTerminal()
installTerminalQuerySuppression(mock.terminal)
Expand Down
28 changes: 15 additions & 13 deletions packages/lib/src/usecases/auth-grok-oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,11 @@ import { runCommandWithExitCodes } from "../shell/command-runner.js"
import { resolveDockerVolumeHostPath } from "../shell/docker-auth.js"
import { AuthError, CommandFailedError } from "../shell/errors.js"

// CHANGE: add Grok CLI OAuth/browser authentication flow
// WHY: issue #304 expects `grok login` style URL handoff and callback paste support
// QUOTE(ТЗ): "Paste the URL here if it doesn't connect"
// CHANGE: run the Grok CLI device-auth flow inside the auth container
// WHY: `docker-git auth grok login` must work from terminal-only containers without callback URL handling
// REF: issue-304
// SOURCE: https://x.ai/news/grok-build-cli
// FORMAT THEOREM: forall cmd: runGrokOauthLogin(cmd) -> grok_credentials_stored | error
// FORMAT THEOREM: forall cmd: runGrokOauthLogin(cmd) -> device_code_authorized -> grok_credentials_stored | error
// PURITY: SHELL
// EFFECT: Effect<void, AuthError | CommandFailedError | PlatformError, CommandExecutor>
// INVARIANT: Grok credentials are stored in ~/.grok within the selected account path
Expand Down Expand Up @@ -77,13 +76,13 @@ export const buildDockerGrokAuthArgs = (spec: DockerGrokAuthSpec): ReadonlyArray
return [...base, spec.image, "grok", "login", "--device-auth"]
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

const printOauthInstructions = (): Effect.Effect<void> =>
const printDeviceAuthInstructions = (): Effect.Effect<void> =>
Effect.sync(() => {
process.stderr.write("\n")
process.stderr.write("Grok CLI OAuth Authentication\n")
process.stderr.write("1. Open the Grok sign-in URL printed by the CLI.\n")
process.stderr.write("2. Complete browser authentication.\n")
process.stderr.write("3. If the callback cannot connect, paste the returned URL into the prompt.\n")
process.stderr.write("Grok CLI Device Authentication\n")
process.stderr.write("1. Copy the device code printed by the Grok CLI.\n")
process.stderr.write("2. Open the verification URL printed by the CLI in a browser.\n")
process.stderr.write("3. Complete approval; this terminal continues after the CLI writes credentials.\n")
process.stderr.write("\n")
})

Expand Down Expand Up @@ -120,18 +119,21 @@ const fixGrokAuthPermissions = (cwd: string, hostPath: string, containerPath: st
)

/**
* Runs the Grok OAuth device login inside the docker-git auth container.
* Runs the Grok CLI `--device-auth` login inside the docker-git auth container.
*
* The CLI prints a device code and verification URL; after the user completes
* approval externally, the command exits and credentials are normalized.
*
* @param cwd Working directory used for Docker command execution.
* @param accountPath Selected docker-git Grok account directory.
* @param options Auth container image and in-container home path.
* @returns Effect that completes after Grok writes credentials and permissions are normalized.
* @returns Effect that completes after device authorization writes credentials and permissions are normalized.
* @pure false
* @effect CommandExecutor; invokes Docker and writes credentials under the selected account path.
* @invariant successful completion leaves credentials scoped to accountPath and not to project source files.
* @precondition Docker is available and options.image contains the official Grok CLI binary.
* @postcondition accountPath ownership follows the mounted account root or a typed error is returned.
* @complexity O(n) local argument construction plus unbounded external OAuth interaction time.
* @complexity O(n) local argument construction plus unbounded external device authorization time.
* @throws Never - failures are modeled as AuthError, CommandFailedError, or PlatformError in the Effect type.
*/
export const runGrokOauthLoginWithPrompt = (
Expand All @@ -143,7 +145,7 @@ export const runGrokOauthLoginWithPrompt = (
}
): Effect.Effect<void, AuthError | CommandFailedError | PlatformError, CommandExecutor.CommandExecutor> =>
Effect.gen(function*(_) {
yield* _(printOauthInstructions())
yield* _(printDeviceAuthInstructions())
const hostPath = yield* _(resolveDockerVolumeHostPath(cwd, accountPath))
const spec = buildDockerGrokAuthSpec(cwd, hostPath, options.image, options.containerPath)
yield* _(
Expand Down
Loading