From 46e5372497d0db5232a586a86ed31c69512adeb1 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 4 Sep 2026 13:44:15 -0700 Subject: [PATCH 01/25] feat(auth): allow passive terminal observation with a read scope --- .../modules/t3terminal/T3TerminalModule.kt | 4 + .../expo/modules/t3terminal/T3TerminalView.kt | 17 ++- .../t3-terminal/ios/T3TerminalModule.swift | 4 + .../t3-terminal/ios/T3TerminalView.swift | 14 ++- .../terminal/NativeTerminalSurface.tsx | 22 ++-- .../terminal/ThreadTerminalRouteScreen.tsx | 54 ++++++--- .../features/terminal/nativeTerminalModule.ts | 1 + apps/mobile/src/state/use-terminal-session.ts | 25 +++-- apps/server/src/auth/RpcAuthorization.test.ts | 23 ++++ apps/server/src/auth/RpcAuthorization.ts | 6 +- .../project/ProjectSetupScriptRunner.test.ts | 1 + apps/server/src/server.test.ts | 104 +++++++++++++++++- apps/server/src/terminal/Manager.test.ts | 76 +++++++++++++ apps/server/src/terminal/Manager.ts | 29 ++++- apps/server/src/ws.ts | 11 ++ apps/web/src/components/ChatView.tsx | 68 +++++++++--- .../src/components/ThreadTerminalDrawer.tsx | 92 ++++++++++++---- .../settings/ConnectionsSettings.tsx | 6 + apps/web/src/state/terminalSessions.ts | 25 ++++- packages/client-runtime/src/rpc/client.ts | 3 +- packages/client-runtime/src/state/terminal.ts | 9 ++ packages/contracts/src/auth.ts | 3 + packages/contracts/src/ipc.ts | 8 ++ packages/contracts/src/rpc.ts | 10 ++ packages/contracts/src/terminal.ts | 3 + 25 files changed, 529 insertions(+), 89 deletions(-) diff --git a/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalModule.kt b/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalModule.kt index 1631c7fe68a1..d638a326b3f6 100644 --- a/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalModule.kt +++ b/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalModule.kt @@ -30,6 +30,10 @@ class T3TerminalModule : Module() { view.focusRequest = focusRequest } + Prop("readOnly") { view: T3TerminalView, readOnly: Boolean -> + view.readOnly = readOnly + } + Prop("autoFocus") { view: T3TerminalView, autoFocus: Boolean -> view.autoFocus = autoFocus } diff --git a/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalView.kt b/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalView.kt index 88de793a8f7d..fa70dd376270 100644 --- a/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalView.kt +++ b/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalView.kt @@ -76,6 +76,16 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex } } + var readOnly: Boolean = false + set(value) { + field = value + inputView.isEnabled = !value + if (value) { + inputView.clearFocus() + hideKeyboard() + } + } + var autoFocus: Boolean = true set(value) { field = value @@ -214,6 +224,7 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS inputView.setPadding(0, 0, 0, 0) inputView.setOnEditorActionListener { _, actionId, event -> + if (readOnly) return@setOnEditorActionListener true val isKeyUp = event?.action == KeyEvent.ACTION_UP val isImeSend = actionId == EditorInfo.IME_ACTION_SEND && !isKeyUp val isHardwareEnter = event?.keyCode == KeyEvent.KEYCODE_ENTER && @@ -228,6 +239,7 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex } } inputView.setOnKeyListener { _, keyCode, event -> + if (readOnly) return@setOnKeyListener true if (event.action != KeyEvent.ACTION_DOWN) return@setOnKeyListener false when { keyCode == KeyEvent.KEYCODE_DEL -> { @@ -249,7 +261,7 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) { - if (clearingInput || s == null || count <= 0) return + if (readOnly || clearingInput || s == null || count <= 0) return val end = (start + count).coerceAtMost(s.length) if (start >= end) return val insertedText = s.subSequence(start, end).toString() @@ -366,12 +378,13 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex } private fun emitResponse(response: ByteArray) { - if (response.isNotEmpty()) { + if (!readOnly && response.isNotEmpty()) { onInput(mapOf("data" to String(response, Charsets.UTF_8))) } } private fun requestKeyboardFocus() { + if (readOnly) return inputView.requestFocus() val inputMethodManager = context.getSystemService( Context.INPUT_METHOD_SERVICE diff --git a/apps/mobile/modules/t3-terminal/ios/T3TerminalModule.swift b/apps/mobile/modules/t3-terminal/ios/T3TerminalModule.swift index f68cc6b4a112..fb923f3bf949 100644 --- a/apps/mobile/modules/t3-terminal/ios/T3TerminalModule.swift +++ b/apps/mobile/modules/t3-terminal/ios/T3TerminalModule.swift @@ -27,6 +27,10 @@ public class T3TerminalModule: Module { view.focusRequest = focusRequest } + Prop("readOnly") { (view: T3TerminalView, readOnly: Bool) in + view.readOnly = readOnly + } + Prop("autoFocus") { (view: T3TerminalView, autoFocus: Bool) in view.autoFocus = autoFocus } diff --git a/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift b/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift index f04db4467fdf..6a12dbc552a7 100644 --- a/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift +++ b/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift @@ -248,6 +248,14 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { } } + var readOnly = false { + didSet { + if readOnly { + inputField.resignFirstResponder() + } + } + } + var autoFocus = true { didSet { guard oldValue != autoFocus else { return } @@ -582,7 +590,7 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { guard let input = String(data: bytes, encoding: .utf8), !input.isEmpty else { return } DispatchQueue.main.async { - view.onInput(["data": input]) + view.emitInput(input) } }, userdata) } @@ -657,13 +665,13 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { } private func requestKeyboardFocus() { - guard window != nil else { return } + guard window != nil, !readOnly else { return } inputField.becomeFirstResponder() textInputModeDidChange() } private func emitInput(_ data: String) { - guard !data.isEmpty else { return } + guard !readOnly, !data.isEmpty else { return } onInput(["data": data]) } diff --git a/apps/mobile/src/features/terminal/NativeTerminalSurface.tsx b/apps/mobile/src/features/terminal/NativeTerminalSurface.tsx index 37dec1fe4562..668ce508b69c 100644 --- a/apps/mobile/src/features/terminal/NativeTerminalSurface.tsx +++ b/apps/mobile/src/features/terminal/NativeTerminalSurface.tsx @@ -37,6 +37,7 @@ interface TerminalSurfaceProps extends ViewProps { readonly buffer: string; readonly fontSize?: number; readonly isRunning: boolean; + readonly readOnly?: boolean; readonly autoFocus?: boolean; readonly keyboardFocusRequest?: number; readonly theme?: TerminalTheme; @@ -62,7 +63,7 @@ const FallbackTerminalSurface = memo(function FallbackTerminalSurface(props: Ter const inputRef = useRef(null); const { themeAppearance, themeId } = useAppearancePreferences(); const theme = props.theme ?? getMobileTerminalTheme(themeId, themeAppearance); - const statusLabel = props.isRunning + const statusLabel = props.readOnly ? "Viewing terminal output." : props.isRunning ? "Native terminal unavailable. Using text fallback." : "Open terminal to start a shell."; @@ -72,14 +73,14 @@ const FallbackTerminalSurface = memo(function FallbackTerminalSurface(props: Ter }; useEffect(() => { - if ((props.keyboardFocusRequest ?? 0) > 0) { + if (!props.readOnly && (props.keyboardFocusRequest ?? 0) > 0) { inputRef.current?.blur(); const focusFrame = requestAnimationFrame(() => inputRef.current?.focus()); return () => cancelAnimationFrame(focusFrame); } return undefined; - }, [props.keyboardFocusRequest]); + }, [props.keyboardFocusRequest, props.readOnly]); return ( ({ - opacity: !props.isRunning ? 0.35 : pressed ? 0.65 : 1, + opacity: !props.isRunning || props.readOnly ? 0.35 : pressed ? 0.65 : 1, paddingHorizontal: 10, paddingVertical: 6, borderRadius: 8, @@ -191,7 +192,7 @@ export const TerminalSurface = memo(function TerminalSurface(props: TerminalSurf }, [hasNativeSurface, props.buffer.length, props.isRunning, props.terminalKey]); const handleNativeInput = useCallback( (event: NativeSyntheticEvent) => { - if (!props.isRunning) { + if (!props.isRunning || props.readOnly) { return; } terminalDebugLog("native:onInput", { @@ -199,7 +200,7 @@ export const TerminalSurface = memo(function TerminalSurface(props: TerminalSurf }); onInput(event.nativeEvent.data); }, - [onInput, props.isRunning], + [onInput, props.isRunning, props.readOnly], ); const handleNativeResize = useCallback( (event: NativeSyntheticEvent) => { @@ -216,9 +217,10 @@ export const TerminalSurface = memo(function TerminalSurface(props: TerminalSurf pickRunningTerminalSessionForBootstrap(knownSessions), - [knownSessions], + () => pickRunningTerminalSessionForBootstrap(knownSessions) ?? (canOperateTerminal ? null : knownSessions[0] ?? null), + [canOperateTerminal, knownSessions], ); const activeKnownSession = useMemo( () => knownSessions.find((session) => session.target.terminalId === terminalId) ?? null, @@ -330,7 +333,9 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) ); const terminal = useAttachedTerminalSession({ environmentId: selectedThread?.environmentId ?? null, - terminal: terminalAttachInput, + terminal: canOperateTerminal ? terminalAttachInput : canReadTerminal && selectedThread && activeKnownSession + ? { threadId: selectedThread.id, terminalId } + : null, }); const terminalKey = selectedThread ? `${selectedThread.environmentId}:${selectedThread.id}:${terminalId}` @@ -369,6 +374,7 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) return; } if ( + !canOperateTerminal || terminalAttachInput === null || !selectedThread || (terminal.status !== "closed" && terminal.status !== "exited") || @@ -399,6 +405,7 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) }, [ isRunning, openTerminal, + canOperateTerminal, selectedThread, terminal.status, terminal.version, @@ -507,7 +514,7 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) height: state.height, isVisible: state.isVisible, })); - const isAccessoryVisible = keyboardState.isVisible && !isAccessoryDismissed; + const isAccessoryVisible = canOperateTerminal && keyboardState.isVisible && !isAccessoryDismissed; const terminalBottomInset = (keyboardState.isVisible ? keyboardState.height : 0) + (isAccessoryVisible ? TERMINAL_ACCESSORY_HEIGHT : 0); @@ -612,6 +619,7 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) useEffect(() => { const initialInput = pendingLaunch?.initialInput; if ( + !canOperateTerminal || !initialInput || !selectedThread || terminal.version === 0 || @@ -635,6 +643,7 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) terminal.version, terminalId, writeTerminal, + canOperateTerminal, ]); useEffect(() => { @@ -700,7 +709,7 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) /** Resolves true once the pty accepted the write, false if it was skipped or rejected. */ const writeInput = useCallback( async (data: string): Promise => { - if (!selectedThread || !isRunning) { + if (!canOperateTerminal || !selectedThread || !isRunning) { return false; } @@ -714,7 +723,7 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) }); return result._tag === "Success"; }, - [isRunning, selectedThread, terminalId, writeTerminal], + [canOperateTerminal, isRunning, selectedThread, terminalId, writeTerminal], ); const pasteSessionRef = useRef | null>(null); @@ -725,11 +734,11 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) // Drop delayed clipboard reads whenever the route or attached pty changes. useEffect(() => { - pasteSession.reset(isRunning); + pasteSession.reset(canOperateTerminal && isRunning); return () => { pasteSession.reset(false); }; - }, [isRunning, pasteSession, terminal.lifecycleVersion, terminalKey]); + }, [canOperateTerminal, isRunning, pasteSession, terminal.lifecycleVersion, terminalKey]); const pasteFromClipboard = useCallback(async () => { await pasteSession.paste({ @@ -801,7 +810,7 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) } setLastGridSize(size); - if (!selectedThread || !isRunning) { + if (!canOperateTerminal || !selectedThread || !isRunning) { return; } @@ -824,6 +833,7 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) routeEnvironmentId, routeThreadId, resizeTerminal, + canOperateTerminal, scheduleBufferReplayReady, selectedThread, terminalId, @@ -887,7 +897,7 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) // the dead snapshot as an exit observed on this screen. A pending exit // navigation stays armed — it only clears once the session runs again — // so refocusing a dead screen still leaves it. - if (terminalAttachInput === null) { + if (!canOperateTerminal || terminalAttachInput === null) { runningTerminalKeyRef.current = null; return; } @@ -925,6 +935,7 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) pendingExitNavigationRef.current = terminalKey; }, [ closeTerminal, + canOperateTerminal, isRunning, navigateAwayAfterExit, navigation, @@ -948,7 +959,7 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) ); const handleOpenNewTerminal = useCallback(() => { - if (!selectedThread) { + if (!canOperateTerminal || !selectedThread) { return; } @@ -962,7 +973,7 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) }), }), ); - }, [navigation, selectedThread, terminalId, terminalMenuSessions]); + }, [canOperateTerminal, navigation, selectedThread, terminalId, terminalMenuSessions]); const handleDecreaseFontSize = useCallback(() => { setTerminalFontSize(stepTerminalFontSize(fontSize, -1)); @@ -1004,11 +1015,12 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) { id: "terminal-new", title: "Open new terminal", + attributes: { disabled: !canOperateTerminal }, image: "plus", subtitle: `Start another shell in ${basename(selectedThreadProject?.workspaceRoot ?? null) ?? "this workspace"}`, }, ], - [fontSize, selectedThreadProject?.workspaceRoot, terminalId, terminalMenuSessions], + [canOperateTerminal, fontSize, selectedThreadProject?.workspaceRoot, terminalId, terminalMenuSessions], ); const handleAndroidTerminalMenuAction = useCallback( @@ -1034,7 +1046,7 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) ); const handleClearTerminal = useCallback(() => { - if (!selectedThread) { + if (!canOperateTerminal || !selectedThread) { return; } @@ -1046,7 +1058,7 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) terminalId, }, }); - }, [clearTerminal, selectedThread, terminalId]); + }, [canOperateTerminal, clearTerminal, selectedThread, terminalId]); const handleToolbarActionPress = useCallback( (action: TerminalToolbarAction) => { @@ -1232,6 +1244,7 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) Open new terminal @@ -1258,11 +1271,16 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) resourceName="terminal" onRetry={handleRetryEnvironment} /> + ) : !canReadTerminal && !canOperateTerminal ? ( + + ) : !canOperateTerminal && activeKnownSession === null ? ( + ) : ( <> - ) : !keyboardState.isVisible ? ( + ) : canOperateTerminal && !keyboardState.isVisible ? ( { + const canRead = useEnvironmentScope(input.environmentId, AuthTerminalReadScope); const metadata = useEnvironmentQuery( - input.environmentId === null + input.environmentId === null || !canRead ? null : terminalEnvironment.metadata({ environmentId: input.environmentId, diff --git a/apps/server/src/auth/RpcAuthorization.test.ts b/apps/server/src/auth/RpcAuthorization.test.ts index 7c6797d7f790..04da93a9eb1f 100644 --- a/apps/server/src/auth/RpcAuthorization.test.ts +++ b/apps/server/src/auth/RpcAuthorization.test.ts @@ -5,6 +5,8 @@ import { AuthPreviewOperateScope, AuthRelayReadScope, AuthRelayWriteScope, + AuthTerminalReadScope, + AuthTerminalOperateScope, WS_METHODS, WsRpcGroup, } from "@t3tools/contracts"; @@ -88,6 +90,27 @@ describe("RPC authorization scopes", () => { } }); + it("separates passive terminal observation from operations that can change a shell", () => { + for (const method of [ + WS_METHODS.terminalObserve, + WS_METHODS.subscribeTerminalEvents, + WS_METHODS.subscribeTerminalMetadata, + ]) { + expect(requiredScopeForRpcMethod(method)).toBe(AuthTerminalReadScope); + } + for (const method of [ + WS_METHODS.terminalAttach, + WS_METHODS.terminalOpen, + WS_METHODS.terminalWrite, + WS_METHODS.terminalResize, + WS_METHODS.terminalClear, + WS_METHODS.terminalRestart, + WS_METHODS.terminalClose, + ]) { + expect(requiredScopeForRpcMethod(method)).toBe(AuthTerminalOperateScope); + } + }); + it("rejects unknown RPC method names", () => { for (const method of ["server.notRegistered", "toString", "constructor"]) { expect(() => requiredScopeForRpcMethod(method)).toThrow( diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 692f487fd879..35ee72ca2036 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -13,6 +13,7 @@ import { AuthRelayWriteScope, AuthSourceControlWriteScope, AuthTerminalOperateScope, + AuthTerminalReadScope, ORCHESTRATION_WS_METHODS, type AuthEnvironmentScope, WS_METHODS, @@ -129,13 +130,14 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.reviewGetDiffFileContents]: AuthFilesystemReadScope, [WS_METHODS.terminalOpen]: AuthTerminalOperateScope, [WS_METHODS.terminalAttach]: AuthTerminalOperateScope, + [WS_METHODS.terminalObserve]: AuthTerminalReadScope, [WS_METHODS.terminalWrite]: AuthTerminalOperateScope, [WS_METHODS.terminalResize]: AuthTerminalOperateScope, [WS_METHODS.terminalClear]: AuthTerminalOperateScope, [WS_METHODS.terminalRestart]: AuthTerminalOperateScope, [WS_METHODS.terminalClose]: AuthTerminalOperateScope, - [WS_METHODS.subscribeTerminalEvents]: AuthTerminalOperateScope, - [WS_METHODS.subscribeTerminalMetadata]: AuthTerminalOperateScope, + [WS_METHODS.subscribeTerminalEvents]: AuthTerminalReadScope, + [WS_METHODS.subscribeTerminalMetadata]: AuthTerminalReadScope, [WS_METHODS.previewOpen]: AuthPreviewOperateScope, [WS_METHODS.previewNavigate]: AuthPreviewOperateScope, [WS_METHODS.previewResize]: AuthPreviewOperateScope, diff --git a/apps/server/src/project/ProjectSetupScriptRunner.test.ts b/apps/server/src/project/ProjectSetupScriptRunner.test.ts index 6cc399dd4de2..ac634cf76d50 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.test.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.test.ts @@ -57,6 +57,7 @@ const makeTerminalManagerLayer = ( Layer.succeed(TerminalManager.TerminalManager, { ...overrides, attachStream: () => Effect.die(new Error("unused")), + observeStream: () => Effect.die(new Error("unused")), resize: () => Effect.void, clear: () => Effect.void, restart: () => Effect.die(new Error("unused")), diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 23262ee938b4..6d9105b6d912 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -7,7 +7,6 @@ import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hos import { AuthAccessTokenType, AuthAdministrativeScopes, - AuthOrchestrationOperateScope, AuthSourceControlWriteScope, AuthPreviewOperateScope, AuthStandardClientScopes, @@ -6292,6 +6291,109 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("terminal observers can read output but cannot start or change a terminal", () => + Effect.gen(function* () { + let observations = 0; + yield* buildAppUnderTest({ + layers: { + terminalManager: { + observeStream: (input, listener) => + Effect.gen(function* () { + observations += 1; + yield* listener({ + type: "snapshot", + snapshot: { + threadId: input.threadId, + terminalId: input.terminalId, + cwd: "/workspace", + worktreePath: null, + status: "exited", + pid: null, + history: "retained output", + exitCode: 0, + exitSignal: null, + label: "Terminal", + updatedAt: "2026-09-04T00:00:00.000Z", + }, + }); + return () => {}; + }), + subscribeMetadata: (listener) => + listener({ type: "snapshot", terminals: [] }).pipe(Effect.as(() => {})), + subscribe: (listener) => + listener({ + type: "output", + threadId: "thread-1", + terminalId: "term-1", + data: "live output", + }).pipe(Effect.as(() => {})), + }, + }, + }); + const token = yield* exchangeAccessToken(defaultDesktopBootstrapToken, { + scope: "terminal:read", + }); + assert.equal(token.response.status, 200); + const ticketResponse = yield* HttpClient.post("/api/auth/websocket-ticket", { + headers: { authorization: `Bearer ${token.body.access_token ?? ""}` }, + }); + const { ticket } = yield* responseJsonEffect<{ readonly ticket: string }>(ticketResponse); + const wsUrl = `${yield* getWsServerUrl("/ws", { authenticated: false })}?wsTicket=${encodeURIComponent(ticket)}`; + yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + Effect.gen(function* () { + const input = { threadId: "thread-1", terminalId: "term-1" }; + const event = yield* client[WS_METHODS.terminalObserve](input).pipe( + Stream.runHead, + Effect.map(Option.getOrThrow), + ); + assert.equal(event.type, "snapshot"); + if (event.type === "snapshot") assert.equal(event.snapshot.history, "retained output"); + const metadata = yield* client[WS_METHODS.subscribeTerminalMetadata]({}).pipe( + Stream.runHead, + Effect.map(Option.getOrThrow), + ); + assert.equal(metadata.type, "snapshot"); + const output = yield* client[WS_METHODS.subscribeTerminalEvents]({}).pipe( + Stream.runHead, + Effect.map(Option.getOrThrow), + ); + assert.equal(output.type, "output"); + const errors = [ + yield* client[WS_METHODS.terminalAttach]({ ...input, cwd: "/workspace" }).pipe( + Stream.runHead, + Effect.flip, + ), + yield* client[WS_METHODS.terminalOpen]({ ...input, cwd: "/workspace" }).pipe( + Effect.flip, + ), + yield* client[WS_METHODS.terminalWrite]({ ...input, data: "whoami\r" }).pipe( + Effect.flip, + ), + yield* client[WS_METHODS.terminalResize]({ ...input, cols: 80, rows: 24 }).pipe( + Effect.flip, + ), + yield* client[WS_METHODS.terminalClear](input).pipe(Effect.flip), + yield* client[WS_METHODS.terminalRestart]({ + ...input, + cwd: "/workspace", + cols: 80, + rows: 24, + }).pipe(Effect.flip), + yield* client[WS_METHODS.terminalClose](input).pipe(Effect.flip), + ]; + for (const error of errors) { + assert.equal(error._tag, "EnvironmentAuthorizationError"); + if (error._tag === "EnvironmentAuthorizationError") + assert.equal(error.requiredScope, "terminal:operate"); + } + }), + ), + ); + assert.equal(observations, 1); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("provider setup lets read-only clients observe installation but not change setup", () => Effect.gen(function* () { let installStarts = 0; diff --git a/apps/server/src/terminal/Manager.test.ts b/apps/server/src/terminal/Manager.test.ts index e480e11588b0..5ed62118c295 100644 --- a/apps/server/src/terminal/Manager.test.ts +++ b/apps/server/src/terminal/Manager.test.ts @@ -16,6 +16,7 @@ import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Data from "effect/Data"; import * as Deferred from "effect/Deferred"; import * as Duration from "effect/Duration"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Encoding from "effect/Encoding"; import * as Exit from "effect/Exit"; @@ -2414,6 +2415,81 @@ it.layer( }), ); + it.effect("observes terminal history and live output without changing the process", () => + Effect.gen(function* () { + const { manager, ptyAdapter } = yield* createManager(); + const opened = yield* manager.open(openInput({ env: { OBSERVER_TEST: "original" } })); + const process = ptyAdapter.processes[0]!; + const historyReceived = yield* Deferred.make(); + const unsubscribeHistory = yield* manager.subscribe((event) => + event.type === "output" ? Deferred.succeed(historyReceived, undefined).pipe(Effect.asVoid) : Effect.void, + ); + process.emitData("existing history\n"); + yield* Deferred.await(historyReceived); + unsubscribeHistory(); + + const observed = yield* Ref.make>([]); + const liveReceived = yield* Deferred.make(); + const unsubscribe = yield* manager.observeStream( + { threadId: opened.threadId, terminalId: opened.terminalId }, + (event) => Ref.update(observed, (events) => [...events, event]).pipe( + Effect.andThen(event.type === "output" + ? Deferred.succeed(liveReceived, undefined).pipe(Effect.asVoid) + : Effect.void), + ), + ); + yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); + process.emitData("live output\n"); + yield* Deferred.await(liveReceived); + + expect(yield* Ref.get(observed)).toMatchObject([ + { type: "snapshot", snapshot: { cwd: opened.cwd, worktreePath: opened.worktreePath, pid: opened.pid, history: "existing history\n" } }, + { type: "output", data: "live output\n" }, + ]); + expect(ptyAdapter.spawnInputs).toHaveLength(1); + expect(ptyAdapter.spawnInputs[0]?.env.OBSERVER_TEST).toBe("original"); + expect(process.resizeCalls).toEqual([]); + expect(process.writes).toEqual([]); + expect(process.killSignals).toEqual([]); + }), + ); + + it.effect("observes exited terminals without restarting and rejects missing sessions", () => + Effect.gen(function* () { + const { manager, ptyAdapter } = yield* createManager(); + const missingEvents: TerminalAttachStreamEvent[] = []; + const missing = yield* manager.observeStream( + { threadId: "thread-1", terminalId: DEFAULT_TERMINAL_ID }, + (event) => Effect.sync(() => { missingEvents.push(event); }), + ).pipe(Effect.flip); + expect(missing._tag).toBe("TerminalSessionLookupError"); + expect(ptyAdapter.spawnInputs).toEqual([]); + + yield* manager.open(openInput()); + const process = ptyAdapter.processes[0]!; + const exited = yield* Deferred.make(); + const unsubscribeExit = yield* manager.subscribe((event) => + event.type === "exited" ? Deferred.succeed(exited, undefined).pipe(Effect.asVoid) : Effect.void, + ); + process.emitExit({ exitCode: 7 }); + yield* Deferred.await(exited); + unsubscribeExit(); + + const events: TerminalAttachStreamEvent[] = []; + const unsubscribe = yield* manager.observeStream( + { threadId: "thread-1", terminalId: DEFAULT_TERMINAL_ID }, + (event) => Effect.sync(() => { events.push(event); }), + ); + unsubscribe(); + expect(events).toMatchObject([{ type: "snapshot", snapshot: { status: "exited", exitCode: 7, pid: null } }]); + expect(ptyAdapter.spawnInputs).toHaveLength(1); + expect(process.resizeCalls).toEqual([]); + expect(process.writes).toEqual([]); + expect(process.killSignals).toEqual([]); + expect(missingEvents).toEqual([]); + }), + ); + it.effect("buffers attach output delivered during the initial snapshot callback", () => Effect.gen(function* () { const { manager, ptyAdapter } = yield* createManager(5, { diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts index d9bdc6bcd92a..7f88d7c9ee6e 100644 --- a/apps/server/src/terminal/Manager.ts +++ b/apps/server/src/terminal/Manager.ts @@ -27,6 +27,7 @@ import { type TerminalEvent, type TerminalMetadataStreamEvent, type TerminalOpenInput, + type TerminalObserveInput, type TerminalResizeInput, type TerminalRestartInput, type TerminalSessionSnapshot, @@ -163,6 +164,12 @@ export class TerminalManager extends Context.Service< listener: (event: TerminalAttachStreamEvent) => Effect.Effect, ) => Effect.Effect<() => void, TerminalError>; + /** Observe an existing session without starting or changing its process. */ + readonly observeStream: ( + input: TerminalObserveInput, + listener: (event: TerminalAttachStreamEvent) => Effect.Effect, + ) => Effect.Effect<() => void, TerminalError>; + /** * Write input bytes to a terminal session. */ @@ -2650,7 +2657,11 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func }; }); - const attachStream: TerminalManager["Service"]["attachStream"] = (input, listener) => { + const streamSession = ( + input: TerminalObserveInput, + initial: Effect.Effect, + listener: (event: TerminalAttachStreamEvent) => Effect.Effect, + ) => { let unsubscribe: (() => void) | null = null; return Effect.gen(function* () { @@ -2671,7 +2682,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func return attachEvent ? listener(attachEvent) : Effect.void; }); - const initialSnapshot = yield* openOrAttachForStream(input); + const initialSnapshot = yield* initial; yield* listener({ type: "snapshot", @@ -2707,6 +2718,19 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func ); }; + const attachStream: TerminalManager["Service"]["attachStream"] = (input, listener) => + streamSession(input, openOrAttachForStream(input), listener); + + const observeStream: TerminalManager["Service"]["observeStream"] = (input, listener) => + streamSession( + input, + withThreadLock( + input.threadId, + requireSession(input.threadId, input.terminalId).pipe(Effect.map(snapshot)), + ), + listener, + ); + const metadataEventFromTerminalEvent = ( event: TerminalEvent, ): Effect.Effect => { @@ -2961,6 +2985,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func return TerminalManager.of({ open, attachStream, + observeStream, write, resize, clear, diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 82af703e2722..94fdf8c275ae 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -2594,6 +2594,17 @@ const makeWsRpcLayer = ( observeRpcEffect(WS_METHODS.terminalWrite, terminalManager.write(input), { "rpc.aggregate": "terminal", }), + [WS_METHODS.terminalObserve]: (input) => + observeRpcStream( + WS_METHODS.terminalObserve, + Stream.callback((queue) => + Effect.acquireRelease( + terminalManager.observeStream(input, (event) => Queue.offer(queue, event)), + (unsubscribe) => Effect.sync(unsubscribe), + ), + ), + { "rpc.aggregate": "terminal" }, + ), [WS_METHODS.terminalResize]: (input) => observeRpcEffect(WS_METHODS.terminalResize, terminalManager.resize(input), { "rpc.aggregate": "terminal", diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 3bec8d244be5..ce8d41cd33e8 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -3,6 +3,8 @@ import { AuthSettingsWriteScope, AuthSourceControlWriteScope, AuthPreviewOperateScope, + AuthTerminalReadScope, + AuthTerminalOperateScope, type AssistantCitation, type ApprovalRequestId, type ChatFileAttachment, @@ -288,6 +290,7 @@ import { serverEnvironment, } from "../state/server"; import { terminalEnvironment } from "../state/terminal"; +import { useEnvironmentScope } from "../state/session"; import { threadEnvironment, useEnvironmentThread } from "../state/threads"; import { requestOlderThreadTurns, @@ -821,6 +824,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra keybindings, onAddTerminalContext, }: PersistentThreadTerminalDrawerProps) { + const canOperateTerminal = useEnvironmentScope(threadRef.environmentId, AuthTerminalOperateScope); const openTerminal = useAtomCommand(terminalEnvironment.open, "terminal open"); const writeTerminal = useAtomCommand(terminalEnvironment.write, "terminal write"); const closeTerminalMutation = useAtomCommand(terminalEnvironment.close, "terminal close"); @@ -982,7 +986,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra ); const splitTerminal = useCallback(() => { - if (!cwd) { + if (!canOperateTerminal || !cwd) { return; } const terminalId = nextTerminalId(allocatableTerminalIds); @@ -1008,9 +1012,10 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra threadId, threadRef, openTerminal, + canOperateTerminal, ]); const splitTerminalVertical = useCallback(() => { - if (!cwd) { + if (!canOperateTerminal || !cwd) { return; } const terminalId = nextTerminalId(allocatableTerminalIds); @@ -1032,6 +1037,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra cwd, effectiveWorktreePath, openTerminal, + canOperateTerminal, runtimeEnv, storeSplitTerminalVertical, threadId, @@ -1039,7 +1045,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra ]); const createNewTerminal = useCallback(() => { - if (!cwd) { + if (!canOperateTerminal || !cwd) { return; } const terminalId = nextTerminalId(allocatableTerminalIds); @@ -1065,6 +1071,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra threadId, threadRef, openTerminal, + canOperateTerminal, ]); const activateTerminal = useCallback( @@ -1077,6 +1084,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra const closeTerminal = useCallback( (terminalId: string) => { + if (!canOperateTerminal) return; const fallbackExitWrite = () => writeTerminal({ environmentId: threadRef.environmentId, @@ -1106,6 +1114,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra threadId, threadRef, closeTerminalMutation, + canOperateTerminal, writeTerminal, ], ); @@ -1144,7 +1153,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra visible={visible} height={terminalUiState.terminalHeight} // Known-session order is MRU and changes on focus; persisted store order keeps sidebar labels stable. - terminalIds={terminalUiState.terminalIds} + terminalIds={canOperateTerminal ? terminalUiState.terminalIds : serverOrderedTerminalIds} activeTerminalId={terminalUiState.activeTerminalId} terminalGroups={terminalUiState.terminalGroups} activeTerminalGroupId={terminalUiState.activeTerminalGroupId} @@ -1379,6 +1388,8 @@ export default function ChatView(props: ChatViewProps) { forceExpandedMobileComposer = false, } = props; const canOperateThread = useEnvironmentScope(environmentId, AuthOrchestrationOperateScope); + const canOperateTerminal = useEnvironmentScope(environmentId, AuthTerminalOperateScope); + const canReadTerminal = useEnvironmentScope(environmentId, AuthTerminalReadScope); const draftId = routeKind === "draft" ? props.draftId : null; const threadSyncPhase = routeKind === "server" ? (props.threadSyncPhase ?? null) : null; const threadDetailLoading = threadSyncPhase === "loading"; @@ -3269,9 +3280,9 @@ export default function ChatView(props: ChatViewProps) { [activeThreadRef, storeSetTerminalOpen], ); const toggleTerminalVisibility = useCallback(() => { - if (!activeThreadRef) return; + if (!activeThreadRef || (!canReadTerminal && !canOperateTerminal)) return; const nextOpen = !terminalUiState.terminalOpen; - if (nextOpen && terminalUiState.terminalIds.length === 0) { + if (nextOpen && canOperateTerminal && terminalUiState.terminalIds.length === 0) { if (!activeThreadId || !activeProject) { return; } @@ -3306,14 +3317,22 @@ export default function ChatView(props: ChatViewProps) { environmentId, gitCwd, openTerminal, + canOperateTerminal, setTerminalOpen, + canReadTerminal, storeEnsureTerminal, terminalUiState.terminalIds.length, terminalUiState.terminalOpen, ]); const splitTerminal = useCallback( (direction: "horizontal" | "vertical" = "horizontal") => { - if (!activeThreadRef || hasReachedSplitLimit || !activeThreadId || !activeProject) { + if ( + !canOperateTerminal || + !activeThreadRef || + hasReachedSplitLimit || + !activeThreadId || + !activeProject + ) { return; } const cwdForOpen = gitCwd ?? activeProject.workspaceRoot; @@ -3347,6 +3366,7 @@ export default function ChatView(props: ChatViewProps) { allocatableActiveTerminalIds, activeThreadRef, openTerminal, + canOperateTerminal, activeThreadWorktreePath, environmentId, gitCwd, @@ -3356,7 +3376,7 @@ export default function ChatView(props: ChatViewProps) { ], ); const createNewTerminal = useCallback(() => { - if (!activeThreadRef || !activeThreadId || !activeProject) { + if (!canOperateTerminal || !activeThreadRef || !activeThreadId || !activeProject) { return; } const cwdForOpen = gitCwd ?? activeProject.workspaceRoot; @@ -3385,6 +3405,7 @@ export default function ChatView(props: ChatViewProps) { allocatableActiveTerminalIds, activeThreadRef, openTerminal, + canOperateTerminal, activeThreadWorktreePath, environmentId, gitCwd, @@ -3392,7 +3413,7 @@ export default function ChatView(props: ChatViewProps) { ]); const closeTerminal = useCallback( (terminalId: string) => { - if (!activeThreadId || !activeThreadRef) return; + if (!canOperateTerminal || !activeThreadId || !activeThreadRef) return; const fallbackExitWrite = () => writeTerminal({ environmentId, @@ -3418,6 +3439,7 @@ export default function ChatView(props: ChatViewProps) { activeThreadId, activeThreadRef, closeTerminalMutation, + canOperateTerminal, environmentId, storeCloseTerminal, writeTerminal, @@ -3434,7 +3456,7 @@ export default function ChatView(props: ChatViewProps) { rememberAsLastInvoked?: boolean; }, ) => { - if (!activeThreadId || !activeProject || !activeThread) return; + if (!canOperateTerminal || !activeThreadId || !activeProject || !activeThread) return; if (options?.rememberAsLastInvoked !== false) { setLastInvokedScriptByProjectId((current) => { if (current[activeProject.id] === script.id) return current; @@ -3535,6 +3557,7 @@ export default function ChatView(props: ChatViewProps) { setLastInvokedScriptByProjectId, environmentId, openTerminal, + canOperateTerminal, activeKnownTerminalIds, allocatableActiveTerminalIds, runningTerminalIds, @@ -4131,7 +4154,7 @@ export default function ChatView(props: ChatViewProps) { } }, [activeThreadRef]); const addTerminalSurface = useCallback(() => { - if (!activeThreadRef || !activeThreadId || !activeProject) return; + if (!canOperateTerminal || !activeThreadRef || !activeThreadId || !activeProject) return; const cwd = gitCwd ?? activeProject.workspaceRoot; const terminalId = nextTerminalId(allocatableActiveTerminalIds); useRightPanelStore.getState().openTerminal(activeThreadRef, terminalId); @@ -4157,10 +4180,12 @@ export default function ChatView(props: ChatViewProps) { allocatableActiveTerminalIds, gitCwd, openTerminal, + canOperateTerminal, ]); const splitPanelTerminal = useCallback( (direction: "horizontal" | "vertical" = "horizontal") => { if ( + !canOperateTerminal || !activeThreadRef || !activeThreadId || !activeProject || @@ -4198,6 +4223,7 @@ export default function ChatView(props: ChatViewProps) { allocatableActiveTerminalIds, gitCwd, openTerminal, + canOperateTerminal, ], ); const splitPanelTerminalVertical = useCallback(() => { @@ -4215,7 +4241,8 @@ export default function ChatView(props: ChatViewProps) { ); const closePanelTerminal = useCallback( (terminalId: string) => { - if (!activeThreadRef || activeRightPanelSurface?.kind !== "terminal") return; + if (!canOperateTerminal || !activeThreadRef || activeRightPanelSurface?.kind !== "terminal") + return; void closeTerminalMutation({ environmentId: activeThreadRef.environmentId, input: { threadId: activeThreadRef.threadId, terminalId, deleteHistory: true }, @@ -4226,25 +4253,33 @@ export default function ChatView(props: ChatViewProps) { .closeTerminal(activeThreadRef, activeRightPanelSurface.id, terminalId); setTerminalFocusRequestId((value) => value + 1); }, - [activeRightPanelSurface, activeThreadRef, closeTerminalMutation, storeCloseTerminal], + [ + canOperateTerminal, + activeRightPanelSurface, + activeThreadRef, + closeTerminalMutation, + storeCloseTerminal, + ], ); const requestCloseTerminal = useCallback( (terminalId: string) => { + if (!canOperateTerminal) return; const label = activeTerminalLabelsById.get(terminalId) ?? getTerminalLabel(terminalId); void confirmTerminalClose([label]).then((confirmed) => { if (confirmed) closeTerminal(terminalId); }); }, - [activeTerminalLabelsById, closeTerminal], + [canOperateTerminal, activeTerminalLabelsById, closeTerminal], ); const requestClosePanelTerminal = useCallback( (terminalId: string) => { + if (!canOperateTerminal) return; const label = activeTerminalLabelsById.get(terminalId) ?? getTerminalLabel(terminalId); void confirmTerminalClose([label]).then((confirmed) => { if (confirmed) closePanelTerminal(terminalId); }); }, - [activeTerminalLabelsById, closePanelTerminal], + [canOperateTerminal, activeTerminalLabelsById, closePanelTerminal], ); const activateRightPanelSurface = useCallback( (surface: RightPanelSurface) => { @@ -4291,7 +4326,7 @@ export default function ChatView(props: ChatViewProps) { threadRef: activeThreadRef, }); } - if (surface.kind === "terminal") { + if (surface.kind === "terminal" && canOperateTerminal) { for (const terminalId of surface.terminalIds) { storeCloseTerminal(activeThreadRef, terminalId); void closeTerminalMutation({ @@ -4308,6 +4343,7 @@ export default function ChatView(props: ChatViewProps) { canOperatePreview, closePreview, closeTerminalMutation, + canOperateTerminal, storeCloseTerminal, ], ); diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index 1702a7fb5a0b..534df3e2fdba 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -20,6 +20,7 @@ import { } from "lucide-react"; import { AuthPreviewOperateScope, + AuthTerminalOperateScope, type ContextMenuItem, type ProviderInstanceId, type ResolvedKeybindingsConfig, @@ -83,6 +84,7 @@ import { serverEnvironment } from "../state/server"; import { previewEnvironment } from "../state/preview"; import { readEnvironmentScope } from "../state/session"; import { terminalEnvironment } from "../state/terminal"; +import { useEnvironmentScope } from "../state/session"; import { openTerminalLinkInPreview } from "./preview/openTerminalLinkInPreview"; import { useAtomCommand } from "../state/use-atom-command"; import { preventTerminalCloseShortcut } from "../lib/terminalCloseShortcut"; @@ -272,6 +274,7 @@ export function terminalSelectionMenuItems(options?: { export function terminalContextMenuItems(options: { hasSelection: boolean; canAddToChat?: boolean; + readOnly?: boolean; }): ContextMenuItem[] { const { hasSelection, canAddToChat = true } = options; return [ @@ -279,7 +282,7 @@ export function terminalContextMenuItems(options: { ...item, disabled: !hasSelection, })), - { id: "paste", label: "Paste" }, + { id: "paste", label: "Paste", ...(options.readOnly ? { disabled: true } : {}) }, ]; } @@ -357,6 +360,8 @@ export function TerminalViewport({ const terminalRef = useRef(null); const visibleRef = useRef(visible); const environmentId = threadRef.environmentId; + const canOperateTerminal = useEnvironmentScope(environmentId, AuthTerminalOperateScope); + const hasTerminalWriteAccess = useEffectEvent(() => canOperateTerminal); const serverConfig = useAtomValue(serverEnvironment.configValueAtom(environmentId)); const openInPreferredEditor = useOpenInPreferredEditor( environmentId, @@ -381,7 +386,7 @@ export function TerminalViewport({ const keybindingsRef = useRef(keybindings); const runtimeEnvKey = useMemo(() => runtimeEnvSignature(runtimeEnv), [runtimeEnv]); const handleSessionExited = useEffectEvent(() => { - onSessionExited(); + if (canOperateTerminal) onSessionExited(); }); const handleAddTerminalContext = useEffectEvent((selection: TerminalContextSelection) => { onAddTerminalContext?.(selection); @@ -420,12 +425,13 @@ export function TerminalViewport({ input: { threadId, terminalId, data }, }), ); - const resizeTerminal = useEffectEvent((cols: number, rows: number) => - runTerminalResize({ + const resizeTerminal = useEffectEvent((cols: number, rows: number) => { + if (!canOperateTerminal) return; + return runTerminalResize({ environmentId, input: { threadId, terminalId, cols, rows }, - }), - ); + }); + }); const terminalOutput = terminalSession.output; const terminalError = terminalSession.error; const terminalStatus = terminalSession.status; @@ -467,6 +473,10 @@ export function TerminalViewport({ keybindingsRef.current = keybindings; }, [keybindings]); + useLayoutEffect(() => { + if (terminalRef.current) terminalRef.current.input.readOnly = !canOperateTerminal; + }, [canOperateTerminal]); + useLayoutEffect(() => { visibleRef.current = visible; terminalRef.current?.setVisible(visible); @@ -521,6 +531,7 @@ export function TerminalViewport({ terminal.setTheme(terminalThemeFromApp(mount)); setupTerminal = terminal; terminalRef.current = terminal; + terminal.input.readOnly = !hasTerminalWriteAccess(); // Client settings hydrate asynchronously; a font preference that landed // while the surface was loading found terminalRef null, so its setFont // was dropped. Re-apply whatever is current once the terminal exists. @@ -634,6 +645,7 @@ export function TerminalViewport({ }; const pasteFromClipboard = async (requestId: number) => { + if (!hasTerminalWriteAccess()) return; const activeTerminal = terminalRef.current; if (!activeTerminal) return; try { @@ -667,6 +679,7 @@ export function TerminalViewport({ terminalContextMenuItems({ hasSelection: selectionAction !== null, canAddToChat: canAddSelectionToChat(), + readOnly: !hasTerminalWriteAccess(), }), { x: event.clientX, y: event.clientY }, ); @@ -732,6 +745,7 @@ export function TerminalViewport({ }; const sendTerminalInput = async (data: string, fallbackError: string) => { + if (!hasTerminalWriteAccess()) return; const activeTerminal = terminalRef.current; if (!activeTerminal) return; const result = await writeTerminal(data); @@ -835,6 +849,7 @@ export function TerminalViewport({ } function handleData(data: string): void { + if (!hasTerminalWriteAccess()) return; void (async () => { const result = await writeTerminal(data); if (result._tag === "Success" || isAtomCommandInterrupted(result)) return; @@ -1029,14 +1044,29 @@ interface TerminalActionButtonProps { className: string; onClick: () => void; children: ReactNode; + disabled?: boolean; } -function TerminalActionButton({ label, className, onClick, children }: TerminalActionButtonProps) { +function TerminalActionButton({ + label, + className, + onClick, + children, + disabled, +}: TerminalActionButtonProps) { return ( } + render={ + @@ -1449,6 +1487,7 @@ export default function ThreadTerminalDrawer({
confirmCloseTerminal(resolvedActiveTerminalId)} label={closeTerminalActionLabel} @@ -1594,6 +1636,7 @@ export default function ThreadTerminalDrawer({
confirmCloseTerminal(resolvedActiveTerminalId)} label={closeTerminalActionLabel} @@ -1689,13 +1735,17 @@ export default function ThreadTerminalDrawer({ : "text-muted-foreground hover:bg-accent/60 hover:text-foreground", )} > - confirmCloseTerminal(terminalId)} - tooltip={closeTerminalLabel} - > + {canOperateTerminal ? ( + confirmCloseTerminal(terminalId)} + tooltip={closeTerminalLabel} + > + + + ) : ( - + )} + )} + + + {!onRunScript && ( + openEditDialog(script)} > - - - - + + Edit {script.name} + + )} + ); })} {importMenuItems} diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index 534df3e2fdba..54231ee4598b 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -419,6 +419,9 @@ export function TerminalViewport({ ...(providerInstanceId ? { providerInstanceId } : {}), }, }); + const canResizeTerminal = + canOperateTerminal && terminalSession.version > 0 && terminalSession.status === "running"; + const resizeSessionGeneration = canResizeTerminal ? terminalSession.output.generation : null; const writeTerminal = useEffectEvent((data: string) => runTerminalWrite({ environmentId, @@ -426,7 +429,7 @@ export function TerminalViewport({ }), ); const resizeTerminal = useEffectEvent((cols: number, rows: number) => { - if (!canOperateTerminal) return; + if (!canResizeTerminal) return; return runTerminalResize({ environmentId, input: { threadId, terminalId, cols, rows }, @@ -477,6 +480,13 @@ export function TerminalViewport({ if (terminalRef.current) terminalRef.current.input.readOnly = !canOperateTerminal; }, [canOperateTerminal]); + useEffect(() => { + if (resizeSessionGeneration === null) return; + // The first fit can finish before authorization or the attach snapshot. + // Replay its grid once this writable session exists, including reconnects. + terminalRef.current?.resendSize(); + }, [resizeSessionGeneration]); + useLayoutEffect(() => { visibleRef.current = visible; terminalRef.current?.setVisible(visible); diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index b87dc4406220..d689498c88c8 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -70,7 +70,7 @@ interface ChatHeaderProps { readonly onOpenPullRequest?: ((number: number) => void) | undefined; onNewThreadInProject: () => void; onOpenProjectSettings?: (() => void) | undefined; - onRunProjectScript: (script: ProjectScript) => void; + onRunProjectScript?: ((script: ProjectScript) => void) | undefined; onAddProjectScript: (input: NewProjectScriptInput) => Promise; onUpdateProjectScript: ( scriptId: string, diff --git a/apps/web/src/terminal/ghostty/surface.test.ts b/apps/web/src/terminal/ghostty/surface.test.ts index ee3240b41b08..9c0639d18262 100644 --- a/apps/web/src/terminal/ghostty/surface.test.ts +++ b/apps/web/src/terminal/ghostty/surface.test.ts @@ -210,6 +210,56 @@ describe("GhosttyTerminalSurface visibility", () => { vi.restoreAllMocks(); }); + it("resends an unchanged grid when authorization and attachment become ready", async () => { + const harness = createHarness(); + let canOperate = false; + let attached = false; + const resizePty = vi.fn<(cols: number, rows: number) => void>(); + const surface = await harness.create({ + onResize: (cols, rows) => { + if (canOperate && attached) resizePty(cols, rows); + }, + }); + vi.advanceTimersByTime(150); + expect(resizePty).not.toHaveBeenCalled(); + + canOperate = true; + surface.resendSize(); + vi.advanceTimersByTime(150); + expect(resizePty).not.toHaveBeenCalled(); + + attached = true; + surface.resendSize(); + vi.advanceTimersByTime(150); + expect(resizePty).toHaveBeenCalledExactlyOnceWith(20, 6); + surface.fit(); + vi.advanceTimersByTime(150); + expect(resizePty).toHaveBeenCalledOnce(); + + canOperate = false; + harness.mount.clientWidth = 248; + surface.fit(); + vi.advanceTimersByTime(150); + expect(resizePty).toHaveBeenCalledOnce(); + }); + + it("replays the current grid on reveal when a hidden host becomes ready", async () => { + const harness = createHarness(); + const onResize = vi.fn<(cols: number, rows: number) => void>(); + const surface = await harness.create({ onResize }); + vi.advanceTimersByTime(150); + onResize.mockClear(); + + surface.setVisible(false); + surface.resendSize(); + vi.advanceTimersByTime(150); + expect(onResize).not.toHaveBeenCalled(); + + surface.setVisible(true); + vi.advanceTimersByTime(150); + expect(onResize).toHaveBeenCalledExactlyOnceWith(20, 6); + }); + it("stops hidden snapshots and paint while preserving live VT replies and the next cursor", async () => { const harness = createHarness(); const surface = await harness.create(); diff --git a/apps/web/src/terminal/ghostty/surface.ts b/apps/web/src/terminal/ghostty/surface.ts index 29aaac6f6abd..12d1c79129c9 100644 --- a/apps/web/src/terminal/ghostty/surface.ts +++ b/apps/web/src/terminal/ghostty/surface.ts @@ -830,6 +830,12 @@ export class GhosttyTerminalSurface { this.applyFontMetrics(); }; + /** Replay the measured grid after the host becomes ready to resize its PTY. */ + resendSize(): void { + this.resizeNotified = false; + this.fit(); + } + fit(): boolean { if (this.disposed || !this.visible) return false; const width = this.mount.clientWidth; From d8db2a47fc357d47e1d7768716d3a4440992956f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 4 Sep 2026 14:44:27 -0700 Subject: [PATCH 07/25] fix(mobile): simplify terminal input bounds guard --- .../src/main/java/expo/modules/t3terminal/T3TerminalView.kt | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalView.kt b/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalView.kt index 789050d4a6f2..11a975f3697e 100644 --- a/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalView.kt +++ b/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalView.kt @@ -264,9 +264,8 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex if (readOnly) return if (clearingInput || s == null || count <= 0) return val end = (start + count).coerceAtMost(s.length) - if (start >= end) return - val insertedText = s.subSequence(start, end).toString() - if (insertedText.isNotEmpty()) { + if (start < end) { + val insertedText = s.subSequence(start, end).toString() onInput(mapOf("data" to insertedText)) } } From ba38b5a268e6b89313cb139b7f369101849ea36f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 4 Sep 2026 14:47:00 -0700 Subject: [PATCH 08/25] fix(terminal): recheck grants after close confirmations --- apps/web/src/components/ChatView.tsx | 19 ++++++++++++++----- .../src/components/ProjectScriptsControl.tsx | 10 +++++++--- .../src/components/ThreadTerminalDrawer.tsx | 6 ++++-- 3 files changed, 25 insertions(+), 10 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index cec5132c34f8..03ff6bb32d6c 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -4265,20 +4265,24 @@ export default function ChatView(props: ChatViewProps) { if (!canOperateTerminal) return; const label = activeTerminalLabelsById.get(terminalId) ?? getTerminalLabel(terminalId); void confirmTerminalClose([label]).then((confirmed) => { - if (confirmed) closeTerminal(terminalId); + if (confirmed && readEnvironmentScope(environmentId, AuthTerminalOperateScope)) { + closeTerminal(terminalId); + } }); }, - [canOperateTerminal, activeTerminalLabelsById, closeTerminal], + [canOperateTerminal, activeTerminalLabelsById, closeTerminal, environmentId], ); const requestClosePanelTerminal = useCallback( (terminalId: string) => { if (!canOperateTerminal) return; const label = activeTerminalLabelsById.get(terminalId) ?? getTerminalLabel(terminalId); void confirmTerminalClose([label]).then((confirmed) => { - if (confirmed) closePanelTerminal(terminalId); + if (confirmed && readEnvironmentScope(environmentId, AuthTerminalOperateScope)) { + closePanelTerminal(terminalId); + } }); }, - [canOperateTerminal, activeTerminalLabelsById, closePanelTerminal], + [canOperateTerminal, activeTerminalLabelsById, closePanelTerminal, environmentId], ); const activateRightPanelSurface = useCallback( (surface: RightPanelSurface) => { @@ -4410,7 +4414,12 @@ export default function ChatView(props: ChatViewProps) { (terminalId) => activeTerminalLabelsById.get(terminalId) ?? getTerminalLabel(terminalId), ); void confirmTerminalClose([activeLabel, ...otherLabels]).then((confirmed) => { - if (confirmed) finishClose(); + if ( + confirmed && + readEnvironmentScope(activeThreadRef.environmentId, AuthTerminalOperateScope) + ) { + finishClose(); + } }); }, [ diff --git a/apps/web/src/components/ProjectScriptsControl.tsx b/apps/web/src/components/ProjectScriptsControl.tsx index 1e2dcbf8e3c0..98aba5483c08 100644 --- a/apps/web/src/components/ProjectScriptsControl.tsx +++ b/apps/web/src/components/ProjectScriptsControl.tsx @@ -170,12 +170,12 @@ export default function ProjectScriptsControl({ ) : null} From 228a252f9fd12ea8baaed9dfdceacb39199848e4 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 02:51:33 -0700 Subject: [PATCH 24/25] fix(terminal): stop resizing after permission revocation --- .../ThreadTerminalDrawer.permissions.test.tsx | 153 ++++++++++++++++++ .../src/components/ThreadTerminalDrawer.tsx | 2 +- 2 files changed, 154 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/components/ThreadTerminalDrawer.permissions.test.tsx diff --git a/apps/web/src/components/ThreadTerminalDrawer.permissions.test.tsx b/apps/web/src/components/ThreadTerminalDrawer.permissions.test.tsx new file mode 100644 index 000000000000..895586a1df9a --- /dev/null +++ b/apps/web/src/components/ThreadTerminalDrawer.permissions.test.tsx @@ -0,0 +1,153 @@ +import { + AuthTerminalOperateScope, + EnvironmentId, + ThreadId, + type AuthEnvironmentScope, +} from "@t3tools/contracts"; +import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings"; +import { nextTerminalAttachSeedState } from "@t3tools/client-runtime/state/terminal"; +import { AsyncResult } from "effect/unstable/reactivity"; +import { act, create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; +import type { + GhosttyTerminalSurface, + GhosttyTerminalSurfaceOptions, +} from "~/terminal/ghostty/surface"; + +const state = vi.hoisted(() => ({ + allowed: true, + listeners: new Set<() => void>(), + resize: vi.fn(), + otherCommand: vi.fn(), + createSurface: + vi.fn< + ( + mount: HTMLElement, + options: GhosttyTerminalSurfaceOptions, + ) => Promise + >(), +})); + +vi.mock("@effect/atom-react", () => ({ useAtomValue: () => null })); +vi.mock("../hooks/useSettings", () => ({ + getClientSettings: () => DEFAULT_CLIENT_SETTINGS, + useClientSettings: (select: (settings: typeof DEFAULT_CLIENT_SETTINGS) => unknown) => + select(DEFAULT_CLIENT_SETTINGS), +})); +vi.mock("../editorPreferences", () => ({ useOpenInPreferredEditor: () => state.otherCommand })); +vi.mock("../localApi", () => ({ readLocalApi: () => null })); +vi.mock("../state/server", () => ({ + serverEnvironment: { configValueAtom: () => "config" }, +})); +vi.mock("../state/preview", () => ({ previewEnvironment: { open: "open" } })); +vi.mock("../state/terminal", () => ({ terminalEnvironment: { resize: "resize", write: "write" } })); +vi.mock("../state/use-atom-command", () => ({ + useAtomCommand: (command: string) => (command === "resize" ? state.resize : state.otherCommand), +})); +vi.mock("../state/terminalSessions", () => ({ + useAttachedTerminalSession: () => session, +})); +vi.mock("~/terminal/ghostty/surface", () => ({ + GhosttyTerminalSurface: { create: state.createSurface }, +})); +vi.mock("../state/session", async () => { + const { useSyncExternalStore } = await import("react"); + const readEnvironmentScope = (id: EnvironmentId | null, scope: AuthEnvironmentScope) => + id !== null && + (scope !== AuthTerminalOperateScope || id !== threadRef.environmentId || state.allowed); + return { + readEnvironmentScope, + useEnvironmentScope: (id: EnvironmentId | null, scope: AuthEnvironmentScope) => + useSyncExternalStore( + (listener) => { + state.listeners.add(listener); + return () => state.listeners.delete(listener); + }, + () => readEnvironmentScope(id, scope), + ), + }; +}); + +import { TerminalViewport } from "./ThreadTerminalDrawer"; + +const threadRef = { + environmentId: EnvironmentId.make("secondary-terminal"), + threadId: ThreadId.make("thread"), +}; +const session = { ...nextTerminalAttachSeedState(), status: "running" as const, version: 1 }; +let renderer: ReactTestRenderer | undefined; + +beforeEach(() => { + state.allowed = true; + state.listeners.clear(); + state.resize.mockReset().mockResolvedValue(AsyncResult.success(undefined)); + state.otherCommand.mockReset().mockResolvedValue(AsyncResult.success(undefined)); + // A surface can report its initial grid while asynchronous WASM setup is + // pending. Keep that unrelated setup pending while exercising its callback. + state.createSurface.mockReset().mockReturnValue(new Promise(() => undefined)); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal("document", { + body: {}, + documentElement: { classList: { contains: () => false } }, + querySelector: () => null, + createElement: () => ({ getContext: () => null }), + }); + vi.stubGlobal("getComputedStyle", () => ({ + colorScheme: "light", + backgroundColor: "", + color: "", + getPropertyValue: () => "", + })); +}); + +afterEach(async () => { + await act(async () => renderer?.unmount()); + renderer = undefined; + vi.unstubAllGlobals(); +}); + +it("rechecks the target terminal grant when a retained surface callback reports a resize", async () => { + await act(async () => { + renderer = create( + , + { createNodeMock: () => ({ closest: () => null, contains: () => false }) }, + ); + }); + expect(state.createSurface).toHaveBeenCalledOnce(); + const onResize = state.createSurface.mock.calls[0]![1].onResize; + if (!onResize) throw new Error("The terminal did not register its resize callback."); + + onResize(80, 24); + expect(state.resize).toHaveBeenCalledExactlyOnceWith({ + environmentId: threadRef.environmentId, + input: { threadId: threadRef.threadId, terminalId: "terminal-1", cols: 80, rows: 24 }, + }); + + // The connection updates before React commits its external-store update. + // Other environments retain access; only this terminal's target is revoked. + state.allowed = false; + onResize(120, 40); + expect(state.resize).toHaveBeenCalledTimes(1); + + state.allowed = true; + onResize(100, 30); + expect(state.resize).toHaveBeenCalledTimes(2); + expect(state.resize).toHaveBeenLastCalledWith({ + environmentId: threadRef.environmentId, + input: { threadId: threadRef.threadId, terminalId: "terminal-1", cols: 100, rows: 30 }, + }); +}); diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index 177f88c39cc6..b8b9d94a0235 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -453,7 +453,7 @@ export function TerminalViewport({ }), ); const resizeTerminal = useEffectEvent((cols: number, rows: number) => { - if (!canResizeTerminal) return; + if (!canResizeTerminal || !hasTerminalWriteAccess()) return; return runTerminalResize({ environmentId, input: { threadId, terminalId, cols, rows }, From afd41820c6fe3e7fed4ecd82590f777bc2815f52 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Sat, 5 Sep 2026 21:30:45 -0700 Subject: [PATCH 25/25] fix(web): leave terminal shortcuts alone without operate access New, split, and close shortcuts consumed the key before checking the grant, so a read-only observer pressing them got nothing at all. They now keep their native meaning, matching the toggle shortcut. The pairing form pairs terminal:read with terminal:operate: a client that can open shells but not list them allocates a fresh session per script run. Co-Authored-By: Claude Fable 5 --- apps/web/src/components/ChatView.tsx | 16 ++++++--- .../ConnectionsSettings.logic.test.ts | 36 +++++++++++++++++++ .../settings/ConnectionsSettings.logic.ts | 32 ++++++++++++++++- .../settings/ConnectionsSettings.tsx | 5 ++- 4 files changed, 81 insertions(+), 8 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 386eed198826..94d5ba421817 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -6102,9 +6102,11 @@ export default function ChatView(props: ChatViewProps) { } if (command === "terminal.split") { + // Without operate access the key keeps its native meaning, as it + // does when nothing is open. + if (!canOperateTerminal) return; event.preventDefault(); event.stopPropagation(); - if (!canOperateTerminal) return; if (terminalFocusOwner === "right-panel") { splitPanelTerminal(); return; @@ -6117,9 +6119,11 @@ export default function ChatView(props: ChatViewProps) { } if (command === "terminal.splitVertical") { + // Without operate access the key keeps its native meaning, as it + // does when nothing is open. + if (!canOperateTerminal) return; event.preventDefault(); event.stopPropagation(); - if (!canOperateTerminal) return; if (terminalFocusOwner === "right-panel") { splitPanelTerminal("vertical"); return; @@ -6132,9 +6136,11 @@ export default function ChatView(props: ChatViewProps) { } if (command === "terminal.close") { + // Without operate access the key keeps its native meaning, as it + // does when nothing is open. + if (!canOperateTerminal) return; event.preventDefault(); event.stopPropagation(); - if (!canOperateTerminal) return; if (terminalFocusOwner === "right-panel" && activeRightPanelSurface?.kind === "terminal") { requestClosePanelTerminal(activeRightPanelSurface.activeTerminalId); return; @@ -6145,9 +6151,11 @@ export default function ChatView(props: ChatViewProps) { } if (command === "terminal.new") { + // Without operate access the key keeps its native meaning, as it + // does when nothing is open. + if (!canOperateTerminal) return; event.preventDefault(); event.stopPropagation(); - if (!canOperateTerminal) return; if (terminalFocusOwner === "right-panel") { addTerminalSurface(); return; diff --git a/apps/web/src/components/settings/ConnectionsSettings.logic.test.ts b/apps/web/src/components/settings/ConnectionsSettings.logic.test.ts index 79a29fa69855..ee50ac2c4277 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.logic.test.ts +++ b/apps/web/src/components/settings/ConnectionsSettings.logic.test.ts @@ -6,8 +6,44 @@ import { isQrShareableEndpoint, isWslSettingsRowVisible, selectQrEndpointOption, + togglePairingScopeSelection, } from "./ConnectionsSettings.logic"; +describe("togglePairingScopeSelection", () => { + it.each([ + { + label: "adds terminal:read when terminal:operate is selected", + current: ["orchestration:read"], + scope: "terminal:operate", + checked: true, + expected: ["orchestration:read", "terminal:operate", "terminal:read"], + }, + { + label: "drops terminal:operate when terminal:read is cleared", + current: ["terminal:read", "terminal:operate", "relay:read"], + scope: "terminal:read", + checked: false, + expected: ["relay:read"], + }, + { + label: "keeps terminal:read when terminal:operate is cleared", + current: ["terminal:read", "terminal:operate"], + scope: "terminal:operate", + checked: false, + expected: ["terminal:read"], + }, + { + label: "toggles unrelated scopes on their own", + current: ["terminal:read"], + scope: "filesystem:read", + checked: true, + expected: ["terminal:read", "filesystem:read"], + }, + ] as const)("$label", ({ current, scope, checked, expected }) => { + expect(togglePairingScopeSelection(current, scope, checked)).toEqual(expected); + }); +}); + const baseWslState: DesktopWslState = { enabled: false, distro: null, diff --git a/apps/web/src/components/settings/ConnectionsSettings.logic.ts b/apps/web/src/components/settings/ConnectionsSettings.logic.ts index 4dcabbb4b40b..2e26eba27d4b 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.logic.ts +++ b/apps/web/src/components/settings/ConnectionsSettings.logic.ts @@ -1,4 +1,34 @@ -import type { AdvertisedEndpoint, DesktopBridge, DesktopWslState } from "@t3tools/contracts"; +import { + type AdvertisedEndpoint, + type AuthGrantScope, + AuthTerminalOperateScope, + AuthTerminalReadScope, + type DesktopBridge, + type DesktopWslState, +} from "@t3tools/contracts"; + +/** + * Operating terminals without being able to list them leaves a client + * allocating a fresh shell for every script run it cannot see, so the pairing + * form keeps `terminal:read` alongside `terminal:operate`. + */ +export function togglePairingScopeSelection( + current: ReadonlyArray, + scope: AuthGrantScope, + checked: boolean, +): ReadonlyArray { + const without = (scopes: ReadonlyArray) => + scopes.filter((currentScope) => currentScope !== scope); + if (!checked) { + return scope === AuthTerminalReadScope + ? without(current).filter((currentScope) => currentScope !== AuthTerminalOperateScope) + : without(current); + } + const next = [...without(current), scope]; + return scope === AuthTerminalOperateScope && !next.includes(AuthTerminalReadScope) + ? [...next, AuthTerminalReadScope] + : next; +} /** A missing access list says nothing about whether other clients exist. */ export function canRevokeOtherClients( diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index 9a8047d9780d..50316ecd89b8 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -60,6 +60,7 @@ import { isQrShareableEndpoint, isWslSettingsRowVisible, selectQrEndpointOption, + togglePairingScopeSelection, } from "./ConnectionsSettings.logic"; import { SettingsPageContainer, @@ -1123,9 +1124,7 @@ const AuthorizedClientsHeaderAction = memo(function AuthorizedClientsHeaderActio }, [delegatableScopes, onPairingLinkCreated, pairingLabel, primaryEnvironmentId, selectedScopes]); const togglePairingScope = useCallback((scope: AuthGrantScope, checked: boolean) => { - setPairingScopes((current) => - checked ? [...current, scope] : current.filter((currentScope) => currentScope !== scope), - ); + setPairingScopes((current) => togglePairingScopeSelection(current, scope, checked)); }, []); return (