Skip to content
Open
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -48,5 +48,8 @@ devenv.local.yaml
# direnv
.direnv

# codegraph index
.codegraph/

# pre-commit
.pre-commit-config.yaml
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,34 @@ This eliminates the need for polling—perfect for long-running processes like b
| `PTY_MAX_BUFFER_LINES` | `50000` | Maximum lines to keep in output buffer per session |
| `PTY_WEB_HOSTNAME` | `::1` | Hostname for the web server to bind to (IPv6 loopback by default) |
| `PTY_WEB_PORT` | `0` (random) | Port for the web server (0 = random port) |
| `PTY_WEB_USERNAME` | OS user | Username accepted by the HTTP Basic Auth gate. Defaults to the OS account that launched the plugin |
| `PTY_WEB_PASSWORD` | unset | When set, every API request (including the WebSocket upgrade and the SPA shell) must carry `Authorization: Basic …` credentials. When unset, the API is open to anyone who can reach the bind address |

### Web UI Authentication

The web server is fully open by default. When you bind it to anything other than a
loopback address (e.g. `PTY_WEB_HOSTNAME=0.0.0.0` so a phone on the same network can
use it), set `PTY_WEB_PASSWORD` so the browser surfaces its native HTTP Basic Auth
prompt and unauthorized clients can no longer read your sessions:

```bash
PTY_WEB_PASSWORD="choose-something-strong" opencode
# Optionally pin the username (defaults to the OS user running opencode):
PTY_WEB_USERNAME="me" PTY_WEB_PASSWORD="choose-something-strong" opencode
```

Behavior:

- `/health` is **always** unauthenticated — the frontend uses it to figure out
whether auth is on.
- When `PTY_WEB_PASSWORD` is set, every other route (`/api/*`, `/ws`, the SPA
shell, the 302 redirect) returns `401` with `WWW-Authenticate: Basic …` until
the correct `Authorization: Basic base64(user:pass)` header is presented.
- When `PTY_WEB_PASSWORD` is **not** set and the page is loaded from a non-loopback
host, the UI shows a banner reminding you to enable Basic Auth, and the
`Kill Session` / `Ctrl+C` kill path is server-blocked (`403`) for non-loopback
origins so a passer-by can't take down your sessions. Loopback access always
keeps the previous, fully-open behavior.

### Permissions

Expand Down
28 changes: 26 additions & 2 deletions src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,28 +7,52 @@ import { ptyRead } from './plugin/pty/tools/read.ts'
import { ptyList } from './plugin/pty/tools/list.ts'
import { ptyKill } from './plugin/pty/tools/kill.ts'
import { PTYServer } from './web/server/server.ts'
import { WebAuth } from './web/server/auth.ts'
import open from 'open'

const ptyOpenClientCommand = 'pty-open-background-spy'
const ptyShowServerUrlCommand = 'pty-show-server-url'

function isLoopbackHostname(hostname: string): boolean {
const normalized = hostname.toLowerCase().replace(/^\[|\]$/g, '')
if (normalized === 'localhost' || normalized === '127.0.0.1' || normalized === '::1') {
return true
}
if (normalized.startsWith('::ffff:')) {
const tail = normalized.slice('::ffff:'.length)
return tail === '127.0.0.1'
}
return false
}

export const PTYPlugin = async ({ client, directory }: PluginContext): Promise<PluginResult> => {
initPermissions(client, directory)
initManager(client)
let ptyServer: PTYServer | undefined
const webAuth = new WebAuth({
username: process.env.PTY_WEB_USERNAME,
password: process.env.PTY_WEB_PASSWORD,
})

return {
'command.execute.before': async (input) => {
if (input.command !== ptyOpenClientCommand && input.command !== ptyShowServerUrlCommand) {
return
}
if (ptyServer === undefined) {
ptyServer = await PTYServer.createServer()
ptyServer = await PTYServer.createServer({ auth: webAuth })
}
if (input.command === ptyOpenClientCommand) {
open(ptyServer.server.url.origin)
} else if (input.command === ptyShowServerUrlCommand) {
const message = `PTY Sessions Web Interface URL: ${ptyServer.server.url.origin}`
const origin = ptyServer.server.url.origin
const isLoopback = isLoopbackHostname(ptyServer.server.url.hostname)
const authNote = webAuth.isEnabled()
? '(HTTP Basic Auth required)'
: isLoopback
? '(no auth — loopback only)'
: '(no auth — set PTY_WEB_PASSWORD to secure)'
const message = `PTY Sessions Web Interface URL: ${origin} ${authNote}`
await client.session.prompt({
path: { id: input.sessionID },
body: {
Expand Down
110 changes: 109 additions & 1 deletion src/web/client/components/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,71 @@ import { Sidebar } from './sidebar.tsx'
import { RawTerminal } from './terminal-renderer.tsx'
import { api } from '../../shared/api-client.ts'

const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]', '::1'])

const AUTH_WARNING_DISMISSED_KEY = 'pty-auth-warning-dismissed'

function isLoopbackHostname(hostname: string): boolean {
const normalized = hostname.toLowerCase().replace(/^\[|\]$/g, '')
if (LOOPBACK_HOSTS.has(normalized)) return true
if (normalized.startsWith('::ffff:')) {
return LOOPBACK_HOSTS.has(normalized.slice('::ffff:'.length))
}
return false
}

function useAuthStatus() {
const [authEnabled, setAuthEnabled] = useState(false)
const [authUsername, setAuthUsername] = useState('')

useEffect(() => {
let cancelled = false
void (async () => {
try {
const response = await fetch('/health', { credentials: 'same-origin' })
if (!response.ok) return
const data = (await response.json()) as {
authEnabled?: boolean
authUsername?: string
}
if (cancelled) return
setAuthEnabled(data.authEnabled === true)
setAuthUsername(typeof data.authUsername === 'string' ? data.authUsername : '')
} catch {
// Network failure is fine; leave defaults so the UI stays usable.
}
})()
return () => {
cancelled = true
}
}, [])

return { authEnabled, authUsername }
}

function useNonLoopback() {
return useState(() => !isLoopbackHostname(window.location.hostname))[0]
}

function useDismissedAuthWarning() {
const [dismissed, setDismissed] = useState(() => {
try {
return localStorage.getItem(AUTH_WARNING_DISMISSED_KEY) === '1'
} catch {
return false
}
})
const dismiss = useCallback(() => {
setDismissed(true)
try {
localStorage.setItem(AUTH_WARNING_DISMISSED_KEY, '1')
} catch {
// Ignore quota / privacy-mode failures; the in-memory flag still hides it.
}
}, [])
return { dismissed, dismiss }
}

export function App() {
const [sessions, setSessions] = useState<PTYSessionInfo[]>([])
const [activeSession, setActiveSession] = useState<PTYSessionInfo | null>(null)
Expand All @@ -16,6 +81,12 @@ export function App() {
const [connected, setConnected] = useState(false)
const [wsMessageCount, setWsMessageCount] = useState(0)
const [sessionUpdateCount, setSessionUpdateCount] = useState(0)
const { authEnabled } = useAuthStatus()
const nonLoopback = useNonLoopback()
const { dismissed: authWarningDismissed, dismiss: dismissAuthWarning } = useDismissedAuthWarning()
const showAuthWarning = nonLoopback && !authEnabled && !authWarningDismissed
const killBlocked = nonLoopback && !authEnabled
const inputBlocked = killBlocked

const {
connected: wsConnected,
Expand Down Expand Up @@ -89,13 +160,33 @@ export function App() {
subscribeWithRetry,
sendInput,
wsConnected,
killBlocked,
inputBlocked,
onRawOutputUpdate: useCallback((rawOutput: string) => {
setRawOutput(rawOutput)
}, []),
})

return (
<div className="container" data-active-session={activeSession?.id}>
{showAuthWarning && (
<output className="auth-warning" data-testid="auth-warning" aria-live="polite">
<span className="auth-warning-icon" aria-hidden="true">
</span>
<span className="auth-warning-text">
Auth disabled. Set <code>PTY_WEB_PASSWORD</code> to secure kill/cleanup.
</span>
<button
type="button"
className="auth-warning-dismiss"
aria-label="Dismiss"
onClick={dismissAuthWarning}
>
×
</button>
</output>
)}
<Sidebar
sessions={sessions}
activeSession={activeSession}
Expand All @@ -107,7 +198,17 @@ export function App() {
<>
<div className="output-header">
<div className="output-title">{activeSession.description ?? activeSession.title}</div>
<button type="button" className="kill-btn" onClick={handleKillSession}>
<button
type="button"
className="kill-btn"
onClick={handleKillSession}
disabled={killBlocked}
title={
killBlocked
? 'Killing sessions is disabled because HTTP Basic Auth is not configured and the UI is being accessed from a non-loopback host.'
: undefined
}
>
Kill Session
</button>
</div>
Expand All @@ -118,8 +219,15 @@ export function App() {
onSendInput={handleSendInput}
onInterrupt={handleKillSession}
disabled={!activeSession || activeSession.status !== 'running'}
readOnly={inputBlocked}
/>
</div>
{inputBlocked && (
<output className="terminal-readonly-banner">
Read-only — input is disabled because HTTP Basic Auth is not configured and the UI
is being accessed from a non-loopback host. Selection, copy, and paste still work.
</output>
)}
<div className="debug-info" data-testid="debug-info">
Debug: {rawOutput.length} chars, active: {activeSession?.id || 'none'}, WS raw_data:{' '}
{wsMessageCount}, session_updates: {sessionUpdateCount}
Expand Down
58 changes: 54 additions & 4 deletions src/web/client/components/terminal-renderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,14 @@ interface RawTerminalProps {
onSendInput?: (data: string) => void
onInterrupt?: () => void
disabled?: boolean
/**
* When true the user can still see and select/copy the rendered output,
* but every keystroke is intercepted by `attachCustomKeyEventHandler`
* before xterm forwards it to the PTY. This is what the non-loopback
* read-only mode uses — the backend would reject the write anyway, but
* silently dropping keys here gives immediate UI feedback.
*/
readOnly?: boolean
}

export class RawTerminal extends React.Component<RawTerminalProps> {
Expand Down Expand Up @@ -49,6 +57,12 @@ export class RawTerminal extends React.Component<RawTerminalProps> {
this.xtermInstance.clear()
this.xtermInstance.write(currentData)
}

// Re-apply read-only mode when the prop changes (e.g. user enabled auth
// and the UI flipped from read-only to writable after a /health probe).
if (prevProps.readOnly !== this.props.readOnly) {
this.applyReadOnly(this.props.readOnly === true)
}
}

override componentWillUnmount() {
Expand Down Expand Up @@ -84,14 +98,46 @@ export class RawTerminal extends React.Component<RawTerminalProps> {
window.xtermTerminal = term
window.xtermSerializeAddon = this.serializeAddon

// Set up input handling
this.applyReadOnly(this.props.readOnly === true)
this.setupInputHandling(term)
}

/**
* Install (or uninstall) a key-event filter that blocks every "input
* intent" key while still letting modifier-only combinations and named
* navigation keys fall through to the browser so the user can keep
* selecting / copying / pasting.
*
* Returning `false` from `attachCustomKeyEventHandler` tells xterm NOT to
* forward the key as PTY data; the browser then handles it natively
* (selection, copy, paste, select-all, scroll, etc. all keep working).
*/
private applyReadOnly(readOnly: boolean) {
const term = this.xtermInstance
if (!term) return
if (!readOnly) {
term.attachCustomKeyEventHandler(() => true)
return
}
term.attachCustomKeyEventHandler((event) => {
// Any modifier combo (Ctrl / Cmd / Alt) belongs to the browser — copy,
// paste, select-all, find, reload. Always let the browser handle them.
if (event.ctrlKey || event.metaKey || event.altKey) return false
// Named navigation / editing keys the browser uses for selection —
// arrow keys, Home / End, PageUp / PageDown, Tab, Esc, F1-F12.
// Returning false here means xterm leaves them alone, so the browser's
// built-in text-selection shortcuts work as the user expects.
if (event.key.length !== 1) return false
// Single-character key with no modifiers = the user is trying to type
// something into the PTY. Swallow it.
return false
})
}

private setupInputHandling(term: Terminal) {
const { onSendInput, onInterrupt, disabled } = this.props
const { onSendInput, onInterrupt, disabled, readOnly } = this.props

if (disabled) return
if (disabled || readOnly) return

const handleData = (data: string) => {
if (data === '\u0003') {
Expand All @@ -108,7 +154,11 @@ export class RawTerminal extends React.Component<RawTerminalProps> {

override render() {
return (
<div ref={this.terminalRef} className="xterm" style={{ width: '100%', height: '100%' }} />
<div
ref={this.terminalRef}
className={`xterm ${this.props.readOnly ? 'xterm-readonly' : ''}`}
style={{ width: '100%', height: '100%' }}
/>
)
}
}
Loading