diff --git a/README.md b/README.md index f23ac251..014cafba 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,8 @@ This plugin gives the agent full control over multiple terminal sessions, like t ## Setup +### OpenCode V1 + Add the plugin to your [OpenCode config](https://opencode.ai/docs/config/): ```json @@ -38,7 +40,33 @@ Add the plugin to your [OpenCode config](https://opencode.ai/docs/config/): } ``` -That's it. OpenCode will automatically install the plugin on next run. +### OpenCode V2 + +OpenCode V2 uses the new plugin API. You can load `opencode-pty/v2` and optionally configure options (such as a fixed web UI port): + +```json +{ + "$schema": "https://opencode.ai/config.json", + "plugin": [ + { + "package": "opencode-pty/v2", + "options": { + "port": 4200, + "hostname": "127.0.0.1", + "autostart": false + } + } + ] +} +``` + +| Option | Type | Default | Description | +| --- | --- | --- | --- | +| `port` | `number` | `0` (ephemeral) | Fixed port for the PTY Web UI observer server | +| `hostname` | `string` | `"::1"` | Hostname to bind the PTY Web UI server to | +| `autostart` | `boolean` | `false` | Automatically start the Web UI server on startup | + +OpenCode will automatically install the plugin on next run. ## Updating diff --git a/package.json b/package.json index 42df1258..2e09147b 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,10 @@ "license": "MIT", "type": "module", "exports": { + "./v2": { + "types": "./dist/src/v2/index.d.ts", + "default": "./dist/src/v2/index.js" + }, "./server": { "types": "./dist/index.d.ts", "default": "./dist/index.js" diff --git a/src/adapters/index.ts b/src/adapters/index.ts new file mode 100644 index 00000000..d2fca41a --- /dev/null +++ b/src/adapters/index.ts @@ -0,0 +1,19 @@ +import { manager } from '../plugin/pty/manager.ts' +import { setPermissionAuthorizer } from '../plugin/pty/permissions.ts' +import type { HostAdapter } from './types.ts' + +export * from './types.ts' +export * from './v1/index.ts' + +/** + * Installs a host adapter by connecting its notifier and permission authorizer + * to the core PTY manager and permission dispatcher. + */ +export function installHostAdapter(adapter: HostAdapter): void { + if (adapter.notifier) { + manager.setNotifier(adapter.notifier) + } + if (adapter.permissions) { + setPermissionAuthorizer(adapter.permissions) + } +} diff --git a/src/adapters/types.ts b/src/adapters/types.ts new file mode 100644 index 00000000..393ef48d --- /dev/null +++ b/src/adapters/types.ts @@ -0,0 +1,28 @@ +import type { PTYSession } from '../plugin/pty/types.ts' + +/** + * Host-agnostic interface for handling session exit notifications. + * Allows decoupling PTY lifecycle from any specific OpenCode client SDK. + */ +export interface SessionNotifier { + sendExitNotification(session: PTYSession, exitCode: number): Promise | void +} + +/** + * Host-agnostic authorizer for validating command and workdir execution permissions. + */ +export interface PermissionAuthorizer { + checkCommand(command: string, args: string[]): Promise + checkWorkdir(workdir: string): Promise +} + +/** + * Common host adapter contract bridging a host environment (e.g. OpenCode V1, V2, Standalone) + * with the core PTY manager and execution environment. + */ +export interface HostAdapter { + readonly id: string + readonly notifier?: SessionNotifier + readonly permissions?: PermissionAuthorizer + onSessionDeleted?(sessionId: string): void +} diff --git a/src/adapters/v1/index.ts b/src/adapters/v1/index.ts new file mode 100644 index 00000000..9bbebc2a --- /dev/null +++ b/src/adapters/v1/index.ts @@ -0,0 +1,23 @@ +import type { OpencodeClient } from '@opencode-ai/sdk' +import type { PluginContext } from '../../plugin/types.ts' +import { manager } from '../../plugin/pty/manager.ts' +import type { HostAdapter } from '../types.ts' +import { V1NotificationAdapter } from './notifications.ts' +import { V1PermissionAuthorizer } from './permissions.ts' + +export { V1NotificationAdapter } from './notifications.ts' +export { V1PermissionAuthorizer } from './permissions.ts' + +export function createV1Adapter(context: PluginContext): HostAdapter { + const notifier = new V1NotificationAdapter(context.client as unknown as OpencodeClient) + const permissions = new V1PermissionAuthorizer(context.client, context.directory) + + return { + id: 'opencode-v1', + notifier, + permissions, + onSessionDeleted: (sessionId: string) => { + manager.cleanupBySession(sessionId) + }, + } +} diff --git a/src/adapters/v1/notifications.ts b/src/adapters/v1/notifications.ts new file mode 100644 index 00000000..07c75b08 --- /dev/null +++ b/src/adapters/v1/notifications.ts @@ -0,0 +1,23 @@ +import type { OpencodeClient } from '@opencode-ai/sdk' +import { NotificationManager } from '../../plugin/pty/notification-manager.ts' +import type { PTYSession } from '../../plugin/pty/types.ts' +import type { SessionNotifier } from '../types.ts' + +export class V1NotificationAdapter implements SessionNotifier { + private manager: NotificationManager + + constructor(client?: OpencodeClient) { + this.manager = new NotificationManager() + if (client) { + this.manager.init(client) + } + } + + init(client: OpencodeClient): void { + this.manager.init(client) + } + + async sendExitNotification(session: PTYSession, exitCode: number): Promise { + await this.manager.sendExitNotification(session, exitCode) + } +} diff --git a/src/adapters/v1/permissions.ts b/src/adapters/v1/permissions.ts new file mode 100644 index 00000000..0b6584c8 --- /dev/null +++ b/src/adapters/v1/permissions.ts @@ -0,0 +1,117 @@ +import type { PluginClient } from '../../plugin/types.ts' +import type { PermissionAuthorizer } from '../types.ts' +import { allStructured } from '../../plugin/pty/wildcard.ts' + +type PermissionAction = 'allow' | 'ask' | 'deny' +type BashPermissions = PermissionAction | Record + +export interface PermissionConfig { + bash?: BashPermissions + external_directory?: PermissionAction +} + +export class V1PermissionAuthorizer implements PermissionAuthorizer { + constructor( + private client: PluginClient | null, + private directory: string | null + ) {} + + private async getPermissionConfig(): Promise { + if (!this.client) { + return {} + } + try { + const response = await this.client.config.get() + if (response.error || !response.data) { + return {} + } + return (response.data as { permission?: PermissionConfig }).permission ?? {} + } catch { + return {} + } + } + + private async showToast( + message: string, + variant: 'info' | 'success' | 'error' = 'info' + ): Promise { + if (!this.client) return + try { + await this.client.tui.showToast({ body: { message, variant } }) + } catch { + // Ignore toast errors + } + } + + private async denyWithToast(msg: string, details?: string): Promise { + await this.showToast(msg, 'error') + throw new Error(details ? `${msg} ${details}` : msg) + } + + private async handleAskPermission(commandLine: string): Promise { + await this.denyWithToast( + `PTY: Command "${commandLine}" requires permission (treated as denied)`, + `PTY spawn denied: Command "${commandLine}" requires user permission which is not supported by this plugin. Configure explicit "allow" or "deny" in your opencode.json permission.bash settings.` + ) + throw new Error('Unreachable') + } + + async checkCommand(command: string, args: string[]): Promise { + const config = await this.getPermissionConfig() + const bashPerms = config.bash + + if (!bashPerms) { + return + } + + if (typeof bashPerms === 'string') { + if (bashPerms === 'deny') { + await this.denyWithToast( + 'PTY spawn denied: All bash commands are disabled by user configuration.' + ) + } + if (bashPerms === 'ask') { + await this.handleAskPermission(command) + } + return + } + + const action = allStructured({ head: command, tail: args }, bashPerms) + + if (action === 'deny') { + await this.denyWithToast( + `PTY spawn denied: Command "${command} ${args.join(' ')}" is explicitly denied by user configuration.` + ) + } + + if (action === 'ask') { + await this.handleAskPermission(`${command} ${args.join(' ')}`) + } + } + + async checkWorkdir(workdir: string): Promise { + if (!this.directory) { + return + } + + const normalizedWorkdir = workdir.replace(/\/$/, '') + const normalizedProject = this.directory.replace(/\/$/, '') + + if (normalizedWorkdir.startsWith(normalizedProject)) { + return + } + + const config = await this.getPermissionConfig() + const extDirPerm = config.external_directory + + if (extDirPerm === 'deny') { + await this.denyWithToast( + `PTY spawn denied: Working directory "${workdir}" is outside project directory "${this.directory}". External directory access is denied by user configuration.` + ) + } + + if (extDirPerm === 'ask') { + // TODO: Implement user prompt for external directory access + } + } +} diff --git a/src/adapters/v2/index.ts b/src/adapters/v2/index.ts new file mode 100644 index 00000000..9075fdb8 --- /dev/null +++ b/src/adapters/v2/index.ts @@ -0,0 +1,26 @@ +import { manager } from '../../plugin/pty/manager.ts' +import type { HostAdapter, PermissionAuthorizer, SessionNotifier } from '../types.ts' + +export interface V2AdapterOptions { + notifier?: SessionNotifier + permissions?: PermissionAuthorizer +} + +export class V2HostAdapter implements HostAdapter { + readonly id = 'opencode-v2' + readonly notifier?: SessionNotifier + readonly permissions?: PermissionAuthorizer + + constructor(options: V2AdapterOptions = {}) { + this.notifier = options.notifier + this.permissions = options.permissions + } + + onSessionDeleted(sessionId: string): void { + manager.cleanupBySession(sessionId) + } +} + +export function createV2Adapter(options?: V2AdapterOptions): HostAdapter { + return new V2HostAdapter(options) +} diff --git a/src/plugin.ts b/src/plugin.ts index 9f9f35c5..4b6c2def 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -1,6 +1,5 @@ import type { PluginContext, PluginResult } from './plugin/types.ts' -import { initManager, manager } from './plugin/pty/manager.ts' -import { initPermissions } from './plugin/pty/permissions.ts' +import { createV1Adapter, installHostAdapter } from './adapters/index.ts' import { ptySpawn } from './plugin/pty/tools/spawn.ts' import { ptyWrite } from './plugin/pty/tools/write.ts' import { ptyRead } from './plugin/pty/tools/read.ts' @@ -12,9 +11,10 @@ import open from 'open' const ptyOpenClientCommand = 'pty-open-background-spy' const ptyShowServerUrlCommand = 'pty-show-server-url' -export const PTYPlugin = async ({ client, directory }: PluginContext): Promise => { - initPermissions(client, directory) - initManager(client) +export const PTYPlugin = async (context: PluginContext): Promise => { + const { client } = context + const adapter = createV1Adapter(context) + installHostAdapter(adapter) let ptyServer: PTYServer | undefined return { @@ -66,7 +66,7 @@ export const PTYPlugin = async ({ client, directory }: PluginContext): Promise

{ if (event.type === 'session.deleted') { - manager.cleanupBySession(event.properties.info.id) + adapter.onSessionDeleted?.(event.properties.info.id) } }, } diff --git a/src/plugin/pty/manager.ts b/src/plugin/pty/manager.ts index 2417622f..e0550cf7 100644 --- a/src/plugin/pty/manager.ts +++ b/src/plugin/pty/manager.ts @@ -1,3 +1,4 @@ +import type { SessionNotifier } from '../../adapters/types.ts' import type { OpencodeClient } from '@opencode-ai/sdk' import { Terminal } from 'bun-pty' import { NotificationManager } from './notification-manager.ts' @@ -71,9 +72,19 @@ class PTYManager { private lifecycleManager = new SessionLifecycleManager() private outputManager = new OutputManager() private notificationManager = new NotificationManager() + private notifier: SessionNotifier | null = null + + setNotifier(notifier: SessionNotifier | null): void { + this.notifier = notifier + } + + getNotifier(): SessionNotifier | null { + return this.notifier ?? this.notificationManager + } init(client: OpencodeClient): void { this.notificationManager.init(client) + this.notifier = this.notificationManager } clearAllSessions(): void { @@ -89,7 +100,8 @@ class PTYManager { async (session, exitCode) => { notifySessionUpdate(this.lifecycleManager.toInfo(session)) if (session?.notifyOnExit) { - await this.notificationManager.sendExitNotification(session, exitCode || 0) + const activeNotifier = this.notifier ?? this.notificationManager + await activeNotifier.sendExitNotification(session, exitCode || 0) } } ) @@ -163,3 +175,7 @@ export const manager = new PTYManager() export function initManager(opcClient: OpencodeClient): void { manager.init(opcClient) } + +export function setManagerNotifier(notifier: SessionNotifier | null): void { + manager.setNotifier(notifier) +} diff --git a/src/plugin/pty/notification-manager.ts b/src/plugin/pty/notification-manager.ts index 52c2ba7c..78b7db7f 100644 --- a/src/plugin/pty/notification-manager.ts +++ b/src/plugin/pty/notification-manager.ts @@ -1,8 +1,9 @@ +import type { SessionNotifier } from '../../adapters/types.ts' import type { PTYSession } from './types.ts' import type { OpencodeClient } from '@opencode-ai/sdk' import { NOTIFICATION_LINE_TRUNCATE, NOTIFICATION_TITLE_TRUNCATE } from '../constants.ts' -export class NotificationManager { +export class NotificationManager implements SessionNotifier { private client: OpencodeClient | null = null init(client: OpencodeClient): void { diff --git a/src/plugin/pty/permissions.ts b/src/plugin/pty/permissions.ts index e5e6372c..e727af14 100644 --- a/src/plugin/pty/permissions.ts +++ b/src/plugin/pty/permissions.ts @@ -1,115 +1,48 @@ import type { PluginClient } from '../types.ts' -import { allStructured } from './wildcard.ts' +import type { PermissionAuthorizer } from '../../adapters/types.ts' +import { V1PermissionAuthorizer } from '../../adapters/v1/permissions.ts' -type PermissionAction = 'allow' | 'ask' | 'deny' -type BashPermissions = PermissionAction | Record +export type { PermissionAuthorizer } -interface PermissionConfig { - bash?: BashPermissions - external_directory?: PermissionAction -} - -let _client: PluginClient | null = null -let _directory: string | null = null - -export function initPermissions(client: PluginClient, directory: string): void { - _client = client - _directory = directory -} - -async function getPermissionConfig(): Promise { - if (!_client) { - return {} - } - try { - const response = await _client.config.get() - if (response.error || !response.data) { - return {} - } - return (response.data as { permission?: PermissionConfig }).permission ?? {} - } catch { - return {} - } -} +let _authorizer: PermissionAuthorizer | null = null -async function showToast( - message: string, - variant: 'info' | 'success' | 'error' = 'info' -): Promise { - if (!_client) return - try { - await _client.tui.showToast({ body: { message, variant } }) - } catch { - // Ignore toast errors - } +/** + * Sets the active permission authorizer for PTY command and directory checks. + */ +export function setPermissionAuthorizer(authorizer: PermissionAuthorizer | null): void { + _authorizer = authorizer } -async function denyWithToast(msg: string, details?: string): Promise { - await showToast(msg, 'error') - throw new Error(details ? `${msg} ${details}` : msg) +/** + * Returns the currently active permission authorizer, if any. + */ +export function getPermissionAuthorizer(): PermissionAuthorizer | null { + return _authorizer } -async function handleAskPermission(commandLine: string): Promise { - await denyWithToast( - `PTY: Command "${commandLine}" requires permission (treated as denied)`, - `PTY spawn denied: Command "${commandLine}" requires user permission which is not supported by this plugin. Configure explicit "allow" or "deny" in your opencode.json permission.bash settings.` - ) - throw new Error('Unreachable') // For TS, should never hit. +/** + * Backward-compatible initialization using V1 PluginClient. + */ +export function initPermissions(client: PluginClient, directory: string): void { + _authorizer = new V1PermissionAuthorizer(client, directory) } +/** + * Checks command execution permission against the active authorizer. + * Defaults to allowing if no authorizer is set. + */ export async function checkCommandPermission(command: string, args: string[]): Promise { - const config = await getPermissionConfig() - const bashPerms = config.bash - - if (!bashPerms) { - return - } - - if (typeof bashPerms === 'string') { - if (bashPerms === 'deny') { - await denyWithToast('PTY spawn denied: All bash commands are disabled by user configuration.') - } - if (bashPerms === 'ask') { - await handleAskPermission(command) - } - return - } - - const action = allStructured({ head: command, tail: args }, bashPerms) - - if (action === 'deny') { - await denyWithToast( - `PTY spawn denied: Command "${command} ${args.join(' ')}" is explicitly denied by user configuration.` - ) - } - - if (action === 'ask') { - await handleAskPermission(`${command} ${args.join(' ')}`) + if (_authorizer) { + await _authorizer.checkCommand(command, args) } } +/** + * Checks working directory access permission against the active authorizer. + * Defaults to allowing if no authorizer is set. + */ export async function checkWorkdirPermission(workdir: string): Promise { - if (!_directory) { - return - } - - const normalizedWorkdir = workdir.replace(/\/$/, '') - const normalizedProject = _directory.replace(/\/$/, '') - - if (normalizedWorkdir.startsWith(normalizedProject)) { - return - } - - const config = await getPermissionConfig() - const extDirPerm = config.external_directory - - if (extDirPerm === 'deny') { - await denyWithToast( - `PTY spawn denied: Working directory "${workdir}" is outside project directory "${_directory}". External directory access is denied by user configuration.` - ) - } - - if (extDirPerm === 'ask') { - // TODO: Implement user prompt for external directory access + if (_authorizer) { + await _authorizer.checkWorkdir(workdir) } } diff --git a/src/v2/commands.ts b/src/v2/commands.ts new file mode 100644 index 00000000..83af1136 --- /dev/null +++ b/src/v2/commands.ts @@ -0,0 +1,57 @@ +import open from 'open' +import { PTYServer, type ServerOptions } from '../web/server/server.ts' +import type { CommandDraft, OpencodePtyOptions } from './types.ts' + +export const PTY_OPEN_CLIENT_COMMAND = 'pty-open-background-spy' +export const PTY_SHOW_SERVER_URL_COMMAND = 'pty-show-server-url' + +let activeServer: PTYServer | null = null + +export async function getOrCreateServer(options?: ServerOptions): Promise { + if (!activeServer) { + activeServer = await PTYServer.createServer(options) + } + return activeServer +} + +export function getActiveServer(): PTYServer | null { + return activeServer +} + +export function stopActiveServer(): void { + if (activeServer) { + activeServer[Symbol.dispose]() + activeServer = null + } +} + +export async function handleOpenClientCommand(options?: ServerOptions): Promise { + const server = await getOrCreateServer(options) + const url = server.server.url.origin + open(url) + return `PTY Sessions Web Interface opened at: ${url}` +} + +export async function handleShowServerUrlCommand(options?: ServerOptions): Promise { + const server = await getOrCreateServer(options) + return `PTY Sessions Web Interface URL: ${server.server.url.origin}` +} + +export function registerV2Commands(draft: CommandDraft, _options?: OpencodePtyOptions): void { + if (typeof draft.update === 'function') { + draft.update(PTY_OPEN_CLIENT_COMMAND, (cmd) => { + if (cmd) { + cmd.description = 'Open PTY Sessions Web Interface' + cmd.template = + 'This command will start the PTY Sessions Web Interface in your default browser.' + } + }) + + draft.update(PTY_SHOW_SERVER_URL_COMMAND, (cmd) => { + if (cmd) { + cmd.description = 'Show PTY Sessions Web Interface URL' + cmd.template = 'This command will show the PTY Sessions Web Interface URL.' + } + }) + } +} diff --git a/src/v2/index.ts b/src/v2/index.ts new file mode 100644 index 00000000..ab803a47 --- /dev/null +++ b/src/v2/index.ts @@ -0,0 +1,36 @@ +import { createV2Adapter } from '../adapters/v2/index.ts' +import { installHostAdapter } from '../adapters/index.ts' +import { getOrCreateServer, registerV2Commands } from './commands.ts' +import { define, type PluginContextV2, type PluginV2 } from './types.ts' + +export * from './commands.ts' +export * from './tools.ts' +export * from './types.ts' + +/** + * OpenCode V2 Plugin definition for opencode-pty. + * Conforms to the V2 Plugin.define({ id, setup }) contract. + */ +export const Plugin: PluginV2 = define({ + id: 'opencode-pty', + setup: async (ctx: PluginContextV2) => { + const options = ctx.options + const adapter = createV2Adapter() + installHostAdapter(adapter) + + if (ctx.command && typeof ctx.command.transform === 'function') { + await ctx.command.transform((draft) => { + registerV2Commands(draft, options) + }) + } + + if (options?.autostart) { + await getOrCreateServer({ + port: options.port, + hostname: options.hostname, + }) + } + }, +}) + +export default Plugin diff --git a/src/v2/tools.ts b/src/v2/tools.ts new file mode 100644 index 00000000..853436ba --- /dev/null +++ b/src/v2/tools.ts @@ -0,0 +1,15 @@ +import { ptyKill } from '../plugin/pty/tools/kill.ts' +import { ptyList } from '../plugin/pty/tools/list.ts' +import { ptyRead } from '../plugin/pty/tools/read.ts' +import { ptySpawn } from '../plugin/pty/tools/spawn.ts' +import { ptyWrite } from '../plugin/pty/tools/write.ts' + +export const ptyTools = { + pty_spawn: ptySpawn, + pty_write: ptyWrite, + pty_read: ptyRead, + pty_list: ptyList, + pty_kill: ptyKill, +} as const + +export type PTYToolName = keyof typeof ptyTools diff --git a/src/v2/types.ts b/src/v2/types.ts new file mode 100644 index 00000000..2835d6f7 --- /dev/null +++ b/src/v2/types.ts @@ -0,0 +1,58 @@ +export interface OpencodePtyOptions { + /** + * Fixed port for the PTY Web UI observer server. + * If not set, defaults to an available ephemeral port or PTY_WEB_PORT env var. + */ + port?: number + + /** + * Hostname to bind the PTY Web UI observer server to. + * Defaults to '::1' (or PTY_WEB_HOSTNAME env var). + */ + hostname?: string + + /** + * Automatically start the PTY Web UI observer server upon plugin initialization. + * Default is false (started on-demand when slash command is executed). + */ + autostart?: boolean +} + +export interface CommandInfo { + title?: string + description?: string + template?: string + [key: string]: unknown +} + +export interface CommandDraft { + list?(): readonly unknown[] + get?(name: string): unknown + update?(name: string, update: (command: CommandInfo) => void): void + remove?(name: string): void + [key: string]: unknown +} + +export interface PluginContextV2 { + readonly options?: OpencodePtyOptions & Record + readonly command?: { + transform( + callback: (commands: CommandDraft) => Promise | void + ): Promise | undefined + reload?(): Promise | void + } + readonly tool?: { + transform(callback: (tools: unknown) => Promise | void): Promise | undefined + reload?(): Promise | void + } + readonly [key: string]: unknown +} + +export interface PluginV2 { + readonly id: string + readonly setup: (context: PluginContextV2) => Promise | void +} + +export function define(plugin: PluginV2): PluginV2 { + return plugin +} diff --git a/src/web/server/server.ts b/src/web/server/server.ts index c8e94ed7..02bf4ae5 100644 --- a/src/web/server/server.ts +++ b/src/web/server/server.ts @@ -17,13 +17,20 @@ import { buildStaticRoutes } from './handlers/static.ts' import { handleUpgrade } from './handlers/upgrade.ts' import { handleWebSocketMessage } from './handlers/websocket.ts' +export interface ServerOptions { + port?: number + hostname?: string +} + export class PTYServer implements Disposable { public readonly server: Server private readonly staticRoutes: Record private readonly stack = new DisposableStack() + private readonly options?: ServerOptions - private constructor(staticRoutes: Record) { + private constructor(staticRoutes: Record, options?: ServerOptions) { this.staticRoutes = staticRoutes + this.options = options this.server = this.startWebServer() this.stack.use(this.server) this.stack.use(new CallbackManager(this.server)) @@ -33,16 +40,20 @@ export class PTYServer implements Disposable { this.stack.dispose() } - public static async createServer(): Promise { + public static async createServer(options?: ServerOptions): Promise { const staticRoutes = await buildStaticRoutes() - return new PTYServer(staticRoutes) + return new PTYServer(staticRoutes, options) } private startWebServer(): Server { + const port = + this.options?.port ?? (process.env.PTY_WEB_PORT ? parseInt(process.env.PTY_WEB_PORT, 10) : 0) + const hostname = this.options?.hostname ?? process.env.PTY_WEB_HOSTNAME ?? '::1' + return Bun.serve({ - port: process.env.PTY_WEB_PORT ? parseInt(process.env.PTY_WEB_PORT, 10) : 0, - hostname: process.env.PTY_WEB_HOSTNAME ?? '::1', + port, + hostname, routes: { ...this.staticRoutes, diff --git a/test/adapters.test.ts b/test/adapters.test.ts new file mode 100644 index 00000000..1ae7ee58 --- /dev/null +++ b/test/adapters.test.ts @@ -0,0 +1,161 @@ +import { afterEach, describe, expect, it, mock } from 'bun:test' +import { + type HostAdapter, + type PermissionAuthorizer, + type SessionNotifier, + createV1Adapter, + installHostAdapter, +} from '../src/adapters/index.ts' +import { manager } from '../src/plugin/pty/manager.ts' +import { + checkCommandPermission, + checkWorkdirPermission, + getPermissionAuthorizer, + setPermissionAuthorizer, +} from '../src/plugin/pty/permissions.ts' +import type { PTYSession } from '../src/plugin/pty/types.ts' +import type { PluginContext } from '../src/plugin/types.ts' + +describe('Adapter Layer', () => { + afterEach(() => { + setPermissionAuthorizer(null) + manager.setNotifier(null) + manager.clearAllSessions() + }) + + describe('PermissionAuthorizer decoupling', () => { + it('delegates command and workdir checks to custom authorizer without PluginClient', async () => { + const checkCommand = mock(async (_cmd: string, _args: string[]) => {}) + const checkWorkdir = mock(async (_dir: string) => {}) + + const customAuthorizer: PermissionAuthorizer = { + checkCommand, + checkWorkdir, + } + + setPermissionAuthorizer(customAuthorizer) + expect(getPermissionAuthorizer()).toBe(customAuthorizer) + + await checkCommandPermission('ls', ['-la']) + expect(checkCommand).toHaveBeenCalledWith('ls', ['-la']) + + await checkWorkdirPermission('/some/dir') + expect(checkWorkdir).toHaveBeenCalledWith('/some/dir') + }) + + it('propagates authorization errors from custom authorizer', async () => { + const customAuthorizer: PermissionAuthorizer = { + checkCommand: async () => { + throw new Error('Command execution denied by security policy') + }, + checkWorkdir: async () => {}, + } + + setPermissionAuthorizer(customAuthorizer) + + expect(checkCommandPermission('rm', ['-rf', '/'])).rejects.toThrow( + 'Command execution denied by security policy' + ) + }) + + it('safely allows when no authorizer is set', async () => { + setPermissionAuthorizer(null) + expect(getPermissionAuthorizer()).toBeNull() + + await expect(checkCommandPermission('echo', ['hello'])).resolves.toBeUndefined() + await expect(checkWorkdirPermission('/anywhere')).resolves.toBeUndefined() + }) + }) + + describe('SessionNotifier decoupling', () => { + it('dispatches exit notifications to custom SessionNotifier without OpencodeClient', async () => { + let notifiedSession: PTYSession | null = null + let notifiedExitCode: number | null = null + + const customNotifier: SessionNotifier = { + sendExitNotification: (session, exitCode) => { + notifiedSession = session + notifiedExitCode = exitCode + }, + } + + manager.setNotifier(customNotifier) + + const info = manager.spawn({ + command: 'echo', + args: ['decoupled-test'], + description: 'Test decoupled notifier', + parentSessionId: 'parent-123', + notifyOnExit: true, + }) + + // Wait briefly for process to exit and notify + await new Promise((resolve) => setTimeout(resolve, 150)) + + expect(notifiedSession).not.toBeNull() + expect((notifiedSession as PTYSession | null)?.id).toBe(info.id) + expect(notifiedExitCode as number | null).toBe(0) + }) + }) + + describe('HostAdapter integration', () => { + it('installHostAdapter wires both notifier and permissions', () => { + const customNotifier: SessionNotifier = { + sendExitNotification: () => {}, + } + const customPermissions: PermissionAuthorizer = { + checkCommand: async () => {}, + checkWorkdir: async () => {}, + } + + const testAdapter: HostAdapter = { + id: 'test-adapter', + notifier: customNotifier, + permissions: customPermissions, + } + + installHostAdapter(testAdapter) + + expect(getPermissionAuthorizer()).toBe(customPermissions) + expect(manager.getNotifier()).toBe(customNotifier) + }) + + it('createV1Adapter creates a V1-compatible HostAdapter', async () => { + const fakeClient = { + config: { + get: async () => ({ + data: { + permission: { + bash: 'deny', + }, + }, + }), + }, + tui: { + showToast: async () => {}, + }, + session: { + get: async () => ({ data: {} }), + promptAsync: async () => {}, + }, + } + + const v1Context = { + client: fakeClient, + directory: '/test/workspace', + } as unknown as PluginContext + + const adapter = createV1Adapter(v1Context) + expect(adapter.id).toBe('opencode-v1') + expect(adapter.notifier).toBeDefined() + expect(adapter.permissions).toBeDefined() + + installHostAdapter(adapter) + + // Permission check should be active and reject bash commands because bash is 'deny' + expect(checkCommandPermission('ls', [])).rejects.toThrow( + 'All bash commands are disabled by user configuration' + ) + }) + }) +}) diff --git a/test/opencode-v2-live.test.ts b/test/opencode-v2-live.test.ts new file mode 100644 index 00000000..af89e892 --- /dev/null +++ b/test/opencode-v2-live.test.ts @@ -0,0 +1,104 @@ +import { afterEach, describe, expect, it } from 'bun:test' +import { + PTY_OPEN_CLIENT_COMMAND, + PTY_SHOW_SERVER_URL_COMMAND, + Plugin, + getOrCreateServer, + ptyTools, + stopActiveServer, +} from '../src/v2/index.ts' +import type { CommandDraft, CommandInfo, PluginContextV2 } from '../src/v2/types.ts' + +describe('OpenCode V2 Live Integration', () => { + afterEach(() => { + stopActiveServer() + }) + + it('matches the Schema.Struct expected by OpenCode core external plugin loader', async () => { + // OpenCode core's packages/core/src/config/plugin/external.ts decodes: + // Schema.Struct({ default: Schema.Union([ ... Schema.Struct({ id: Schema.String, setup: Function }) ]) }) + const v2Module = await import('../dist/src/v2/index.js') + + expect(v2Module.default).toBeDefined() + expect(typeof v2Module.default).toBe('object') + expect(v2Module.default.id).toBe('opencode-pty') + expect(typeof v2Module.default.setup).toBe('function') + }) + + it('executes setup inside a simulated OpenCode V2 PluginPromise host', async () => { + const registeredCommands: Record = {} + + // Simulated V2 Command Draft from OpenCode core + const commandDraft: CommandDraft = { + update: (name: string, updateFn: (cmd: CommandInfo) => void) => { + const item: CommandInfo = {} + registeredCommands[name] = item + updateFn(item) + }, + } + + let transformCalled = false + const simulatedContext: PluginContextV2 = { + options: { + port: 48999, + hostname: '127.0.0.1', + }, + command: { + transform: async (callback) => { + transformCalled = true + await callback(commandDraft) + }, + reload: async () => {}, + }, + } + + // Run setup through V2 plugin contract + await Plugin.setup(simulatedContext) + + expect(transformCalled).toBe(true) + expect(registeredCommands[PTY_OPEN_CLIENT_COMMAND]?.description).toBe( + 'Open PTY Sessions Web Interface' + ) + expect(registeredCommands[PTY_SHOW_SERVER_URL_COMMAND]?.description).toBe( + 'Show PTY Sessions Web Interface URL' + ) + + // Verify server creation with V2 options + const server = await getOrCreateServer({ + port: simulatedContext.options?.port, + hostname: simulatedContext.options?.hostname, + }) + + expect(server.server.url.port).toBe('48999') + expect(server.server.url.hostname).toBe('127.0.0.1') + }) + + it('runs PTY lifecycle operations in the V2 context', async () => { + // Spawn echo command using exported tools + const session = ptyTools.pty_spawn + expect(session).toBeDefined() + + // Test spawning a real background process through the manager + const { manager } = await import('../src/plugin/pty/manager.ts') + const spawned = manager.spawn({ + command: 'echo', + args: ['opencode-v2-live-test'], + description: 'V2 live test process', + parentSessionId: 'v2-session-1', + notifyOnExit: false, + }) + + expect(spawned.id).toBeDefined() + expect(spawned.status).toBe('running') + + // Read output + await new Promise((resolve) => setTimeout(resolve, 100)) + const readResult = manager.read(spawned.id, 0) + expect(readResult).not.toBeNull() + expect(readResult?.lines.join('')).toContain('opencode-v2-live-test') + + // Terminate + const killed = manager.kill(spawned.id, true) + expect(killed).toBe(true) + }) +}) diff --git a/test/v2.test.ts b/test/v2.test.ts new file mode 100644 index 00000000..d8afd8d7 --- /dev/null +++ b/test/v2.test.ts @@ -0,0 +1,99 @@ +import { afterEach, describe, expect, it, mock } from 'bun:test' +import { + PTY_OPEN_CLIENT_COMMAND, + PTY_SHOW_SERVER_URL_COMMAND, + Plugin, + getActiveServer, + getOrCreateServer, + handleShowServerUrlCommand, + ptyTools, + stopActiveServer, +} from '../src/v2/index.ts' +import type { CommandDraft, CommandInfo, PluginContextV2 } from '../src/v2/types.ts' + +describe('OpenCode V2 Plugin API', () => { + afterEach(() => { + stopActiveServer() + }) + + describe('Plugin Contract Conformance', () => { + it('satisfies the V2 plugin structure (id and setup)', () => { + expect(Plugin.id).toBe('opencode-pty') + expect(typeof Plugin.setup).toBe('function') + }) + + it('exports standard PTY tools', () => { + expect(ptyTools.pty_spawn).toBeDefined() + expect(ptyTools.pty_write).toBeDefined() + expect(ptyTools.pty_read).toBeDefined() + expect(ptyTools.pty_list).toBeDefined() + expect(ptyTools.pty_kill).toBeDefined() + }) + }) + + describe('Command Registration via ctx.command.transform', () => { + it('registers slash commands in the command draft', async () => { + const registeredCommands: Record = {} + + const draft: CommandDraft = { + update: (name: string, updateFn: (cmd: CommandInfo) => void) => { + const entry: CommandInfo = {} + registeredCommands[name] = entry + updateFn(entry) + }, + } + + const mockTransform = mock(async (callback: (draft: CommandDraft) => void) => { + callback(draft) + }) + + const ctx: PluginContextV2 = { + options: {}, + command: { + transform: mockTransform, + }, + } + + await Plugin.setup(ctx) + + expect(mockTransform).toHaveBeenCalled() + expect(registeredCommands[PTY_OPEN_CLIENT_COMMAND]?.description).toBe( + 'Open PTY Sessions Web Interface' + ) + expect(registeredCommands[PTY_SHOW_SERVER_URL_COMMAND]?.description).toBe( + 'Show PTY Sessions Web Interface URL' + ) + }) + }) + + describe('Options Support (Custom Port & Hostname)', () => { + it('creates server with custom port and hostname when specified', async () => { + const server = await getOrCreateServer({ port: 0, hostname: '127.0.0.1' }) + expect(server).toBeDefined() + expect(server.server.url.protocol).toBe('http:') + expect(server.server.url.hostname).toBe('127.0.0.1') + }) + + it('autostarts server when autostart option is true in ctx.options', async () => { + expect(getActiveServer()).toBeNull() + + const ctx: PluginContextV2 = { + options: { + autostart: true, + hostname: '127.0.0.1', + }, + } + + await Plugin.setup(ctx) + + const active = getActiveServer() + expect(active).not.toBeNull() + expect(active?.server.url.hostname).toBe('127.0.0.1') + }) + + it('shows server URL via handleShowServerUrlCommand', async () => { + const message = await handleShowServerUrlCommand({ hostname: '127.0.0.1' }) + expect(message).toContain('PTY Sessions Web Interface URL: http://127.0.0.1:') + }) + }) +})