Complete terminal session flow stabilization

This commit is contained in:
sladro 2026-03-30 10:32:32 +08:00
parent 50a2b0b48b
commit fb86ea2012
77 changed files with 6734 additions and 85 deletions

29
AGENTS.md Normal file
View File

@ -0,0 +1,29 @@
# AGENTS.md
## Tooling
- Flutter SDK path: `C:\tools\flutter\bin\flutter.bat`
- On Windows, use the absolute Flutter path instead of relying on `PATH`.
- Prefer running Flutter and Dart work from the repository root or the app directory with the absolute Flutter path.
## Windows Shell Setup
- Before running commands, initialize the terminal as UTF-8:
- `chcp 65001`
- `[Console]::InputEncoding = [System.Text.UTF8Encoding]::new($false)`
- `[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false)`
- `$OutputEncoding = [System.Text.UTF8Encoding]::new($false)`
- `$env:PYTHONUTF8 = '1'`
- `$env:PYTHONIOENCODING = 'utf-8'`
## Common Commands
- Run all mobile app tests:
- `C:\tools\flutter\bin\flutter.bat test`
- Run a targeted Flutter test:
- `C:\tools\flutter\bin\flutter.bat test test/widget_test.dart`
- Run Windows agent tests:
- `dotnet test apps/windows_agent/TermRemoteCtl.Agent.sln`
## Encoding Rules
- All source, config, Markdown, JSON, YAML, TOML, and script files must use UTF-8 without BOM.
- Do not rely on terminal rendering alone to diagnose encoding issues.
- Always read and write text files explicitly as UTF-8 when tooling requires an encoding choice.

View File

@ -17,3 +17,10 @@ TermRemoteCtl is a personal remote coding controller for one Windows workstation
2. Run dotnet new sln -n TermRemoteCtl.Agent -o apps/windows_agent
3. Verify the generated workspace with `flutter --version` and `dotnet --version`
4. Begin implementation in `apps/mobile_app` and `apps/windows_agent` using the checked-in workspace files and protocol notes
## Verification
- `dotnet test apps/windows_agent/tests/TermRemoteCtl.Agent.Tests/TermRemoteCtl.Agent.Tests.csproj`
- `flutter test apps/mobile_app/test`
- `flutter test apps/mobile_app/integration_test`
- The Flutter `integration_test` suite may still require a supported local runtime target or device on the machine running it.

View File

@ -1,8 +1,11 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET"/>
<application
android:label="term_remote_ctl"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
android:icon="@mipmap/ic_launcher"
android:networkSecurityConfig="@xml/network_security_config">
<activity
android:name=".MainActivity"
android:exported="true"

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<base-config cleartextTrafficPermitted="false" />
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="false">10.0.2.2</domain>
</domain-config>
</network-security-config>

View File

@ -0,0 +1,29 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:xterm/xterm.dart';
import 'package:term_remote_ctl/features/terminal/terminal_page.dart';
void main() {
testWidgets('reopens terminal and shows live mode affordance after being removed', (tester) async {
await tester.pumpWidget(const MaterialApp(home: TerminalPage()));
await tester.pumpAndSettle();
expect(find.textContaining('Live | 0 lines'), findsOneWidget);
expect(find.byType(TerminalView), findsOneWidget);
await tester.pumpWidget(const SizedBox.shrink());
await tester.pumpAndSettle();
expect(find.byType(TerminalPage), findsNothing);
await tester.pumpWidget(const MaterialApp(home: TerminalPage()));
await tester.pumpAndSettle();
expect(find.text('Terminal'), findsOneWidget);
expect(find.byType(TerminalView), findsOneWidget);
expect(
find.text('Minimal terminal shell. Live session wiring is not attached yet.'),
findsOneWidget,
);
expect(find.textContaining('Live | 0 lines'), findsOneWidget);
});
}

View File

@ -0,0 +1,16 @@
import 'package:flutter/material.dart';
import '../features/sessions/session_list_page.dart';
class TermRemoteCtlApp extends StatelessWidget {
const TermRemoteCtlApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'TermRemoteCtl',
theme: ThemeData(colorSchemeSeed: Colors.blue),
home: const SessionListPage(),
);
}
}

View File

@ -0,0 +1,70 @@
import 'package:dio/dio.dart';
class AgentApiClient {
AgentApiClient(this.baseUri, {Dio? dio}) : _dio = dio ?? Dio();
final Uri baseUri;
final Dio _dio;
Uri get sessionsUri => baseUri.resolve('/api/sessions');
Uri get pairingCodeUri => baseUri.resolve('/api/pairing/code');
Uri get pairingRedeemUri => baseUri.resolve('/api/pairing/redeem');
Uri sessionHistoryUri(String sessionId, {int lineCount = 200}) {
return baseUri.resolve('/api/sessions/$sessionId/history').replace(
queryParameters: <String, String>{'lineCount': '$lineCount'},
);
}
Future<List<Map<String, dynamic>>> listSessions() async {
final response = await _dio.getUri(sessionsUri);
return _readJsonList(response.data, 'sessions');
}
Future<Map<String, dynamic>> createSession(String name) async {
final response = await _dio.postUri(
sessionsUri,
data: <String, String>{'name': name},
);
return _readJsonMap(response.data, 'session');
}
Future<Map<String, dynamic>> getSessionHistory(
String sessionId, {
int lineCount = 200,
}) async {
final response = await _dio.getUri(
sessionHistoryUri(sessionId, lineCount: lineCount),
);
return _readJsonMap(response.data, 'session history');
}
Future<void> redeemPairingCode({
required String code,
required String deviceName,
}) {
return _dio.postUri(
pairingRedeemUri,
data: <String, String>{
'code': code,
'deviceName': deviceName,
},
);
}
List<Map<String, dynamic>> _readJsonList(dynamic data, String label) {
if (data is! List) {
throw FormatException('Expected $label response to be a JSON list.');
}
return data.map((entry) => _readJsonMap(entry, label)).toList();
}
Map<String, dynamic> _readJsonMap(dynamic data, String label) {
if (data is! Map) {
throw FormatException('Expected $label response to be a JSON object.');
}
return Map<String, dynamic>.from(data);
}
}

View File

@ -0,0 +1,21 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'agent_api_client.dart';
import '../../features/sessions/session_repository.dart';
import '../../features/sessions/session.dart';
final agentBaseUriProvider = StateProvider<Uri>((ref) {
return Uri.parse('http://10.0.2.2:5067');
});
final agentApiClientProvider = Provider<AgentApiClient>((ref) {
return AgentApiClient(ref.watch(agentBaseUriProvider));
});
final sessionRepositoryProvider = Provider<SessionRepository>((ref) {
return SessionRepository(ref.watch(agentApiClientProvider));
});
final sessionsProvider = FutureProvider<List<Session>>((ref) {
return ref.watch(sessionRepositoryProvider).listSessions();
});

View File

@ -0,0 +1,32 @@
class AgentSocketClient {
AgentSocketClient(this.baseUri);
final Uri baseUri;
Uri buildTerminalSocketUri(String sessionId) {
return Uri(
scheme: baseUri.scheme == 'https' ? 'wss' : 'ws',
host: baseUri.host,
port: baseUri.port,
path: '/ws/terminal',
queryParameters: <String, String>{'sessionId': sessionId},
);
}
Map<String, dynamic> buildAttachMessage(String sessionId) => <String, dynamic>{
'type': 'attach',
'sessionId': sessionId,
};
Map<String, dynamic> buildInputMessage(String input) => <String, dynamic>{
'type': 'input',
'input': input,
};
Map<String, dynamic> buildResizeMessage(int columns, int rows) =>
<String, dynamic>{
'type': 'resize',
'columns': columns,
'rows': rows,
};
}

View File

@ -0,0 +1,19 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/network/agent_api_client.dart';
class PairingController extends StateNotifier<AsyncValue<void>> {
PairingController(this._client) : super(const AsyncData(null));
final AgentApiClient _client;
Future<void> redeemCode(String code, String deviceName) async {
state = const AsyncLoading();
try {
await _client.redeemPairingCode(code: code, deviceName: deviceName);
state = const AsyncData(null);
} catch (error, stackTrace) {
state = AsyncError<void>(error, stackTrace);
}
}
}

View File

@ -0,0 +1,11 @@
class PresetCommand {
PresetCommand({
required this.id,
required this.label,
required this.commandText,
});
final String id;
final String label;
final String commandText;
}

View File

@ -0,0 +1,23 @@
import 'package:flutter/material.dart';
import 'package:term_remote_ctl/features/presets/preset_command.dart';
class PresetPanel extends StatelessWidget {
const PresetPanel({super.key, required this.presets});
final List<PresetCommand> presets;
@override
Widget build(BuildContext context) {
return Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final preset in presets)
FilledButton(
onPressed: () {},
child: Text(preset.label),
),
],
);
}
}

View File

@ -0,0 +1,9 @@
import 'package:term_remote_ctl/features/presets/preset_command.dart';
class PresetRepository {
const PresetRepository();
List<PresetCommand> listPresets() {
return const [];
}
}

View File

@ -0,0 +1,17 @@
class Session {
Session({
required this.sessionId,
required this.name,
required this.status,
});
final String sessionId;
final String name;
final String status;
factory Session.fromJson(Map<String, dynamic> json) => Session(
sessionId: json['sessionId'] as String,
name: json['name'] as String,
status: json['status'] as String,
);
}

View File

@ -0,0 +1,296 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/network/agent_connection_providers.dart';
import 'session.dart';
import '../terminal/terminal_page.dart';
class SessionListPage extends ConsumerStatefulWidget {
const SessionListPage({super.key});
@override
ConsumerState<SessionListPage> createState() => _SessionListPageState();
}
class _SessionListPageState extends ConsumerState<SessionListPage> {
late final TextEditingController _agentUrlController;
@override
void initState() {
super.initState();
_agentUrlController = TextEditingController(
text: ref.read(agentBaseUriProvider).toString(),
);
}
@override
void dispose() {
_agentUrlController.dispose();
super.dispose();
}
void _showMessage(String message) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message)),
);
}
Future<void> _refreshSessions() async {
await _reloadSessions();
}
Future<void> _createSession() async {
final repository = ref.read(sessionRepositoryProvider);
var sessionNameInput = '';
final sessionName = await showDialog<String>(
context: context,
builder: (context) {
return AlertDialog(
title: const Text('New session'),
content: TextField(
autofocus: true,
textInputAction: TextInputAction.done,
decoration: const InputDecoration(
labelText: 'Session name',
hintText: 'codex-main',
),
onChanged: (value) {
sessionNameInput = value;
},
onSubmitted: (value) {
sessionNameInput = value;
Navigator.of(context).pop(value);
},
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(sessionNameInput),
child: const Text('Create'),
),
],
);
},
);
final trimmedName = sessionName?.trim() ?? '';
if (trimmedName.isEmpty) {
return;
}
try {
await repository.createSession(trimmedName);
await _reloadSessions();
} catch (error) {
if (!mounted) {
return;
}
_showMessage('Failed to create session: $error');
}
}
void _openSession(Session session) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) {
return TerminalPage(
session: session,
agentBaseUri: ref.read(agentBaseUriProvider),
);
},
),
);
}
Future<void> _applyAgentUrl() async {
final parsedUri = Uri.tryParse(_agentUrlController.text.trim());
if (parsedUri == null || parsedUri.scheme.isEmpty || parsedUri.host.isEmpty) {
_showMessage('Enter a valid agent base URL.');
return;
}
final current = ref.read(agentBaseUriProvider);
if (current == parsedUri) {
return;
}
ref.read(agentBaseUriProvider.notifier).state = parsedUri;
await _reloadSessions();
}
Future<void> _reloadSessions() async {
ref.invalidate(sessionsProvider);
try {
await ref.read(sessionsProvider.future);
} catch (error) {
if (!mounted) {
return;
}
_showMessage('Failed to load sessions: $error');
}
}
Widget _buildAgentConfigCard(Uri baseUri) {
return Card(
margin: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Agent base URL',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: TextField(
controller: _agentUrlController,
decoration: const InputDecoration(
hintText: 'http://10.0.2.2:5067',
border: OutlineInputBorder(),
),
keyboardType: TextInputType.url,
textInputAction: TextInputAction.done,
onSubmitted: (_) => _applyAgentUrl(),
),
),
const SizedBox(width: 12),
FilledButton(
onPressed: _applyAgentUrl,
child: const Text('Use'),
),
],
),
const SizedBox(height: 8),
Text(
'Session requests use this base origin: ${baseUri.toString()}.',
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
);
}
Widget _buildSessionsBody(AsyncValue<List<Session>> sessionsAsync) {
return sessionsAsync.when(
loading: () {
return const Center(child: CircularProgressIndicator());
},
error: (error, stackTrace) {
return RefreshIndicator(
onRefresh: _refreshSessions,
child: ListView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(24),
children: [
const SizedBox(height: 96),
const Icon(Icons.cloud_off_outlined, size: 48),
const SizedBox(height: 16),
Text(
'Could not load sessions',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
Text(
'$error',
textAlign: TextAlign.center,
),
],
),
);
},
data: (sessions) {
return RefreshIndicator(
onRefresh: _refreshSessions,
child: sessions.isEmpty
? ListView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(24),
children: [
const SizedBox(height: 56),
Icon(
Icons.terminal_outlined,
size: 56,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(height: 16),
Text(
'No sessions yet',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
Text(
'Create a session after you point the app at a reachable agent.',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium,
),
],
)
: ListView.separated(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(16),
itemCount: sessions.length,
separatorBuilder: (context, index) =>
const SizedBox(height: 12),
itemBuilder: (context, index) {
final session = sessions[index];
return Card(
child: ListTile(
onTap: () => _openSession(session),
leading: const Icon(Icons.terminal),
title: Text(session.name),
subtitle: Text('Status: ${session.status}'),
trailing: Text(
session.sessionId,
style: Theme.of(context).textTheme.bodySmall,
),
),
);
},
),
);
},
);
}
@override
Widget build(BuildContext context) {
final baseUri = ref.watch(agentBaseUriProvider);
final sessionsAsync = ref.watch(sessionsProvider);
return Scaffold(
appBar: AppBar(
title: const Text('Sessions'),
actions: [
IconButton(
onPressed: _refreshSessions,
tooltip: 'Refresh sessions',
icon: const Icon(Icons.refresh),
),
],
),
floatingActionButton: FloatingActionButton(
onPressed: _createSession,
tooltip: 'Create session',
child: const Icon(Icons.add),
),
body: Column(
children: [
_buildAgentConfigCard(baseUri),
Expanded(child: _buildSessionsBody(sessionsAsync)),
],
),
);
}
}

View File

@ -0,0 +1,18 @@
import 'package:term_remote_ctl/core/network/agent_api_client.dart';
import 'package:term_remote_ctl/features/sessions/session.dart';
class SessionRepository {
SessionRepository(this._client);
final AgentApiClient _client;
Future<List<Session>> listSessions() async {
final sessions = await _client.listSessions();
return sessions.map(Session.fromJson).toList(growable: false);
}
Future<Session> createSession(String name) async {
final session = await _client.createSession(name);
return Session.fromJson(session);
}
}

View File

@ -0,0 +1,9 @@
class HistoryWindow {
const HistoryWindow({
required this.lines,
required this.hasMoreAbove,
});
final List<String> lines;
final bool hasMoreAbove;
}

View File

@ -0,0 +1,7 @@
import 'history_window.dart';
import 'terminal_interaction_controller.dart';
class TerminalController extends TerminalInteractionController {
TerminalController({HistoryWindow historyWindow = const HistoryWindow(lines: <String>[], hasMoreAbove: false)})
: super(historyWindow: historyWindow);
}

View File

@ -0,0 +1,89 @@
import 'package:flutter/foundation.dart';
import 'history_window.dart';
enum TerminalConnectionState {
connecting,
connected,
reconnecting,
disconnected,
}
class TerminalInteractionController extends ChangeNotifier {
TerminalInteractionController({
HistoryWindow historyWindow = const HistoryWindow(
lines: <String>[],
hasMoreAbove: false,
),
}) : _historyWindow = historyWindow;
TerminalConnectionState _connectionState = TerminalConnectionState.connecting;
bool _isFollowingLiveOutput = true;
bool _hasPendingLiveOutput = false;
HistoryWindow _historyWindow;
final List<String> _liveLines = <String>[];
TerminalConnectionState get connectionState => _connectionState;
bool get isFollowingLiveOutput => _isFollowingLiveOutput;
bool get hasPendingLiveOutput => _hasPendingLiveOutput;
bool get canSendInput => _connectionState == TerminalConnectionState.connected;
HistoryWindow get historyWindow => _historyWindow;
List<String> get liveLines => List.unmodifiable(_liveLines);
void markConnecting() {
_connectionState = TerminalConnectionState.connecting;
notifyListeners();
}
void markConnected() {
_connectionState = TerminalConnectionState.connected;
notifyListeners();
}
void markReconnecting() {
_connectionState = TerminalConnectionState.reconnecting;
notifyListeners();
}
void markDisconnected() {
_connectionState = TerminalConnectionState.disconnected;
notifyListeners();
}
void enterScrollback() {
_isFollowingLiveOutput = false;
notifyListeners();
}
void jumpToLive() {
_isFollowingLiveOutput = true;
_hasPendingLiveOutput = false;
notifyListeners();
}
void registerIncomingFrame() {
if (!_isFollowingLiveOutput) {
_hasPendingLiveOutput = true;
notifyListeners();
}
}
void applyFrame(String chunk) {
_liveLines.add(chunk);
notifyListeners();
}
void loadHistory(HistoryWindow historyWindow) {
_historyWindow = historyWindow;
_liveLines
..clear()
..addAll(historyWindow.lines);
_hasPendingLiveOutput = false;
notifyListeners();
}
}

View File

@ -0,0 +1,468 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:xterm/xterm.dart';
import '../../core/network/agent_connection_providers.dart';
import '../sessions/session.dart';
import 'history_window.dart';
import 'terminal_interaction_controller.dart';
import 'terminal_session_coordinator.dart';
import 'terminal_socket_session.dart';
class TerminalPage extends ConsumerStatefulWidget {
const TerminalPage({
super.key,
required this.session,
required this.agentBaseUri,
});
final Session session;
final Uri agentBaseUri;
@override
ConsumerState<TerminalPage> createState() => _TerminalPageState();
}
class _TerminalPageState extends ConsumerState<TerminalPage> {
final Terminal terminal = Terminal(maxLines: 1000);
final TerminalInteractionController controller =
TerminalInteractionController();
final FocusNode _terminalFocusNode = FocusNode();
final TextEditingController _inputController = TextEditingController();
late final TerminalSessionCoordinator _coordinator;
late final Listenable _controllerAndCoordinator;
@override
void initState() {
super.initState();
_coordinator = TerminalSessionCoordinator(
controller: controller,
apiClient: ref.read(agentApiClientProvider),
session: widget.session,
sessionFactory: ref.read(terminalSocketSessionFactoryProvider).create,
baseUri: widget.agentBaseUri,
onFrame: terminal.write,
onHistoryLoaded: (history) {
if (history.lines.isNotEmpty) {
terminal.write('${history.lines.join('\r\n')}\r\n');
}
},
viewportProvider: () => TerminalViewport(
columns: terminal.viewWidth,
rows: terminal.viewHeight,
),
);
_controllerAndCoordinator = Listenable.merge([controller, _coordinator]);
terminal.onResize = (width, height, _, _) {
_coordinator.handleTerminalResize(width, height);
};
terminal.onOutput = _coordinator.sendInput;
unawaited(_coordinator.start());
}
@override
void dispose() {
_terminalFocusNode.dispose();
_inputController.dispose();
unawaited(_coordinator.close());
controller.dispose();
super.dispose();
}
Future<void> _sendLine() async {
final input = _inputController.text;
if (!_canSendInput || input.trim().isEmpty) {
return;
}
_coordinator.sendInput('$input\r');
_inputController.clear();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.session.name),
actions: [
AnimatedBuilder(
animation: controller,
builder: (context, _) {
final mode = controller.isFollowingLiveOutput
? 'Live'
: 'Scrollback';
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: InkWell(
borderRadius: BorderRadius.circular(999),
onTap: () {
if (controller.isFollowingLiveOutput) {
controller.enterScrollback();
}
},
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 6,
),
child: Text('$mode | ${controller.liveLines.length} lines'),
),
),
),
if (!controller.isFollowingLiveOutput)
Padding(
padding: const EdgeInsets.only(right: 12),
child: TextButton(
onPressed: controller.jumpToLive,
child: const Text('Back to live'),
),
),
],
);
},
),
],
),
body: Column(
children: [
AnimatedBuilder(
animation: _controllerAndCoordinator,
builder: (context, _) {
if (controller.isFollowingLiveOutput) {
return const SizedBox.shrink();
}
return Container(
width: double.infinity,
margin: const EdgeInsets.fromLTRB(12, 6, 12, 0),
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: Theme.of(context).colorScheme.outlineVariant,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
'Recent scrollback',
style: Theme.of(context).textTheme.titleSmall,
),
const Spacer(),
Text(
'${controller.historyWindow.lines.length} lines loaded',
style: Theme.of(context).textTheme.labelMedium,
),
],
),
const SizedBox(height: 6),
ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 72),
child: DecoratedBox(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(12),
),
child: ListView.separated(
key: const Key('terminal_scrollback_list'),
shrinkWrap: true,
itemCount: controller.historyWindow.lines.length,
itemBuilder: (context, index) {
return Padding(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 6,
),
child: Text(
controller.historyWindow.lines[index],
style: Theme.of(context).textTheme.bodySmall,
),
);
},
separatorBuilder: (context, index) => Divider(
height: 1,
color: Theme.of(context).colorScheme.outlineVariant,
),
),
),
),
const SizedBox(height: 6),
Container(
key: const Key('terminal_scrollback_actions'),
width: double.infinity,
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 8,
),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
Expanded(
child: Text(
controller.historyWindow.hasMoreAbove
? 'Recent history is loaded. Older lines are not loaded yet.'
: 'All loaded history is visible.',
style: Theme.of(context).textTheme.bodySmall,
),
),
if (controller.historyWindow.hasMoreAbove) ...[
const SizedBox(width: 8),
TextButton.icon(
onPressed: _coordinator.isLoadingOlderHistory
? null
: _coordinator.loadOlderHistory,
icon: _coordinator.isLoadingOlderHistory
? const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: const Icon(Icons.unfold_less_double),
label: Text(
_coordinator.isLoadingOlderHistory
? 'Loading older lines...'
: 'Load older lines',
),
),
],
],
),
),
],
),
);
},
),
AnimatedBuilder(
animation: controller,
builder: (context, _) {
if (controller.isFollowingLiveOutput) {
return const SizedBox.shrink();
}
return Container(
width: double.infinity,
margin: const EdgeInsets.fromLTRB(12, 4, 12, 0),
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
Icon(
Icons.pause_circle_outline,
size: 18,
color: Theme.of(context).colorScheme.onSecondaryContainer,
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Browsing history. Live output is still arriving.',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context)
.colorScheme
.onSecondaryContainer,
),
),
if (controller.hasPendingLiveOutput)
Align(
alignment: Alignment.centerLeft,
child: TextButton(
onPressed: controller.jumpToLive,
child: const Text('New output available'),
),
),
],
),
),
],
),
);
},
),
Expanded(
child: TerminalView(
terminal,
focusNode: _terminalFocusNode,
autofocus: true,
),
),
AnimatedBuilder(
animation: _controllerAndCoordinator,
builder: (context, _) {
return Material(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
child: Padding(
padding: const EdgeInsets.all(12),
child: Container(
key: const Key('terminal_controls_panel'),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: Theme.of(context).colorScheme.outlineVariant,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Terminal controls',
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 12),
Row(
children: [
_StatusChip(
label: _statusLabel,
icon: _statusIcon,
color: _statusColor(context),
),
const SizedBox(width: 8),
_StatusChip(
label: controller.historyWindow.lines.isEmpty
? 'No history'
: 'History ready',
icon: controller.historyWindow.hasMoreAbove
? Icons.history_toggle_off
: Icons.history,
color: Theme.of(context).colorScheme.secondary,
),
const Spacer(),
OutlinedButton.icon(
onPressed: () => unawaited(_coordinator.reconnectNow()),
icon: const Icon(Icons.refresh),
label: const Text('Reconnect'),
),
],
),
const SizedBox(height: 12),
TextField(
controller: _inputController,
enabled: _canSendInput,
decoration: const InputDecoration(
labelText: 'Send input',
hintText: 'dir',
border: OutlineInputBorder(),
),
onSubmitted: (_) => _sendLine(),
),
const SizedBox(height: 8),
Row(
children: [
FilledButton(
onPressed: _canSendInput ? _sendLine : null,
child: const Text('Send'),
),
const SizedBox(width: 12),
Expanded(
child: Text(
_coordinator.connectionStatus,
style: Theme.of(context).textTheme.bodySmall,
),
),
],
),
],
),
),
),
);
},
),
],
),
);
}
String get _statusLabel => switch (_connectionState) {
TerminalConnectionState.connecting => 'Connecting',
TerminalConnectionState.connected => 'Connected',
TerminalConnectionState.reconnecting => 'Reconnecting',
TerminalConnectionState.disconnected => 'Offline',
};
bool get _canSendInput => controller.canSendInput;
IconData get _statusIcon => switch (_connectionState) {
TerminalConnectionState.connecting => Icons.sync,
TerminalConnectionState.connected => Icons.check_circle,
TerminalConnectionState.reconnecting => Icons.refresh,
TerminalConnectionState.disconnected => Icons.portable_wifi_off,
};
Color _statusColor(BuildContext context) => switch (_connectionState) {
TerminalConnectionState.connecting =>
Theme.of(context).colorScheme.tertiary,
TerminalConnectionState.connected =>
Theme.of(context).colorScheme.primary,
TerminalConnectionState.reconnecting =>
Theme.of(context).colorScheme.secondary,
TerminalConnectionState.disconnected =>
Theme.of(context).colorScheme.error,
};
TerminalConnectionState get _connectionState => controller.connectionState;
}
class _StatusChip extends StatelessWidget {
const _StatusChip({
required this.label,
required this.icon,
required this.color,
});
final String label;
final IconData icon;
final Color color;
@override
Widget build(BuildContext context) {
return DecoratedBox(
decoration: BoxDecoration(
color: color.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(999),
border: Border.all(color: color.withValues(alpha: 0.24)),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 16, color: color),
const SizedBox(width: 6),
Text(
label,
style: Theme.of(context).textTheme.labelMedium?.copyWith(
color: color,
fontWeight: FontWeight.w600,
),
),
],
),
),
);
}
}

View File

@ -0,0 +1,243 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import '../../core/network/agent_api_client.dart';
import '../sessions/session.dart';
import 'history_window.dart';
import 'terminal_interaction_controller.dart';
import 'terminal_socket_session.dart';
typedef CancelReconnect = void Function();
typedef ReconnectScheduler = CancelReconnect Function(
Duration delay,
Future<void> Function() callback,
);
typedef TerminalSessionFactory = TerminalSocketSession Function({
required Uri baseUri,
required Session session,
});
class TerminalViewport {
const TerminalViewport({
required this.columns,
required this.rows,
});
final int columns;
final int rows;
}
class TerminalSessionCoordinator extends ChangeNotifier {
TerminalSessionCoordinator({
required this.controller,
required this.apiClient,
required this.session,
required this.sessionFactory,
required this.onFrame,
required this.viewportProvider,
Uri? baseUri,
this.onHistoryLoaded,
ReconnectScheduler? reconnectScheduler,
}) : baseUri = baseUri ?? _defaultBaseUri,
_reconnectScheduler = reconnectScheduler ?? _defaultReconnectScheduler;
static final Uri _defaultBaseUri = Uri(
scheme: 'https',
host: 'host',
port: 9443,
);
static const Duration reconnectDelay = Duration(seconds: 1);
static const int initialHistoryLineCount = 1000;
final TerminalInteractionController controller;
final AgentApiClient apiClient;
final Session session;
final TerminalSessionFactory sessionFactory;
final void Function(String frame) onFrame;
final TerminalViewport Function() viewportProvider;
final Uri baseUri;
final void Function(HistoryWindow history)? onHistoryLoaded;
final ReconnectScheduler _reconnectScheduler;
TerminalSocketSession? _socketSession;
CancelReconnect? _cancelReconnect;
int _historyLineCount = initialHistoryLineCount;
bool _isLoadingOlderHistory = false;
bool _isDisposed = false;
String _connectionStatus = 'Connecting...';
bool get isLoadingOlderHistory => _isLoadingOlderHistory;
String get connectionStatus => _connectionStatus;
Future<void> start({bool isReconnect = false}) async {
_cancelPendingReconnect();
if (_isDisposed) {
return;
}
if (isReconnect) {
controller.markReconnecting();
_connectionStatus = 'Reconnecting to ${session.name}...';
} else {
controller.markConnecting();
_connectionStatus = 'Connecting...';
}
notifyListeners();
_disposeActiveSessionInBackground();
if (!isReconnect && controller.liveLines.isEmpty) {
await _loadHistory();
}
if (_isDisposed) {
return;
}
final socketSession = sessionFactory(
baseUri: baseUri,
session: session,
);
_socketSession = socketSession;
try {
await socketSession.connect(
onFrame: _handleFrame,
onDisconnected: () {
if (_isDisposed || !identical(_socketSession, socketSession)) {
return;
}
_scheduleReconnect();
},
);
if (_isDisposed || !identical(_socketSession, socketSession)) {
return;
}
final viewport = viewportProvider();
socketSession.sendResize(viewport.columns, viewport.rows);
controller.markConnected();
_connectionStatus = 'Attached to ${session.name}';
notifyListeners();
} catch (error) {
if (_isDisposed || !identical(_socketSession, socketSession)) {
return;
}
controller.markDisconnected();
_connectionStatus = 'Live connection unavailable: $error';
notifyListeners();
_scheduleReconnect();
}
}
void handleTerminalResize(int columns, int rows) {
_socketSession?.sendResize(columns, rows);
}
void sendInput(String input) {
_socketSession?.sendInput(input);
}
Future<void> loadOlderHistory() async {
if (_isLoadingOlderHistory || !controller.historyWindow.hasMoreAbove) {
return;
}
_isLoadingOlderHistory = true;
_historyLineCount += initialHistoryLineCount;
notifyListeners();
try {
await _loadHistory();
} finally {
_isLoadingOlderHistory = false;
notifyListeners();
}
}
Future<void> reconnectNow() async {
_cancelPendingReconnect();
await start(isReconnect: true);
}
Future<void> close() async {
_isDisposed = true;
_cancelPendingReconnect();
await _closeActiveSession();
}
void _handleFrame(String chunk) {
controller.registerIncomingFrame();
controller.applyFrame(chunk);
onFrame(chunk);
}
Future<void> _loadHistory() async {
try {
final payload = await apiClient.getSessionHistory(
session.sessionId,
lineCount: _historyLineCount,
);
final history = HistoryWindow(
lines: ((payload['lines'] as List?) ?? const <dynamic>[])
.map((line) => line.toString())
.toList(growable: false),
hasMoreAbove: payload['hasMoreAbove'] == true,
);
controller.loadHistory(history);
onHistoryLoaded?.call(history);
} catch (_) {
}
}
void _scheduleReconnect() {
_cancelPendingReconnect();
controller.markReconnecting();
_connectionStatus = 'Connection lost. Reconnecting...';
notifyListeners();
_cancelReconnect = _reconnectScheduler(reconnectDelay, () async {
if (_isDisposed) {
return;
}
await start(isReconnect: true);
});
}
void _cancelPendingReconnect() {
_cancelReconnect?.call();
_cancelReconnect = null;
}
void _disposeActiveSessionInBackground() {
final activeSession = _socketSession;
_socketSession = null;
if (activeSession != null) {
unawaited(activeSession.dispose());
}
}
Future<void> _closeActiveSession() async {
final activeSession = _socketSession;
_socketSession = null;
if (activeSession != null) {
await activeSession.dispose();
}
}
static CancelReconnect _defaultReconnectScheduler(
Duration delay,
Future<void> Function() callback,
) {
final timer = Timer(delay, () {
unawaited(callback());
});
return timer.cancel;
}
}

View File

@ -0,0 +1,179 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:web_socket_channel/web_socket_channel.dart';
import '../../core/network/agent_api_client.dart';
import '../../core/network/agent_socket_client.dart';
import '../sessions/session.dart';
typedef TerminalSocketTransportFactory = TerminalSocketTransport Function(Uri uri);
final terminalSocketSessionFactoryProvider =
Provider<TerminalSocketSessionFactory>((ref) {
return TerminalSocketSessionFactory(
transportFactory: WebSocketTerminalSocketTransport.connect,
);
});
class TerminalSocketSessionFactory {
TerminalSocketSessionFactory({TerminalSocketTransportFactory? transportFactory})
: _transportFactory = transportFactory ?? WebSocketTerminalSocketTransport.connect;
final TerminalSocketTransportFactory _transportFactory;
TerminalSocketSession create({
required Uri baseUri,
required Session session,
}) {
return TerminalSocketSession(
sessionId: session.sessionId,
socketClient: AgentSocketClient(baseUri),
transportFactory: _transportFactory,
);
}
}
class TerminalSocketSession {
TerminalSocketSession({
required this.sessionId,
required this.socketClient,
TerminalSocketTransportFactory? transportFactory,
}) : _transportFactory = transportFactory ?? WebSocketTerminalSocketTransport.connect;
final String sessionId;
final AgentSocketClient socketClient;
final TerminalSocketTransportFactory _transportFactory;
TerminalSocketTransport? _transport;
StreamSubscription<dynamic>? _subscription;
bool _isAttached = false;
Future<void> connect({
required void Function(String frame) onFrame,
void Function()? onDisconnected,
}) async {
if (_transport != null || _subscription != null) {
await dispose();
}
final transport = _transportFactory(socketClient.buildTerminalSocketUri(sessionId));
_transport = transport;
final attachedCompleter = Completer<void>();
_subscription = transport.stream.listen(
(message) {
if (message is! String) {
return;
}
if (!_isAttached && _handleAttachedAck(message)) {
_isAttached = true;
if (!attachedCompleter.isCompleted) {
attachedCompleter.complete();
}
return;
}
onFrame(message);
},
onError: (error, stackTrace) {
if (!attachedCompleter.isCompleted) {
attachedCompleter.completeError(error, stackTrace);
}
},
onDone: () {
if (!attachedCompleter.isCompleted) {
attachedCompleter.completeError(
StateError('Terminal socket closed before attach acknowledgement.'),
);
return;
}
onDisconnected?.call();
},
);
transport.send(jsonEncode(socketClient.buildAttachMessage(sessionId)));
try {
await attachedCompleter.future;
} catch (_) {
await dispose();
rethrow;
}
}
void sendInput(String input) {
final transport = _transport;
if (transport == null || input.isEmpty) {
return;
}
transport.send(jsonEncode(socketClient.buildInputMessage(input)));
}
void sendResize(int columns, int rows) {
final transport = _transport;
if (transport == null || columns <= 0 || rows <= 0) {
return;
}
transport.send(jsonEncode(socketClient.buildResizeMessage(columns, rows)));
}
Future<void> dispose() async {
final subscription = _subscription;
final transport = _transport;
_subscription = null;
_transport = null;
_isAttached = false;
await subscription?.cancel();
try {
await transport?.close();
} catch (_) {
}
}
bool _handleAttachedAck(String frame) {
try {
final decoded = jsonDecode(frame);
if (decoded is Map && decoded['type'] == 'attached') {
return true;
}
} catch (_) {
}
return false;
}
}
abstract class TerminalSocketTransport {
Stream<dynamic> get stream;
void send(String message);
Future<void> close();
}
class WebSocketTerminalSocketTransport implements TerminalSocketTransport {
WebSocketTerminalSocketTransport(this._channel);
final WebSocketChannel _channel;
static WebSocketTerminalSocketTransport connect(Uri uri) {
return WebSocketTerminalSocketTransport(WebSocketChannel.connect(uri));
}
@override
Stream<dynamic> get stream => _channel.stream;
@override
void send(String message) {
_channel.sink.add(message);
}
@override
Future<void> close() {
return _channel.sink.close();
}
}

View File

@ -1,55 +1,8 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'app/app.dart';
void main() {
runApp(const TermRemoteCtlApp());
}
class TermRemoteCtlApp extends StatelessWidget {
const TermRemoteCtlApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
title: 'TermRemoteCtl',
theme: ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(seedColor: Colors.teal),
),
home: const BootstrapShell(),
);
}
}
class BootstrapShell extends StatelessWidget {
const BootstrapShell({super.key});
@override
Widget build(BuildContext context) {
return const Scaffold(
body: SafeArea(
child: Center(
child: Padding(
padding: EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Icon(Icons.computer, size: 48),
SizedBox(height: 16),
Text(
'TermRemoteCtl',
textAlign: TextAlign.center,
),
SizedBox(height: 8),
Text(
'Bootstrap shell ready for the mobile client.',
textAlign: TextAlign.center,
),
],
),
),
),
),
);
}
runApp(const ProviderScope(child: TermRemoteCtlApp()));
}

View File

@ -1,6 +1,30 @@
# Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile
packages:
_fe_analyzer_shared:
dependency: transitive
description:
name: _fe_analyzer_shared
sha256: da0d9209ca76bde579f2da330aeb9df62b6319c834fa7baae052021b0462401f
url: "https://pub.flutter-io.cn"
source: hosted
version: "85.0.0"
analyzer:
dependency: transitive
description:
name: analyzer
sha256: "974859dc0ff5f37bc4313244b3218c791810d03ab3470a579580279ba971a48d"
url: "https://pub.flutter-io.cn"
source: hosted
version: "7.7.1"
args:
dependency: transitive
description:
name: args
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.7.0"
async:
dependency: transitive
description:
@ -17,6 +41,70 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.1.2"
build:
dependency: transitive
description:
name: build
sha256: "51dc711996cbf609b90cbe5b335bbce83143875a9d58e4b5c6d3c4f684d3dda7"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.5.4"
build_config:
dependency: transitive
description:
name: build_config
sha256: "4ae2de3e1e67ea270081eaee972e1bd8f027d459f249e0f1186730784c2e7e33"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.1.2"
build_daemon:
dependency: transitive
description:
name: build_daemon
sha256: bf05f6e12cfea92d3c09308d7bcdab1906cd8a179b023269eed00c071004b957
url: "https://pub.flutter-io.cn"
source: hosted
version: "4.1.1"
build_resolvers:
dependency: transitive
description:
name: build_resolvers
sha256: ee4257b3f20c0c90e72ed2b57ad637f694ccba48839a821e87db762548c22a62
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.5.4"
build_runner:
dependency: "direct dev"
description:
name: build_runner
sha256: "382a4d649addbfb7ba71a3631df0ec6a45d5ab9b098638144faf27f02778eb53"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.5.4"
build_runner_core:
dependency: transitive
description:
name: build_runner_core
sha256: "85fbbb1036d576d966332a3f5ce83f2ce66a40bea1a94ad2d5fc29a19a0d3792"
url: "https://pub.flutter-io.cn"
source: hosted
version: "9.1.2"
built_collection:
dependency: transitive
description:
name: built_collection
sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100"
url: "https://pub.flutter-io.cn"
source: hosted
version: "5.1.1"
built_value:
dependency: transitive
description:
name: built_value
sha256: "0730c18c770d05636a8f945c32a4d7d81cb6e0f0148c8db4ad12e7748f7e49af"
url: "https://pub.flutter-io.cn"
source: hosted
version: "8.12.5"
characters:
dependency: transitive
description:
@ -25,6 +113,14 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.4.0"
checked_yaml:
dependency: transitive
description:
name: checked_yaml
sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.0.4"
clock:
dependency: transitive
description:
@ -33,6 +129,14 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.1.2"
code_builder:
dependency: transitive
description:
name: code_builder
sha256: "6a6cab2ba4680d6423f34a9b972a4c9a94ebe1b62ecec4e1a1f2cba91fd1319d"
url: "https://pub.flutter-io.cn"
source: hosted
version: "4.11.1"
collection:
dependency: transitive
description:
@ -41,6 +145,22 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.19.1"
convert:
dependency: transitive
description:
name: convert
sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.1.2"
crypto:
dependency: transitive
description:
name: crypto
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.0.7"
cupertino_icons:
dependency: "direct main"
description:
@ -49,6 +169,38 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.0.9"
dart_style:
dependency: transitive
description:
name: dart_style
sha256: "8a0e5fba27e8ee025d2ffb4ee820b4e6e2cf5e4246a6b1a477eb66866947e0bb"
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.1.1"
dio:
dependency: "direct main"
description:
name: dio
sha256: aff32c08f92787a557dd5c0145ac91536481831a01b4648136373cddb0e64f8c
url: "https://pub.flutter-io.cn"
source: hosted
version: "5.9.2"
dio_web_adapter:
dependency: transitive
description:
name: dio_web_adapter
sha256: "2f9e64323a7c3c7ef69567d5c800424a11f8337b8b228bad02524c9fb3c1f340"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.1.2"
equatable:
dependency: transitive
description:
name: equatable
sha256: "3e0141505477fd8ad55d6eb4e7776d3fe8430be8e497ccb1521370c3f21a3e2b"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.0.8"
fake_async:
dependency: transitive
description:
@ -57,6 +209,22 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.3.3"
file:
dependency: transitive
description:
name: file
sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
url: "https://pub.flutter-io.cn"
source: hosted
version: "7.0.1"
fixnum:
dependency: transitive
description:
name: fixnum
sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.1.1"
flutter:
dependency: "direct main"
description: flutter
@ -70,11 +238,128 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "5.0.0"
flutter_riverpod:
dependency: "direct main"
description:
name: flutter_riverpod
sha256: "9532ee6db4a943a1ed8383072a2e3eeda041db5657cdf6d2acecf3c21ecbe7e1"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.6.1"
flutter_test:
dependency: "direct dev"
description: flutter
source: sdk
version: "0.0.0"
flutter_web_plugins:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
freezed:
dependency: "direct dev"
description:
name: freezed
sha256: "59a584c24b3acdc5250bb856d0d3e9c0b798ed14a4af1ddb7dc1c7b41df91c9c"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.5.8"
freezed_annotation:
dependency: "direct main"
description:
name: freezed_annotation
sha256: c2e2d632dd9b8a2b7751117abcfc2b4888ecfe181bd9fca7170d9ef02e595fe2
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.4.4"
frontend_server_client:
dependency: transitive
description:
name: frontend_server_client
sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694
url: "https://pub.flutter-io.cn"
source: hosted
version: "4.0.0"
glob:
dependency: transitive
description:
name: glob
sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.1.3"
go_router:
dependency: "direct main"
description:
name: go_router
sha256: f02fd7d2a4dc512fec615529824fdd217fecb3a3d3de68360293a551f21634b3
url: "https://pub.flutter-io.cn"
source: hosted
version: "14.8.1"
graphs:
dependency: transitive
description:
name: graphs
sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.3.2"
http:
dependency: transitive
description:
name: http
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.6.0"
http_multi_server:
dependency: transitive
description:
name: http_multi_server
sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.2.2"
http_parser:
dependency: transitive
description:
name: http_parser
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
url: "https://pub.flutter-io.cn"
source: hosted
version: "4.1.2"
io:
dependency: transitive
description:
name: io
sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.0.5"
js:
dependency: transitive
description:
name: js
sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc"
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.7.2"
json_annotation:
dependency: "direct main"
description:
name: json_annotation
sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1"
url: "https://pub.flutter-io.cn"
source: hosted
version: "4.9.0"
json_serializable:
dependency: "direct dev"
description:
name: json_serializable
sha256: c50ef5fc083d5b5e12eef489503ba3bf5ccc899e487d691584699b4bdefeea8c
url: "https://pub.flutter-io.cn"
source: hosted
version: "6.9.5"
leak_tracker:
dependency: transitive
description:
@ -107,6 +392,14 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "5.1.1"
logging:
dependency: transitive
description:
name: logging
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.3.0"
matcher:
dependency: transitive
description:
@ -131,6 +424,30 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.16.0"
mime:
dependency: transitive
description:
name: mime
sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.0.0"
mocktail:
dependency: "direct dev"
description:
name: mocktail
sha256: "890df3f9688106f25755f26b1c60589a92b3ab91a22b8b224947ad041bf172d8"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.0.4"
package_config:
dependency: transitive
description:
name: package_config
sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.2.0"
path:
dependency: transitive
description:
@ -139,11 +456,83 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.9.1"
pool:
dependency: transitive
description:
name: pool
sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.5.2"
pub_semver:
dependency: transitive
description:
name: pub_semver
sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.2.0"
pubspec_parse:
dependency: transitive
description:
name: pubspec_parse
sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.5.0"
quiver:
dependency: transitive
description:
name: quiver
sha256: ea0b925899e64ecdfbf9c7becb60d5b50e706ade44a85b2363be2a22d88117d2
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.2.2"
riverpod:
dependency: transitive
description:
name: riverpod
sha256: "59062512288d3056b2321804332a13ffdd1bf16df70dcc8e506e411280a72959"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.6.1"
shelf:
dependency: transitive
description:
name: shelf
sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.4.2"
shelf_web_socket:
dependency: transitive
description:
name: shelf_web_socket
sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925"
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.0.0"
sky_engine:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
source_gen:
dependency: transitive
description:
name: source_gen
sha256: "35c8150ece9e8c8d263337a265153c3329667640850b9304861faea59fc98f6b"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.0.0"
source_helper:
dependency: transitive
description:
name: source_helper
sha256: a447acb083d3a5ef17f983dd36201aeea33fedadb3228fa831f2f0c92f0f3aca
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.3.7"
source_span:
dependency: transitive
description:
@ -160,6 +549,14 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.12.1"
state_notifier:
dependency: transitive
description:
name: state_notifier
sha256: b8677376aa54f2d7c58280d5a007f9e8774f1968d1fb1c096adcb4792fba29bb
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.0.0"
stream_channel:
dependency: transitive
description:
@ -168,6 +565,14 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.1.4"
stream_transform:
dependency: transitive
description:
name: stream_transform
sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.1.1"
string_scanner:
dependency: transitive
description:
@ -192,6 +597,22 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.7.6"
timing:
dependency: transitive
description:
name: timing
sha256: "62ee18aca144e4a9f29d212f5a4c6a053be252b895ab14b5821996cff4ed90fe"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.0.2"
typed_data:
dependency: transitive
description:
name: typed_data
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.4.0"
vector_math:
dependency: transitive
description:
@ -208,6 +629,62 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "15.0.2"
watcher:
dependency: transitive
description:
name: watcher
sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.2.1"
web:
dependency: transitive
description:
name: web
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.1.1"
web_socket:
dependency: transitive
description:
name: web_socket
sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.0.1"
web_socket_channel:
dependency: "direct main"
description:
name: web_socket_channel
sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.0.3"
xterm:
dependency: "direct main"
description:
name: xterm
sha256: "168dfedca77cba33fdb6f52e2cd001e9fde216e398e89335c19b524bb22da3a2"
url: "https://pub.flutter-io.cn"
source: hosted
version: "4.0.0"
yaml:
dependency: transitive
description:
name: yaml
sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.1.3"
zmodem:
dependency: transitive
description:
name: zmodem
sha256: "3b7e5b29f3a7d8aee472029b05165a68438eff2f3f7766edf13daba1e297adbf"
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.0.6"
sdks:
dart: ">=3.9.2 <4.0.0"
flutter: ">=3.18.0-18.0.pre.54"
flutter: ">=3.22.0"

View File

@ -30,20 +30,22 @@ environment:
dependencies:
flutter:
sdk: flutter
# The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons.
flutter_riverpod: ^2.6.1
go_router: ^14.8.1
dio: ^5.8.0+1
web_socket_channel: ^3.0.3
freezed_annotation: ^2.4.4
json_annotation: ^4.9.0
xterm: ^4.0.0
cupertino_icons: ^1.0.8
dev_dependencies:
flutter_test:
sdk: flutter
# The "flutter_lints" package below contains a set of recommended lints to
# encourage good coding practices. The lint set provided by the package is
# activated in the `analysis_options.yaml` file located at the root of your
# package. See that file for information about deactivating specific lint
# rules and activating additional ones.
build_runner: ^2.4.15
freezed: ^2.5.8
json_serializable: ^6.9.4
mocktail: ^1.0.4
flutter_lints: ^5.0.0
# For information on the generic Dart part of this file, see the

View File

@ -0,0 +1,144 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:dio/dio.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:term_remote_ctl/core/network/agent_api_client.dart';
void main() {
test('builds session list url from base uri', () {
final client = AgentApiClient(Uri.parse('https://host:9443'));
expect(client.sessionsUri.toString(), 'https://host:9443/api/sessions');
});
test('builds pairing urls from base uri', () {
final client = AgentApiClient(Uri.parse('https://host:9443'));
expect(client.pairingCodeUri.toString(), 'https://host:9443/api/pairing/code');
expect(
client.pairingRedeemUri.toString(),
'https://host:9443/api/pairing/redeem',
);
});
test('lists sessions from the sessions endpoint', () async {
final adapter = _FakeHttpClientAdapter(
jsonEncode([
{
'sessionId': 'abc',
'name': 'codex-main',
'status': 'idle',
},
{
'sessionId': 'def',
'name': 'cloud-code',
'status': 'active',
},
]),
);
final dio = Dio()..httpClientAdapter = adapter;
final client = AgentApiClient(Uri.parse('https://host:9443'), dio: dio);
final sessions = await client.listSessions();
expect(adapter.lastOptions?.method, 'GET');
expect(
adapter.lastOptions?.uri.toString(),
'https://host:9443/api/sessions',
);
expect(sessions, hasLength(2));
expect(sessions.first['name'], 'codex-main');
expect(sessions.last['status'], 'active');
});
test('creates a session with the provided name', () async {
final adapter = _FakeHttpClientAdapter(
jsonEncode({
'sessionId': 'abc',
'name': 'codex-main',
'status': 'idle',
}),
);
final dio = Dio()..httpClientAdapter = adapter;
final client = AgentApiClient(Uri.parse('https://host:9443'), dio: dio);
final session = await client.createSession('codex-main');
expect(adapter.lastOptions?.method, 'POST');
expect(
adapter.lastOptions?.uri.toString(),
'https://host:9443/api/sessions',
);
expect(adapter.lastOptions?.data, <String, String>{'name': 'codex-main'});
expect(session['sessionId'], 'abc');
expect(session['name'], 'codex-main');
});
test('posts pairing redeem payload to the redeem endpoint', () async {
final adapter = _FakeHttpClientAdapter();
final dio = Dio()..httpClientAdapter = adapter;
final client = AgentApiClient(Uri.parse('https://host:9443'), dio: dio);
await client.redeemPairingCode(code: '123456', deviceName: 'tablet');
expect(adapter.lastOptions?.method, 'POST');
expect(
adapter.lastOptions?.uri.toString(),
'https://host:9443/api/pairing/redeem',
);
expect(
adapter.lastOptions?.data,
<String, String>{'code': '123456', 'deviceName': 'tablet'},
);
});
test('fetches session history from the history endpoint', () async {
final adapter = _FakeHttpClientAdapter(
jsonEncode({
'sessionId': 'abc',
'lines': ['one', 'two'],
'hasMoreAbove': true,
}),
);
final dio = Dio()..httpClientAdapter = adapter;
final client = AgentApiClient(Uri.parse('https://host:9443'), dio: dio);
final history = await client.getSessionHistory('abc', lineCount: 2);
expect(adapter.lastOptions?.method, 'GET');
expect(
adapter.lastOptions?.uri.toString(),
'https://host:9443/api/sessions/abc/history?lineCount=2',
);
expect(history['sessionId'], 'abc');
expect(history['hasMoreAbove'], isTrue);
});
}
class _FakeHttpClientAdapter implements HttpClientAdapter {
_FakeHttpClientAdapter([this.responseBody = '']);
final String responseBody;
RequestOptions? lastOptions;
@override
Future<ResponseBody> fetch(
RequestOptions options,
Stream<Uint8List>? requestStream,
Future<void>? cancelFuture,
) async {
lastOptions = options;
return ResponseBody.fromString(
responseBody,
200,
headers: responseBody.isEmpty
? const <String, List<String>>{}
: <String, List<String>>{
Headers.contentTypeHeader: <String>[Headers.jsonContentType],
},
);
}
@override
void close({bool force = false}) {}
}

View File

@ -0,0 +1,57 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:term_remote_ctl/core/network/agent_api_client.dart';
import 'package:term_remote_ctl/core/network/agent_connection_providers.dart';
void main() {
test('uses the emulator-local http agent base uri by default', () {
final container = ProviderContainer();
addTearDown(container.dispose);
final baseUri = container.read(agentBaseUriProvider);
expect(baseUri.toString(), 'http://10.0.2.2:5067');
});
test('builds the agent client from the configured base uri', () {
final container = ProviderContainer(
overrides: [
agentBaseUriProvider.overrideWith((ref) {
return Uri.parse('https://host.example:9443');
}),
],
);
addTearDown(container.dispose);
final client = container.read(agentApiClientProvider);
expect(client.baseUri.toString(), 'https://host.example:9443');
});
test('session repository provider depends on the configured agent client', () async {
final client = _FakeAgentApiClient(Uri.parse('https://host.example:9443'));
final container = ProviderContainer(
overrides: [
agentApiClientProvider.overrideWith((ref) => client),
],
);
addTearDown(container.dispose);
final repository = container.read(sessionRepositoryProvider);
await repository.listSessions();
expect(client.listCalls, 1);
});
}
class _FakeAgentApiClient extends AgentApiClient {
_FakeAgentApiClient(Uri baseUri) : super(baseUri);
int listCalls = 0;
@override
Future<List<Map<String, dynamic>>> listSessions() async {
listCalls += 1;
return const [];
}
}

View File

@ -0,0 +1,37 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:term_remote_ctl/core/network/agent_socket_client.dart';
void main() {
test('builds terminal socket url with wss and session id', () {
final client = AgentSocketClient(Uri.parse('https://host:9443'));
expect(
client.buildTerminalSocketUri('session-123').toString(),
'wss://host:9443/ws/terminal?sessionId=session-123',
);
});
test('builds attach message for terminal sessions', () {
final client = AgentSocketClient(Uri.parse('https://host:9443'));
expect(
client.buildAttachMessage('session-123'),
<String, dynamic>{
'type': 'attach',
'sessionId': 'session-123',
},
);
});
test('builds input message for terminal input', () {
final client = AgentSocketClient(Uri.parse('https://host:9443'));
expect(
client.buildInputMessage('ls'),
<String, dynamic>{
'type': 'input',
'input': 'ls',
},
);
});
}

View File

@ -0,0 +1,57 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:term_remote_ctl/core/network/agent_api_client.dart';
import 'package:term_remote_ctl/features/pairing/pairing_controller.dart';
void main() {
test('redeemCode updates state on success', () async {
final client = _FakeAgentApiClient();
final controller = PairingController(client);
final future = controller.redeemCode('123456', 'tablet');
expect(controller.state, const AsyncLoading<void>());
await future;
expect(client.redeemCalls, 1);
expect(client.lastCode, '123456');
expect(client.lastDeviceName, 'tablet');
expect(controller.state, const AsyncData<void>(null));
});
test('redeemCode exposes api failures as AsyncError', () async {
final client = _FakeAgentApiClient(shouldThrow: true);
final controller = PairingController(client);
final future = controller.redeemCode('123456', 'tablet');
expect(controller.state, const AsyncLoading<void>());
await future;
expect(controller.state, isA<AsyncError<void>>());
});
}
class _FakeAgentApiClient extends AgentApiClient {
_FakeAgentApiClient({this.shouldThrow = false}) : super(Uri.parse('https://host:9443'));
final bool shouldThrow;
int redeemCalls = 0;
String? lastCode;
String? lastDeviceName;
@override
Future<void> redeemPairingCode({
required String code,
required String deviceName,
}) async {
redeemCalls += 1;
lastCode = code;
lastDeviceName = deviceName;
if (shouldThrow) {
throw StateError('boom');
}
}
}

View File

@ -0,0 +1,79 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:term_remote_ctl/core/network/agent_api_client.dart';
import 'package:term_remote_ctl/features/sessions/session.dart';
import 'package:term_remote_ctl/features/sessions/session_repository.dart';
void main() {
test('lists sessions from the agent and maps them to models', () async {
final client = _FakeAgentApiClient(
sessions: const [
<String, dynamic>{
'sessionId': 'abc',
'name': 'codex-main',
'status': 'idle',
},
<String, dynamic>{
'sessionId': 'def',
'name': 'cloud-code',
'status': 'active',
},
],
);
final repository = SessionRepository(client);
final sessions = await repository.listSessions();
expect(client.listCalls, 1);
expect(sessions, hasLength(2));
expect(sessions.first.sessionId, 'abc');
expect(sessions.last.name, 'cloud-code');
});
test('creates a session through the agent and maps the response', () async {
final client = _FakeAgentApiClient(
createdSession: const <String, dynamic>{
'sessionId': 'xyz',
'name': 'new-session',
'status': 'idle',
},
);
final repository = SessionRepository(client);
final session = await repository.createSession('new-session');
expect(client.lastCreatedName, 'new-session');
expect(session, isA<Session>());
expect(session.sessionId, 'xyz');
expect(session.name, 'new-session');
});
}
class _FakeAgentApiClient extends AgentApiClient {
_FakeAgentApiClient({
this.sessions = const [],
this.createdSession,
}) : super(Uri.parse('https://host:9443'));
final List<Map<String, dynamic>> sessions;
final Map<String, dynamic>? createdSession;
int listCalls = 0;
String? lastCreatedName;
@override
Future<List<Map<String, dynamic>>> listSessions() async {
listCalls += 1;
return sessions;
}
@override
Future<Map<String, dynamic>> createSession(String name) async {
lastCreatedName = name;
return createdSession ??
<String, dynamic>{
'sessionId': 'generated',
'name': name,
'status': 'idle',
};
}
}

View File

@ -0,0 +1,26 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:term_remote_ctl/features/terminal/terminal_controller.dart';
import 'package:term_remote_ctl/features/terminal/history_window.dart';
void main() {
test('entering scrollback keeps live follow disabled after new output', () {
final controller = TerminalController();
controller.enterScrollback();
controller.applyFrame('new output');
expect(controller.isFollowingLiveOutput, isFalse);
});
test('loading history seeds the scrollback window and visible lines', () {
final controller = TerminalController();
controller.loadHistory(
const HistoryWindow(lines: ['one', 'two'], hasMoreAbove: true),
);
expect(controller.historyWindow.lines, ['one', 'two']);
expect(controller.historyWindow.hasMoreAbove, isTrue);
expect(controller.liveLines, ['one', 'two']);
});
}

View File

@ -0,0 +1,51 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:term_remote_ctl/features/terminal/history_window.dart';
import 'package:term_remote_ctl/features/terminal/terminal_interaction_controller.dart';
void main() {
test('scrollback mode tracks pending live output until returning to live', () {
final controller = TerminalInteractionController();
controller.markConnected();
controller.enterScrollback();
controller.registerIncomingFrame();
expect(controller.isFollowingLiveOutput, isFalse);
expect(controller.hasPendingLiveOutput, isTrue);
controller.jumpToLive();
expect(controller.isFollowingLiveOutput, isTrue);
expect(controller.hasPendingLiveOutput, isFalse);
});
test('loadHistory replaces visible history and clears pending live output', () {
final controller = TerminalInteractionController();
controller.enterScrollback();
controller.registerIncomingFrame();
controller.loadHistory(
const HistoryWindow(lines: ['one', 'two'], hasMoreAbove: true),
);
expect(controller.historyWindow.lines, ['one', 'two']);
expect(controller.historyWindow.hasMoreAbove, isTrue);
expect(controller.liveLines, ['one', 'two']);
expect(controller.hasPendingLiveOutput, isFalse);
});
test('connection state controls whether input can be sent', () {
final controller = TerminalInteractionController();
expect(controller.canSendInput, isFalse);
controller.markConnecting();
expect(controller.canSendInput, isFalse);
controller.markConnected();
expect(controller.canSendInput, isTrue);
controller.markReconnecting();
expect(controller.canSendInput, isFalse);
});
}

View File

@ -0,0 +1,247 @@
import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:term_remote_ctl/core/network/agent_api_client.dart';
import 'package:term_remote_ctl/core/network/agent_socket_client.dart';
import 'package:term_remote_ctl/features/sessions/session.dart';
import 'package:term_remote_ctl/features/terminal/terminal_interaction_controller.dart';
import 'package:term_remote_ctl/features/terminal/terminal_session_coordinator.dart';
import 'package:term_remote_ctl/features/terminal/terminal_socket_session.dart';
void main() {
test('start stays connecting until attach completes and then sends resize', () async {
final controller = TerminalInteractionController();
final apiClient = _FakeAgentApiClient();
final sessionFactory = _FakeTerminalSessionFactory(autoConnect: false);
final session = Session(sessionId: 'abc', name: 'codex-main', status: 'idle');
final coordinator = TerminalSessionCoordinator(
controller: controller,
apiClient: apiClient,
session: session,
sessionFactory: sessionFactory.create,
onFrame: (_) {},
viewportProvider: () => const TerminalViewport(columns: 132, rows: 40),
);
final startFuture = coordinator.start();
await Future<void>.delayed(Duration.zero);
expect(controller.connectionState, TerminalConnectionState.connecting);
expect(sessionFactory.createdSessions.single.resizeCalls, isEmpty);
sessionFactory.createdSessions.single.completeConnect();
await startFuture;
expect(controller.connectionState, TerminalConnectionState.connected);
expect(sessionFactory.createdSessions.single.resizeCalls, const [
[132, 40],
]);
});
test('disconnected session schedules reconnect and reconnects when triggered', () async {
final controller = TerminalInteractionController();
final apiClient = _FakeAgentApiClient();
final sessionFactory = _FakeTerminalSessionFactory();
final reconnectScheduler = _FakeReconnectScheduler();
final session = Session(sessionId: 'abc', name: 'codex-main', status: 'idle');
final coordinator = TerminalSessionCoordinator(
controller: controller,
apiClient: apiClient,
session: session,
sessionFactory: sessionFactory.create,
onFrame: (_) {},
viewportProvider: () => const TerminalViewport(columns: 80, rows: 24),
reconnectScheduler: reconnectScheduler.schedule,
);
await coordinator.start();
expect(sessionFactory.createdSessions, hasLength(1));
sessionFactory.createdSessions.single.disconnect();
expect(controller.connectionState, TerminalConnectionState.reconnecting);
expect(sessionFactory.createdSessions, hasLength(1));
expect(reconnectScheduler.pendingCallback, isNotNull);
await reconnectScheduler.runPending();
expect(sessionFactory.createdSessions, hasLength(2));
expect(controller.connectionState, TerminalConnectionState.connected);
});
test('loadOlderHistory increases the requested history window size', () async {
final controller = TerminalInteractionController();
final apiClient = _FakeAgentApiClient(
responses: [
<String, dynamic>{
'sessionId': 'abc',
'lines': <String>['one', 'two'],
'hasMoreAbove': true,
},
<String, dynamic>{
'sessionId': 'abc',
'lines': <String>['zero', 'one', 'two'],
'hasMoreAbove': false,
},
],
);
final sessionFactory = _FakeTerminalSessionFactory();
final session = Session(sessionId: 'abc', name: 'codex-main', status: 'idle');
final coordinator = TerminalSessionCoordinator(
controller: controller,
apiClient: apiClient,
session: session,
sessionFactory: sessionFactory.create,
onFrame: (_) {},
viewportProvider: () => const TerminalViewport(columns: 80, rows: 24),
);
await coordinator.start();
await coordinator.loadOlderHistory();
expect(apiClient.requestedLineCounts, [1000, 2000]);
expect(controller.historyWindow.lines, ['zero', 'one', 'two']);
expect(controller.historyWindow.hasMoreAbove, isFalse);
});
test('incoming frames while browsing history flag pending live output', () async {
final controller = TerminalInteractionController();
final apiClient = _FakeAgentApiClient();
final sessionFactory = _FakeTerminalSessionFactory();
final session = Session(sessionId: 'abc', name: 'codex-main', status: 'idle');
final receivedFrames = <String>[];
final coordinator = TerminalSessionCoordinator(
controller: controller,
apiClient: apiClient,
session: session,
sessionFactory: sessionFactory.create,
onFrame: receivedFrames.add,
viewportProvider: () => const TerminalViewport(columns: 80, rows: 24),
);
await coordinator.start();
controller.enterScrollback();
sessionFactory.createdSessions.single.emitFrame('next-line');
expect(receivedFrames, ['next-line']);
expect(controller.hasPendingLiveOutput, isTrue);
expect(controller.liveLines, contains('next-line'));
});
}
class _FakeAgentApiClient extends AgentApiClient {
_FakeAgentApiClient({List<Map<String, dynamic>>? responses})
: _responses = responses ??
[
<String, dynamic>{
'sessionId': 'abc',
'lines': <String>['one', 'two'],
'hasMoreAbove': true,
},
],
super(Uri.parse('https://host:9443'));
final List<Map<String, dynamic>> _responses;
final requestedLineCounts = <int>[];
var _index = 0;
@override
Future<Map<String, dynamic>> getSessionHistory(
String sessionId, {
int lineCount = 200,
}) async {
requestedLineCounts.add(lineCount);
final response = _responses[_index];
if (_index < _responses.length - 1) {
_index += 1;
}
return response;
}
}
class _FakeTerminalSessionFactory {
_FakeTerminalSessionFactory({this.autoConnect = true});
final bool autoConnect;
final createdSessions = <_FakeTerminalSocketSession>[];
TerminalSocketSession create({
required Uri baseUri,
required Session session,
}) {
final fake = _FakeTerminalSocketSession(autoConnect: autoConnect);
createdSessions.add(fake);
return fake;
}
}
class _FakeTerminalSocketSession extends TerminalSocketSession {
_FakeTerminalSocketSession({required this.autoConnect})
: super(
sessionId: 'abc',
socketClient: _FakeAgentSocketClient(),
);
final bool autoConnect;
final resizeCalls = <List<int>>[];
Completer<void> _connectCompleter = Completer<void>();
void Function(String frame)? _onFrame;
void Function()? _onDisconnected;
@override
Future<void> connect({
required void Function(String frame) onFrame,
void Function()? onDisconnected,
}) {
_onFrame = onFrame;
_onDisconnected = onDisconnected;
if (autoConnect && !_connectCompleter.isCompleted) {
_connectCompleter.complete();
}
return _connectCompleter.future;
}
@override
void sendResize(int columns, int rows) {
resizeCalls.add([columns, rows]);
}
void completeConnect() {
if (!_connectCompleter.isCompleted) {
_connectCompleter.complete();
}
}
void emitFrame(String frame) {
_onFrame?.call(frame);
}
void disconnect() {
_onDisconnected?.call();
}
}
class _FakeAgentSocketClient extends AgentSocketClient {
_FakeAgentSocketClient() : super(Uri.parse('https://host:9443'));
}
class _FakeReconnectScheduler {
Future<void> Function()? pendingCallback;
CancelReconnect schedule(Duration _, Future<void> Function() callback) {
pendingCallback = callback;
return () {
pendingCallback = null;
};
}
Future<void> runPending() async {
final callback = pendingCallback;
pendingCallback = null;
if (callback != null) {
await callback();
}
}
}

View File

@ -0,0 +1,148 @@
import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:term_remote_ctl/core/network/agent_socket_client.dart';
import 'package:term_remote_ctl/features/terminal/terminal_socket_session.dart';
void main() {
test('connect waits for attach acknowledgement before completing', () async {
final transport = _FakeTerminalSocketTransport();
final session = TerminalSocketSession(
sessionId: 'session-123',
socketClient: AgentSocketClient(Uri.parse('https://host:9443')),
transportFactory: (_) => transport,
);
final frames = <String>[];
var completed = false;
final connectFuture = session.connect(onFrame: frames.add).then((_) {
completed = true;
});
await Future<void>.delayed(Duration.zero);
expect(
transport.sentMessages.first,
'{"type":"attach","sessionId":"session-123"}',
);
expect(completed, isFalse);
transport.emit('{"type":"attached","sessionId":"session-123"}');
await Future<void>.delayed(Duration.zero);
await connectFuture;
expect(completed, isTrue);
expect(frames, isEmpty);
transport.emit('abc');
await Future<void>.delayed(Duration.zero);
expect(frames, ['abc']);
await session.dispose();
});
test('connect fails if the socket closes before attach acknowledgement', () async {
final transport = _FakeTerminalSocketTransport();
final session = TerminalSocketSession(
sessionId: 'session-123',
socketClient: AgentSocketClient(Uri.parse('https://host:9443')),
transportFactory: (_) => transport,
);
final connectFuture = session.connect(onFrame: (_) {});
await transport.close();
await expectLater(connectFuture, throwsStateError);
});
test('sendInput serializes the input message', () async {
final transport = _FakeTerminalSocketTransport();
final session = TerminalSocketSession(
sessionId: 'session-123',
socketClient: AgentSocketClient(Uri.parse('https://host:9443')),
transportFactory: (_) => transport,
);
final connectFuture = session.connect(onFrame: (_) {});
await Future<void>.delayed(Duration.zero);
transport.emit('{"type":"attached","sessionId":"session-123"}');
await connectFuture;
session.sendInput('dir\r');
expect(
transport.sentMessages,
contains('{"type":"input","input":"dir\\r"}'),
);
await session.dispose();
});
test('sendResize serializes the resize message', () async {
final transport = _FakeTerminalSocketTransport();
final session = TerminalSocketSession(
sessionId: 'session-123',
socketClient: AgentSocketClient(Uri.parse('https://host:9443')),
transportFactory: (_) => transport,
);
final connectFuture = session.connect(onFrame: (_) {});
await Future<void>.delayed(Duration.zero);
transport.emit('{"type":"attached","sessionId":"session-123"}');
await connectFuture;
session.sendResize(132, 40);
expect(
transport.sentMessages,
contains('{"type":"resize","columns":132,"rows":40}'),
);
await session.dispose();
});
test('connect notifies when an attached socket closes', () async {
final transport = _FakeTerminalSocketTransport();
final session = TerminalSocketSession(
sessionId: 'session-123',
socketClient: AgentSocketClient(Uri.parse('https://host:9443')),
transportFactory: (_) => transport,
);
var disconnectCount = 0;
final connectFuture = session.connect(
onFrame: (_) {},
onDisconnected: () {
disconnectCount += 1;
},
);
await Future<void>.delayed(Duration.zero);
transport.emit('{"type":"attached","sessionId":"session-123"}');
await connectFuture;
await transport.close();
await Future<void>.delayed(Duration.zero);
expect(disconnectCount, 1);
});
}
class _FakeTerminalSocketTransport implements TerminalSocketTransport {
final _incoming = StreamController<dynamic>.broadcast();
final sentMessages = <String>[];
@override
Stream<dynamic> get stream => _incoming.stream;
@override
void send(String message) {
sentMessages.add(message);
}
@override
Future<void> close() async {
await _incoming.close();
}
void emit(dynamic message) {
_incoming.add(message);
}
}

View File

@ -1,18 +1,550 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'dart:async';
import 'package:term_remote_ctl/main.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:term_remote_ctl/app/app.dart';
import 'package:term_remote_ctl/core/network/agent_api_client.dart';
import 'package:term_remote_ctl/core/network/agent_connection_providers.dart';
import 'package:term_remote_ctl/features/sessions/session.dart';
import 'package:term_remote_ctl/features/sessions/session_repository.dart';
import 'package:term_remote_ctl/features/terminal/terminal_socket_session.dart';
import 'package:xterm/xterm.dart';
void main() {
testWidgets('shows the TermRemoteCtl bootstrap shell', (tester) async {
await tester.pumpWidget(const TermRemoteCtlApp());
testWidgets('shows the sessions shell', (tester) async {
final repository = _FakeSessionRepository();
await tester.pumpWidget(
ProviderScope(
overrides: [
agentApiClientProvider.overrideWithValue(_FakeAgentApiClient()),
sessionRepositoryProvider.overrideWithValue(repository),
],
child: const TermRemoteCtlApp(),
),
);
await tester.pumpAndSettle();
final agentUrlField = tester.widget<TextField>(find.byType(TextField).first);
expect(find.text('Sessions'), findsOneWidget);
expect(find.text('Agent base URL'), findsOneWidget);
expect(agentUrlField.controller?.text, 'http://10.0.2.2:5067');
expect(agentUrlField.decoration?.hintText, 'http://10.0.2.2:5067');
expect(find.text('Session requests use this base origin: http://10.0.2.2:5067.'), findsOneWidget);
expect(find.text('codex-main'), findsOneWidget);
});
testWidgets('surfaces create-session errors in a snackbar', (tester) async {
final repository = _FakeSessionRepository(shouldThrowOnCreate: true);
await tester.pumpWidget(
ProviderScope(
overrides: [
agentBaseUriProvider.overrideWith((ref) {
return Uri.parse('https://host.example:9443');
}),
agentApiClientProvider.overrideWithValue(_FakeAgentApiClient()),
sessionRepositoryProvider.overrideWithValue(repository),
],
child: const TermRemoteCtlApp(),
),
);
await tester.pumpAndSettle();
await tester.tap(find.byIcon(Icons.add));
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField).last, 'broken-session');
await tester.tap(find.text('Create'));
await tester.pumpAndSettle();
expect(find.textContaining('Failed to create session'), findsOneWidget);
});
testWidgets('tapping a session opens the terminal page', (tester) async {
final repository = _FakeSessionRepository();
final socketFactory = TerminalSocketSessionFactory(
transportFactory: (_) => _FakeTerminalSocketTransport(autoAttach: true),
);
await tester.pumpWidget(
ProviderScope(
overrides: [
agentBaseUriProvider.overrideWith((ref) {
return Uri.parse('https://host.example:9443');
}),
agentApiClientProvider.overrideWithValue(_FakeAgentApiClient()),
sessionRepositoryProvider.overrideWithValue(repository),
terminalSocketSessionFactoryProvider.overrideWithValue(socketFactory),
],
child: const TermRemoteCtlApp(),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('codex-main'));
await tester.pumpAndSettle();
expect(find.text('Send input'), findsOneWidget);
expect(find.text('Attached to codex-main'), findsOneWidget);
expect(find.text('Live | 2 lines'), findsOneWidget);
expect(find.text('Terminal controls'), findsOneWidget);
expect(find.byKey(const Key('terminal_controls_panel')), findsOneWidget);
expect(find.text('Connected'), findsOneWidget);
expect(find.text('History ready'), findsOneWidget);
expect(find.text('Reconnect'), findsOneWidget);
expect(
find.text('Live terminal attach is minimal for now. Resize and reconnect are not implemented yet.'),
findsNothing,
);
});
testWidgets('terminal page reconnects after the socket closes', (
tester,
) async {
final repository = _FakeSessionRepository();
final transportFactory = _QueuedTerminalSocketTransportFactory();
final socketFactory = TerminalSocketSessionFactory(
transportFactory: transportFactory.create,
);
await tester.pumpWidget(
ProviderScope(
overrides: [
agentBaseUriProvider.overrideWith((ref) {
return Uri.parse('https://host.example:9443');
}),
agentApiClientProvider.overrideWithValue(_FakeAgentApiClient()),
sessionRepositoryProvider.overrideWithValue(repository),
terminalSocketSessionFactoryProvider.overrideWithValue(socketFactory),
],
child: const TermRemoteCtlApp(),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('codex-main'));
await tester.pumpAndSettle();
expect(find.text('Attached to codex-main'), findsOneWidget);
expect(transportFactory.createCount, 1);
final firstTransport = transportFactory.createdTransports.first;
await firstTransport.close();
await tester.pump();
expect(find.text('TermRemoteCtl'), findsOneWidget);
expect(
find.text('Bootstrap shell ready for the mobile client.'),
findsOneWidget,
expect(find.text('Reconnecting'), findsOneWidget);
final textField = tester.widget<TextField>(find.byType(TextField).last);
final sendButton = tester.widget<FilledButton>(find.widgetWithText(FilledButton, 'Send'));
expect(textField.enabled, isFalse);
expect(sendButton.onPressed, isNull);
await tester.pump(const Duration(seconds: 2));
await tester.pumpAndSettle();
expect(transportFactory.createCount, 2);
expect(find.text('Attached to codex-main'), findsOneWidget);
final reconnectedField = tester.widget<TextField>(find.byType(TextField).last);
final reconnectedButton = tester.widget<FilledButton>(find.widgetWithText(FilledButton, 'Send'));
expect(reconnectedField.enabled, isTrue);
expect(reconnectedButton.onPressed, isNotNull);
});
testWidgets('terminal page can send input after leaving and reopening a session', (
tester,
) async {
final repository = _FakeSessionRepository();
final transportFactory = _QueuedTerminalSocketTransportFactory();
final socketFactory = TerminalSocketSessionFactory(
transportFactory: transportFactory.create,
);
expect(find.byIcon(Icons.computer), findsOneWidget);
await tester.pumpWidget(
ProviderScope(
overrides: [
agentBaseUriProvider.overrideWith((ref) {
return Uri.parse('https://host.example:9443');
}),
agentApiClientProvider.overrideWithValue(_FakeAgentApiClient()),
sessionRepositoryProvider.overrideWithValue(repository),
terminalSocketSessionFactoryProvider.overrideWithValue(socketFactory),
],
child: const TermRemoteCtlApp(),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('codex-main'));
await tester.pumpAndSettle();
expect(transportFactory.createCount, 1);
await tester.pageBack();
await tester.pumpAndSettle();
await tester.tap(find.text('codex-main'));
await tester.pumpAndSettle();
expect(transportFactory.createCount, 2);
expect(find.text('Attached to codex-main'), findsOneWidget);
await tester.enterText(find.byType(TextField).last, 'dir');
await tester.tap(find.widgetWithText(FilledButton, 'Send'));
await tester.pumpAndSettle();
expect(
transportFactory.createdTransports.last.sentMessages,
contains('{"type":"input","input":"dir\\r"}'),
);
});
testWidgets('terminal view accepts keyboard input after leaving and reopening a session', (
tester,
) async {
final repository = _FakeSessionRepository();
final transportFactory = _QueuedTerminalSocketTransportFactory();
final socketFactory = TerminalSocketSessionFactory(
transportFactory: transportFactory.create,
);
await tester.pumpWidget(
ProviderScope(
overrides: [
agentBaseUriProvider.overrideWith((ref) {
return Uri.parse('https://host.example:9443');
}),
agentApiClientProvider.overrideWithValue(_FakeAgentApiClient()),
sessionRepositoryProvider.overrideWithValue(repository),
terminalSocketSessionFactoryProvider.overrideWithValue(socketFactory),
],
child: const TermRemoteCtlApp(),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('codex-main'));
await tester.pumpAndSettle();
await tester.pageBack();
await tester.pumpAndSettle();
await tester.tap(find.text('codex-main'));
await tester.pumpAndSettle();
await tester.tap(find.byType(TerminalView));
await tester.pump(const Duration(seconds: 1));
tester.testTextInput.enterText('pwd');
await tester.idle();
expect(
transportFactory.createdTransports.last.sentMessages,
contains('{"type":"input","input":"pwd"}'),
);
});
testWidgets('terminal view regains keyboard input on reopen without an extra tap', (
tester,
) async {
final repository = _FakeSessionRepository();
final transportFactory = _QueuedTerminalSocketTransportFactory();
final socketFactory = TerminalSocketSessionFactory(
transportFactory: transportFactory.create,
);
await tester.pumpWidget(
ProviderScope(
overrides: [
agentBaseUriProvider.overrideWith((ref) {
return Uri.parse('https://host.example:9443');
}),
agentApiClientProvider.overrideWithValue(_FakeAgentApiClient()),
sessionRepositoryProvider.overrideWithValue(repository),
terminalSocketSessionFactoryProvider.overrideWithValue(socketFactory),
],
child: const TermRemoteCtlApp(),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('codex-main'));
await tester.pumpAndSettle();
await tester.pageBack();
await tester.pumpAndSettle();
await tester.tap(find.text('codex-main'));
await tester.pumpAndSettle();
tester.testTextInput.enterText('whoami');
await tester.idle();
expect(
transportFactory.createdTransports.last.sentMessages,
contains('{"type":"input","input":"whoami"}'),
);
});
testWidgets('terminal page lets the user return to live mode', (
tester,
) async {
final repository = _FakeSessionRepository();
final socketFactory = TerminalSocketSessionFactory(
transportFactory: (_) => _FakeTerminalSocketTransport(autoAttach: true),
);
await tester.pumpWidget(
ProviderScope(
overrides: [
agentBaseUriProvider.overrideWith((ref) {
return Uri.parse('https://host.example:9443');
}),
agentApiClientProvider.overrideWithValue(_FakeAgentApiClient()),
sessionRepositoryProvider.overrideWithValue(repository),
terminalSocketSessionFactoryProvider.overrideWithValue(socketFactory),
],
child: const TermRemoteCtlApp(),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('codex-main'));
await tester.pumpAndSettle();
expect(find.text('Back to live'), findsNothing);
await tester.tap(find.text('Live | 2 lines'));
await tester.pumpAndSettle();
expect(find.text('Back to live'), findsOneWidget);
expect(find.text('Scrollback | 2 lines'), findsOneWidget);
expect(find.text('Recent scrollback'), findsOneWidget);
expect(find.text('Browsing history. Live output is still arriving.'), findsOneWidget);
expect(find.text('one'), findsOneWidget);
expect(find.text('two'), findsOneWidget);
expect(find.byKey(const Key('terminal_scrollback_list')), findsOneWidget);
expect(find.byKey(const Key('terminal_scrollback_actions')), findsOneWidget);
expect(find.text('Recent history is loaded. Older lines are not loaded yet.'), findsOneWidget);
expect(find.text('Load older lines'), findsOneWidget);
await tester.tap(find.text('Back to live'));
await tester.pumpAndSettle();
expect(find.text('Back to live'), findsNothing);
expect(find.text('Live | 2 lines'), findsOneWidget);
expect(find.text('Recent scrollback'), findsNothing);
expect(find.text('Browsing history. Live output is still arriving.'), findsNothing);
});
testWidgets('terminal page loads older scrollback lines on demand', (
tester,
) async {
final repository = _FakeSessionRepository();
final socketFactory = TerminalSocketSessionFactory(
transportFactory: (_) => _FakeTerminalSocketTransport(autoAttach: true),
);
final apiClient = _SequencedHistoryAgentApiClient(
responses: [
<String, dynamic>{
'sessionId': 'abc',
'lines': <String>['one', 'two'],
'hasMoreAbove': true,
},
<String, dynamic>{
'sessionId': 'abc',
'lines': <String>['zero', 'one', 'two'],
'hasMoreAbove': false,
},
],
);
await tester.pumpWidget(
ProviderScope(
overrides: [
agentBaseUriProvider.overrideWith((ref) {
return Uri.parse('https://host.example:9443');
}),
agentApiClientProvider.overrideWithValue(apiClient),
sessionRepositoryProvider.overrideWithValue(repository),
terminalSocketSessionFactoryProvider.overrideWithValue(socketFactory),
],
child: const TermRemoteCtlApp(),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('codex-main'));
await tester.pumpAndSettle();
await tester.tap(find.text('Live | 2 lines'));
await tester.pumpAndSettle();
expect(find.text('Load older lines'), findsOneWidget);
await tester.tap(find.text('Load older lines'));
await tester.pump();
await tester.pumpAndSettle();
expect(apiClient.requestedLineCounts, [1000, 2000]);
expect(find.text('Scrollback | 3 lines'), findsOneWidget);
expect(find.text('3 lines loaded'), findsOneWidget);
expect(find.text('zero'), findsOneWidget);
expect(find.text('Load older lines'), findsNothing);
expect(
find.text('Recent history is loaded. Older lines are not loaded yet.'),
findsNothing,
);
});
testWidgets('terminal page surfaces new live output while browsing history', (
tester,
) async {
final repository = _FakeSessionRepository();
final transport = _FakeTerminalSocketTransport(autoAttach: true);
final socketFactory = TerminalSocketSessionFactory(
transportFactory: (_) => transport,
);
await tester.pumpWidget(
ProviderScope(
overrides: [
agentBaseUriProvider.overrideWith((ref) {
return Uri.parse('https://host.example:9443');
}),
agentApiClientProvider.overrideWithValue(_FakeAgentApiClient()),
sessionRepositoryProvider.overrideWithValue(repository),
terminalSocketSessionFactoryProvider.overrideWithValue(socketFactory),
],
child: const TermRemoteCtlApp(),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('codex-main'));
await tester.pumpAndSettle();
await tester.tap(find.text('Live | 2 lines'));
await tester.pumpAndSettle();
expect(find.text('New output available'), findsNothing);
transport.emit('next-line');
await tester.pumpAndSettle();
expect(find.text('New output available'), findsOneWidget);
await tester.tap(find.text('New output available'));
await tester.pumpAndSettle();
expect(find.text('Back to live'), findsNothing);
expect(find.text('Live | 3 lines'), findsOneWidget);
expect(find.text('New output available'), findsNothing);
});
}
class _FakeSessionRepository extends SessionRepository {
_FakeSessionRepository({this.shouldThrowOnCreate = false})
: _sessions = [
Session(
sessionId: 'abc',
name: 'codex-main',
status: 'idle',
),
],
super(_FakeAgentApiClient());
final List<Session> _sessions;
final bool shouldThrowOnCreate;
@override
Future<List<Session>> listSessions() async {
return List<Session>.of(_sessions);
}
@override
Future<Session> createSession(String name) async {
if (shouldThrowOnCreate) {
throw StateError('boom');
}
return Session(sessionId: 'created', name: name, status: 'idle');
}
}
class _FakeAgentApiClient extends AgentApiClient {
_FakeAgentApiClient() : super(Uri.parse('https://host:9443'));
@override
Future<Map<String, dynamic>> getSessionHistory(
String sessionId, {
int lineCount = 200,
}) async {
return <String, dynamic>{
'sessionId': sessionId,
'lines': <String>['one', 'two'],
'hasMoreAbove': true,
};
}
}
class _SequencedHistoryAgentApiClient extends _FakeAgentApiClient {
_SequencedHistoryAgentApiClient({required List<Map<String, dynamic>> responses})
: _responses = responses;
final List<Map<String, dynamic>> _responses;
final requestedLineCounts = <int>[];
var _index = 0;
@override
Future<Map<String, dynamic>> getSessionHistory(
String sessionId, {
int lineCount = 200,
}) async {
requestedLineCounts.add(lineCount);
final response = _responses[_index];
if (_index < _responses.length - 1) {
_index += 1;
}
return response;
}
}
class _FakeTerminalSocketTransport implements TerminalSocketTransport {
_FakeTerminalSocketTransport({this.autoAttach = false}) {
if (autoAttach) {
Future<void>.microtask(() {
emit('{"type":"attached","sessionId":"abc"}');
});
}
}
final bool autoAttach;
final _incoming = StreamController<dynamic>.broadcast();
final sentMessages = <String>[];
@override
Stream<dynamic> get stream => _incoming.stream;
@override
void send(String message) {
sentMessages.add(message);
}
@override
Future<void> close() async {
await _incoming.close();
}
void emit(String message) {
_incoming.add(message);
}
}
class _QueuedTerminalSocketTransportFactory {
final createdTransports = <_FakeTerminalSocketTransport>[];
var createCount = 0;
TerminalSocketTransport create(Uri _) {
final transport = _FakeTerminalSocketTransport(autoAttach: true);
createdTransports.add(transport);
createCount += 1;
return transport;
}
}

View File

@ -7,6 +7,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{762BA82A-FFA
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TermRemoteCtl.Agent", "src\TermRemoteCtl.Agent\TermRemoteCtl.Agent.csproj", "{51E4993A-E415-4425-B6B8-9761B2209A5B}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TermRemoteCtl.ConPtyHelper", "src\TermRemoteCtl.ConPtyHelper\TermRemoteCtl.ConPtyHelper.csproj", "{5BA37FEB-AB49-4F19-93C5-E8B8B1254DBA}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{ED0BB4F4-E9D8-49A4-A344-773EA12C5970}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TermRemoteCtl.Agent.Tests", "tests\TermRemoteCtl.Agent.Tests\TermRemoteCtl.Agent.Tests.csproj", "{CD81CCA7-FD15-4F13-85D3-89FFE0F0E62F}"
@ -26,6 +28,10 @@ Global
{51E4993A-E415-4425-B6B8-9761B2209A5B}.Debug|Any CPU.Build.0 = Debug|Any CPU
{51E4993A-E415-4425-B6B8-9761B2209A5B}.Release|Any CPU.ActiveCfg = Release|Any CPU
{51E4993A-E415-4425-B6B8-9761B2209A5B}.Release|Any CPU.Build.0 = Release|Any CPU
{5BA37FEB-AB49-4F19-93C5-E8B8B1254DBA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{5BA37FEB-AB49-4F19-93C5-E8B8B1254DBA}.Debug|Any CPU.Build.0 = Debug|Any CPU
{5BA37FEB-AB49-4F19-93C5-E8B8B1254DBA}.Release|Any CPU.ActiveCfg = Release|Any CPU
{5BA37FEB-AB49-4F19-93C5-E8B8B1254DBA}.Release|Any CPU.Build.0 = Release|Any CPU
{CD81CCA7-FD15-4F13-85D3-89FFE0F0E62F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{CD81CCA7-FD15-4F13-85D3-89FFE0F0E62F}.Debug|Any CPU.Build.0 = Debug|Any CPU
{CD81CCA7-FD15-4F13-85D3-89FFE0F0E62F}.Release|Any CPU.ActiveCfg = Release|Any CPU
@ -37,6 +43,7 @@ Global
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{51E4993A-E415-4425-B6B8-9761B2209A5B} = {762BA82A-FFA7-43DE-A006-811AC3BCE95E}
{5BA37FEB-AB49-4F19-93C5-E8B8B1254DBA} = {762BA82A-FFA7-43DE-A006-811AC3BCE95E}
{CD81CCA7-FD15-4F13-85D3-89FFE0F0E62F} = {ED0BB4F4-E9D8-49A4-A344-773EA12C5970}
{2C37B937-C6AC-401D-9005-9C34A52F3A89} = {ED0BB4F4-E9D8-49A4-A344-773EA12C5970}
EndGlobalSection

View File

@ -12,6 +12,20 @@ public static class SessionEndpoints
var group = endpoints.MapGroup("/api/sessions");
group.MapGet(string.Empty, (SessionRegistry registry) => Results.Ok(registry.List()));
group.MapGet("/{sessionId}/history", (
string sessionId,
int? lineCount,
SessionRegistry registry) =>
{
try
{
return Results.Ok(registry.GetHistory(sessionId, lineCount ?? 200));
}
catch (KeyNotFoundException)
{
return Results.NotFound();
}
});
group.MapPost(string.Empty, async (
HttpRequest httpRequest,

View File

@ -7,18 +7,40 @@ public static class AgentEndpointConfiguration
{
public static Uri BuildListenUri(AgentOptions options)
{
return new UriBuilder(Uri.UriSchemeHttps, options.BindAddress, options.HttpsPort).Uri;
if (options.HasHttpsEndpoint)
{
return new UriBuilder(Uri.UriSchemeHttps, options.BindAddress, options.HttpsPort).Uri;
}
return new UriBuilder(Uri.UriSchemeHttp, options.BindAddress, options.HttpPort).Uri;
}
public static void ConfigureHttpsEndpoint(KestrelServerOptions kestrel, AgentOptions options)
{
if (string.Equals(options.BindAddress, "localhost", StringComparison.OrdinalIgnoreCase))
{
kestrel.ListenLocalhost(options.HttpsPort, listenOptions => listenOptions.UseHttps());
if (options.HasHttpsEndpoint)
{
kestrel.ListenLocalhost(options.HttpsPort, listenOptions => listenOptions.UseHttps());
}
if (options.HasHttpEndpoint)
{
kestrel.ListenLocalhost(options.HttpPort);
}
return;
}
var address = IPAddress.Parse(options.BindAddress);
kestrel.Listen(address, options.HttpsPort, listenOptions => listenOptions.UseHttps());
if (options.HasHttpsEndpoint)
{
kestrel.Listen(address, options.HttpsPort, listenOptions => listenOptions.UseHttps());
}
if (options.HasHttpEndpoint)
{
kestrel.Listen(address, options.HttpPort);
}
}
}

View File

@ -10,7 +10,13 @@ public sealed class AgentOptions
public int HttpsPort { get; set; }
public int HttpPort { get; set; }
public int WebSocketFrameFlushMilliseconds { get; set; }
public int RingBufferLineLimit { get; set; }
public bool HasHttpsEndpoint => HttpsPort > 0;
public bool HasHttpEndpoint => HttpPort > 0;
}

View File

@ -18,6 +18,7 @@ public static class AgentOptionsServiceCollectionExtensions
options.DataRoot = effectiveOptions.DataRoot;
options.BindAddress = effectiveOptions.BindAddress;
options.HttpsPort = effectiveOptions.HttpsPort;
options.HttpPort = effectiveOptions.HttpPort;
options.WebSocketFrameFlushMilliseconds = effectiveOptions.WebSocketFrameFlushMilliseconds;
options.RingBufferLineLimit = effectiveOptions.RingBufferLineLimit;
})

View File

@ -19,9 +19,24 @@ public sealed class AgentOptionsValidator : IValidateOptions<AgentOptions>
failures.Add("Agent:BindAddress must be 'localhost' or an IP address literal.");
}
if (options.HttpsPort is < 1 or > 65535)
if (options.HttpsPort is < 0 or > 65535)
{
failures.Add("Agent:HttpsPort must be between 1 and 65535.");
failures.Add("Agent:HttpsPort must be 0 or between 1 and 65535.");
}
if (options.HttpPort is < 0 or > 65535)
{
failures.Add("Agent:HttpPort must be 0 or between 1 and 65535.");
}
if (!options.HasHttpsEndpoint && !options.HasHttpEndpoint)
{
failures.Add("Agent:HttpsPort or Agent:HttpPort must be configured.");
}
if (options.HasHttpsEndpoint && options.HasHttpEndpoint && options.HttpsPort == options.HttpPort)
{
failures.Add("Agent:HttpsPort and Agent:HttpPort must not be the same when both endpoints are enabled.");
}
if (options.WebSocketFrameFlushMilliseconds <= 0)

View File

@ -2,8 +2,10 @@ using Microsoft.Extensions.Options;
using TermRemoteCtl.Agent.Api;
using TermRemoteCtl.Agent.Configuration;
using TermRemoteCtl.Agent.History;
using TermRemoteCtl.Agent.Realtime;
using TermRemoteCtl.Agent.Security;
using TermRemoteCtl.Agent.Sessions;
using TermRemoteCtl.Agent.Terminal;
var builder = WebApplication.CreateBuilder(args);
@ -13,6 +15,8 @@ builder.Services.AddSingleton<PairingService>();
builder.Services.AddSingleton<TrustedDeviceStore>();
builder.Services.AddSingleton<AuditLog>();
builder.Services.AddSingleton<SessionRegistry>();
builder.Services.AddSingleton<IConPtySessionFactory, ConPtySessionFactory>();
builder.Services.AddSingleton<ISessionHost, PowerShellSessionHost>();
builder.Services.AddSingleton(serviceProvider =>
{
var options = serviceProvider.GetRequiredService<IOptions<AgentOptions>>().Value;
@ -32,6 +36,7 @@ Directory.CreateDirectory(agentOptions.DataRoot);
app.MapGet("/health", () => Results.Json(new { status = "ok" }));
app.MapPairingEndpoints();
app.MapSessionEndpoints();
app.MapTerminalSocket();
app.Run();

View File

@ -0,0 +1,6 @@
using System.Runtime.CompilerServices;
using System.Runtime.Versioning;
[assembly: SupportedOSPlatform("windows")]
[assembly: InternalsVisibleTo("TermRemoteCtl.Agent.Tests")]
[assembly: InternalsVisibleTo("TermRemoteCtl.Agent.IntegrationTests")]

View File

@ -0,0 +1,220 @@
using System.Net.WebSockets;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using TermRemoteCtl.Agent.Configuration;
using TermRemoteCtl.Agent.Sessions;
using TermRemoteCtl.Agent.Terminal;
namespace TermRemoteCtl.Agent.Realtime;
public static class TerminalWebSocketHandler
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
public static WebApplication MapTerminalSocket(this WebApplication app)
{
app.UseWebSockets();
app.Map("/ws/terminal", HandleTerminalSocketAsync);
return app;
}
private static async Task HandleTerminalSocketAsync(HttpContext context)
{
if (!context.WebSockets.IsWebSocketRequest)
{
context.Response.StatusCode = StatusCodes.Status400BadRequest;
return;
}
var sessionId = context.Request.Query["sessionId"].ToString();
if (string.IsNullOrWhiteSpace(sessionId))
{
context.Response.StatusCode = StatusCodes.Status400BadRequest;
return;
}
var registry = context.RequestServices.GetRequiredService<SessionRegistry>();
if (!registry.TryGet(sessionId, out _))
{
context.Response.StatusCode = StatusCodes.Status404NotFound;
return;
}
var host = context.RequestServices.GetRequiredService<ISessionHost>();
var options = context.RequestServices.GetRequiredService<IOptions<AgentOptions>>().Value;
using var socket = await context.WebSockets.AcceptWebSocketAsync().ConfigureAwait(false);
try
{
await host.StartAsync(sessionId, context.RequestAborted).ConfigureAwait(false);
}
catch (Exception ex)
{
if (socket.State == WebSocketState.Open)
{
await socket.CloseAsync(WebSocketCloseStatus.InternalServerError, ex.Message, context.RequestAborted).ConfigureAwait(false);
}
return;
}
using var sendGate = new SemaphoreSlim(1, 1);
await using var batcher = new TerminalFrameBatcher(
TimeSpan.FromMilliseconds(options.WebSocketFrameFlushMilliseconds),
chunk => SendTextAsync(socket, chunk, sendGate, context.RequestAborted));
void HandleOutput(object? sender, TerminalOutputEventArgs args)
{
if (string.Equals(args.SessionId, sessionId, StringComparison.Ordinal))
{
batcher.Append(args.Chunk);
}
}
host.OutputReceived += HandleOutput;
try
{
await SendJsonAsync(socket, new TerminalAttachResponse(sessionId), sendGate, context.RequestAborted).ConfigureAwait(false);
await ReceiveLoopAsync(context, socket, host, sessionId).ConfigureAwait(false);
}
finally
{
host.OutputReceived -= HandleOutput;
}
}
private static async Task ReceiveLoopAsync(
HttpContext context,
WebSocket socket,
ISessionHost host,
string sessionId)
{
var buffer = new byte[4096];
while (socket.State == WebSocketState.Open && !context.RequestAborted.IsCancellationRequested)
{
using var message = new MemoryStream();
WebSocketReceiveResult receiveResult;
do
{
receiveResult = await socket.ReceiveAsync(buffer, context.RequestAborted).ConfigureAwait(false);
if (receiveResult.MessageType == WebSocketMessageType.Close)
{
return;
}
message.Write(buffer, 0, receiveResult.Count);
}
while (!receiveResult.EndOfMessage);
if (receiveResult.MessageType != WebSocketMessageType.Text)
{
continue;
}
await HandleClientMessageAsync(
Encoding.UTF8.GetString(message.ToArray()),
host,
sessionId,
context.RequestAborted).ConfigureAwait(false);
}
}
private static async Task HandleClientMessageAsync(
string payload,
ISessionHost host,
string sessionId,
CancellationToken cancellationToken)
{
TerminalClientMessage? message;
try
{
message = JsonSerializer.Deserialize<TerminalClientMessage>(payload, JsonOptions);
}
catch (JsonException)
{
return;
}
if (message is null || !string.Equals(message.Type, "input", StringComparison.OrdinalIgnoreCase) && !string.Equals(message.Type, "resize", StringComparison.OrdinalIgnoreCase))
{
if (message is not null && string.Equals(message.Type, "attach", StringComparison.OrdinalIgnoreCase))
{
return;
}
return;
}
if (string.Equals(message.Type, "input", StringComparison.OrdinalIgnoreCase))
{
if (!string.IsNullOrEmpty(message.Input))
{
await host.WriteInputAsync(sessionId, message.Input, cancellationToken).ConfigureAwait(false);
}
return;
}
if (message.Columns is > 0 && message.Rows is > 0)
{
await host.ResizeAsync(sessionId, message.Columns.Value, message.Rows.Value, cancellationToken).ConfigureAwait(false);
}
}
private static async Task SendJsonAsync(
WebSocket socket,
TerminalAttachResponse response,
SemaphoreSlim sendGate,
CancellationToken cancellationToken)
{
var json = JsonSerializer.SerializeToUtf8Bytes(response, JsonOptions);
await SendAsync(socket, json, sendGate, cancellationToken).ConfigureAwait(false);
}
private static async Task SendTextAsync(
WebSocket socket,
string chunk,
SemaphoreSlim sendGate,
CancellationToken cancellationToken)
{
var bytes = Encoding.UTF8.GetBytes(chunk);
await SendAsync(socket, bytes, sendGate, cancellationToken).ConfigureAwait(false);
}
private static async Task SendAsync(
WebSocket socket,
byte[] payload,
SemaphoreSlim sendGate,
CancellationToken cancellationToken)
{
await sendGate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
if (socket.State == WebSocketState.Open)
{
await socket.SendAsync(payload, WebSocketMessageType.Text, true, cancellationToken).ConfigureAwait(false);
}
}
catch (WebSocketException)
{
}
finally
{
sendGate.Release();
}
}
private sealed record TerminalAttachResponse(string SessionId, string Type = "attached");
private sealed record TerminalClientMessage(
string Type,
string? SessionId,
string? Input,
int? Columns,
int? Rows);
}

View File

@ -6,3 +6,8 @@ public sealed record SessionRecord(
string Status,
DateTimeOffset CreatedAtUtc,
DateTimeOffset UpdatedAtUtc);
public sealed record SessionHistorySnapshot(
string SessionId,
IReadOnlyList<string> Lines,
bool HasMoreAbove);

View File

@ -1,10 +1,22 @@
using System.Collections.Concurrent;
using Microsoft.Extensions.Options;
using TermRemoteCtl.Agent.Configuration;
using TermRemoteCtl.Agent.History;
namespace TermRemoteCtl.Agent.Sessions;
public sealed class SessionRegistry
{
private readonly ConcurrentDictionary<string, SessionRecord> _records = new();
private readonly ConcurrentDictionary<string, TerminalRingBuffer> _historyBySession = new();
private readonly SessionHistoryStore _historyStore;
private readonly int _ringBufferLineLimit;
public SessionRegistry(SessionHistoryStore historyStore, IOptions<AgentOptions> options)
{
_historyStore = historyStore;
_ringBufferLineLimit = options.Value.RingBufferLineLimit;
}
public SessionRecord Create(string name, DateTimeOffset now)
{
@ -18,6 +30,7 @@ public sealed class SessionRegistry
now);
_records[record.SessionId] = record;
_historyBySession[record.SessionId] = new TerminalRingBuffer(_ringBufferLineLimit);
return record;
}
@ -27,4 +40,53 @@ public sealed class SessionRegistry
.OrderBy(record => record.Name, StringComparer.Ordinal)
.ToArray();
}
public bool TryGet(string sessionId, out SessionRecord? record)
{
ArgumentException.ThrowIfNullOrWhiteSpace(sessionId);
return _records.TryGetValue(sessionId, out record);
}
public async Task AppendOutputAsync(
string sessionId,
string chunk,
CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(sessionId);
ArgumentNullException.ThrowIfNull(chunk);
if (!_records.TryGetValue(sessionId, out var record))
{
throw new KeyNotFoundException($"Session '{sessionId}' was not found.");
}
var history = _historyBySession.GetOrAdd(
sessionId,
_ => new TerminalRingBuffer(_ringBufferLineLimit));
history.Append(chunk);
_records[sessionId] = record with { UpdatedAtUtc = DateTimeOffset.UtcNow };
await _historyStore.AppendAsync(sessionId, chunk, cancellationToken).ConfigureAwait(false);
}
public SessionHistorySnapshot GetHistory(string sessionId, int lineCount)
{
ArgumentException.ThrowIfNullOrWhiteSpace(sessionId);
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(lineCount);
if (!_records.ContainsKey(sessionId))
{
throw new KeyNotFoundException($"Session '{sessionId}' was not found.");
}
var history = _historyBySession.GetOrAdd(
sessionId,
_ => new TerminalRingBuffer(_ringBufferLineLimit));
var lines = history.GetSnapshotLines();
var skipCount = Math.Max(0, lines.Count - lineCount);
return new SessionHistorySnapshot(
sessionId,
lines.Skip(skipCount).ToArray(),
skipCount > 0);
}
}

View File

@ -1,9 +1,28 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\TermRemoteCtl.ConPtyHelper\TermRemoteCtl.ConPtyHelper.csproj" ReferenceOutputAssembly="false" />
</ItemGroup>
<Target Name="CopyConPtyHelper" AfterTargets="Build">
<PropertyGroup>
<ConPtyHelperSourceDir>..\TermRemoteCtl.ConPtyHelper\bin\$(Configuration)\$(TargetFramework)\</ConPtyHelperSourceDir>
<ConPtyHelperTargetDir>$(OutDir)ConPtyHelper\</ConPtyHelperTargetDir>
</PropertyGroup>
<ItemGroup>
<ConPtyHelperFiles Include="$(ConPtyHelperSourceDir)TermRemoteCtl.ConPtyHelper.*" />
</ItemGroup>
<MakeDir Directories="$(ConPtyHelperTargetDir)" />
<Copy SourceFiles="@(ConPtyHelperFiles)" DestinationFolder="$(ConPtyHelperTargetDir)" SkipUnchangedFiles="true" />
</Target>
</Project>

View File

@ -0,0 +1,344 @@
using System.ComponentModel;
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Security.Principal;
using System.Runtime.Versioning;
using System.Text;
using Microsoft.Win32.SafeHandles;
namespace TermRemoteCtl.Agent.Terminal;
[SupportedOSPlatform("windows")]
internal static partial class ConPtyInterop
{
public static void EnsureSupported()
{
if (!OperatingSystem.IsWindows())
{
throw new PlatformNotSupportedException("ConPTY requires Windows.");
}
}
[SupportedOSPlatform("windows")]
public static WindowsIdentity GetCurrentWindowsIdentity()
{
EnsureSupported();
return WindowsIdentity.GetCurrent();
}
[SupportedOSPlatform("windows")]
public static string ResolveShellPath()
{
var powershell = Path.Combine(Environment.SystemDirectory, "WindowsPowerShell", "v1.0", "powershell.exe");
if (File.Exists(powershell))
{
return powershell;
}
return "pwsh.exe";
}
[SupportedOSPlatform("windows")]
public static string ResolveShellArguments()
{
return "-NoLogo -NoProfile -NoExit";
}
[SupportedOSPlatform("windows")]
public static ConPtyProcessSession StartProcess(string sessionId, string shellPath, string shellArguments, int columns, int rows)
{
EnsureSupported();
if (columns <= 0)
{
throw new ArgumentOutOfRangeException(nameof(columns));
}
if (rows <= 0)
{
throw new ArgumentOutOfRangeException(nameof(rows));
}
var inputPipe = CreatePipe();
var outputPipe = CreatePipe();
var pseudoConsole = CreatePseudoConsole(columns, rows, inputPipe.ReadHandle, outputPipe.WriteHandle);
var startupInfo = new StartupInfoEx();
startupInfo.StartupInfo.Cb = (uint)Marshal.SizeOf<StartupInfoEx>();
nuint attributeListSize = 0;
_ = InitializeProcThreadAttributeList(IntPtr.Zero, 1, 0, ref attributeListSize);
startupInfo.AttributeList = Marshal.AllocHGlobal((int)attributeListSize);
try
{
if (!InitializeProcThreadAttributeList(startupInfo.AttributeList, 1, 0, ref attributeListSize))
{
throw new Win32Exception(Marshal.GetLastWin32Error());
}
if (!UpdateProcThreadAttribute(
startupInfo.AttributeList,
0,
ProcThreadAttributePseudoConsole,
pseudoConsole.DangerousGetHandle(),
IntPtr.Size,
IntPtr.Zero,
IntPtr.Zero))
{
throw new Win32Exception(Marshal.GetLastWin32Error());
}
startupInfo.StartupInfo.Cb = (uint)Marshal.SizeOf<StartupInfoEx>();
var processInfo = new ProcessInformation();
var commandLine = new StringBuilder();
commandLine.Append('"');
commandLine.Append(shellPath);
commandLine.Append('"');
if (!string.IsNullOrWhiteSpace(shellArguments))
{
commandLine.Append(' ');
commandLine.Append(shellArguments);
}
if (!CreateProcess(
null,
commandLine,
IntPtr.Zero,
IntPtr.Zero,
false,
CreationFlags.ExtendedStartupInfoPresent,
IntPtr.Zero,
null,
ref startupInfo,
out processInfo))
{
throw new Win32Exception(Marshal.GetLastWin32Error());
}
var process = Process.GetProcessById((int)processInfo.ProcessId);
var processHandle = processInfo.ProcessHandle;
CloseHandle(processInfo.ThreadHandle);
inputPipe.ReadHandle.Dispose();
outputPipe.WriteHandle.Dispose();
return new ConPtyProcessSession(sessionId, pseudoConsole, inputPipe.WriteHandle, outputPipe.ReadHandle, process, processHandle);
}
catch
{
pseudoConsole.Dispose();
inputPipe.ReadHandle.Dispose();
inputPipe.WriteHandle.Dispose();
outputPipe.WriteHandle.Dispose();
outputPipe.ReadHandle.Dispose();
throw;
}
finally
{
startupInfo.Dispose();
}
}
[SupportedOSPlatform("windows")]
public static void ResizePseudoConsole(SafePseudoConsoleHandle pseudoConsole, int columns, int rows)
{
ResizePseudoConsole(pseudoConsole.DangerousGetHandle(), new Coord((short)columns, (short)rows));
}
[SupportedOSPlatform("windows")]
public static bool TryGetExitCode(IntPtr processHandle, out uint exitCode)
{
return GetExitCodeProcess(processHandle, out exitCode);
}
[SupportedOSPlatform("windows")]
public static bool CloseNativeHandle(IntPtr handle)
{
return CloseHandle(handle);
}
[SupportedOSPlatform("windows")]
public static bool ReadPipe(SafeFileHandle handle, byte[] buffer, out int bytesRead)
{
if (!ReadFile(handle, buffer, (uint)buffer.Length, out var nativeBytesRead, IntPtr.Zero))
{
bytesRead = 0;
return false;
}
bytesRead = checked((int)nativeBytesRead);
return true;
}
[SupportedOSPlatform("windows")]
public static bool WritePipe(SafeFileHandle handle, byte[] buffer, out int bytesWritten)
{
if (!WriteFile(handle, buffer, (uint)buffer.Length, out var nativeBytesWritten, IntPtr.Zero))
{
bytesWritten = 0;
return false;
}
bytesWritten = checked((int)nativeBytesWritten);
return true;
}
private static ConPtyPipePair CreatePipe()
{
if (!CreatePipe(out var read, out var write, IntPtr.Zero, 0))
{
throw new Win32Exception(Marshal.GetLastWin32Error());
}
return new ConPtyPipePair(new SafeFileHandle(read, ownsHandle: true), new SafeFileHandle(write, ownsHandle: true));
}
private static SafePseudoConsoleHandle CreatePseudoConsole(int columns, int rows, SafeFileHandle inputRead, SafeFileHandle outputWrite)
{
var hr = CreatePseudoConsole(new Coord((short)columns, (short)rows), inputRead.DangerousGetHandle(), outputWrite.DangerousGetHandle(), 0, out var pseudoConsole);
if (hr != 0)
{
throw new Win32Exception(hr);
}
return new SafePseudoConsoleHandle(pseudoConsole);
}
[DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
private static extern bool CreateProcess(
string? lpApplicationName,
StringBuilder lpCommandLine,
IntPtr lpProcessAttributes,
IntPtr lpThreadAttributes,
bool bInheritHandles,
CreationFlags dwCreationFlags,
IntPtr lpEnvironment,
string? lpCurrentDirectory,
ref StartupInfoEx lpStartupInfo,
out ProcessInformation lpProcessInformation);
[DllImport("kernel32.dll", SetLastError = true)]
private static extern bool CreatePipe(out IntPtr hReadPipe, out IntPtr hWritePipe, IntPtr lpPipeAttributes, int nSize);
[DllImport("kernel32.dll", SetLastError = true)]
private static extern int CreatePseudoConsole(Coord size, IntPtr hInput, IntPtr hOutput, uint dwFlags, out IntPtr phPC);
[DllImport("kernel32.dll", SetLastError = true)]
private static extern void ResizePseudoConsole(IntPtr hPC, Coord size);
[DllImport("kernel32.dll", SetLastError = true)]
internal static extern void ClosePseudoConsole(IntPtr hPC);
[DllImport("kernel32.dll", SetLastError = true)]
private static extern bool InitializeProcThreadAttributeList(IntPtr lpAttributeList, int dwAttributeCount, int dwFlags, ref nuint lpSize);
[DllImport("kernel32.dll", SetLastError = true)]
private static extern bool UpdateProcThreadAttribute(
IntPtr lpAttributeList,
uint dwFlags,
IntPtr attribute,
IntPtr lpValue,
IntPtr cbSize,
IntPtr lpPreviousValue,
IntPtr lpReturnSize);
private const int ProcThreadAttributePseudoConsole = 0x00020016;
[Flags]
private enum CreationFlags : uint
{
ExtendedStartupInfoPresent = 0x00080000
}
[StructLayout(LayoutKind.Sequential)]
private struct Coord
{
public Coord(short x, short y)
{
X = x;
Y = y;
}
public short X;
public short Y;
}
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
private struct StartupInfo
{
public uint Cb;
public IntPtr Reserved;
public IntPtr Desktop;
public IntPtr Title;
public uint X;
public uint Y;
public uint XSize;
public uint YSize;
public uint XCountChars;
public uint YCountChars;
public uint FillAttribute;
public uint Flags;
public ushort ShowWindow;
public ushort Reserved2;
public IntPtr Reserved3;
public IntPtr StdInput;
public IntPtr StdOutput;
public IntPtr StdError;
}
[StructLayout(LayoutKind.Sequential)]
private struct StartupInfoEx : IDisposable
{
public StartupInfo StartupInfo;
public IntPtr AttributeList;
public void Dispose()
{
if (AttributeList != IntPtr.Zero)
{
DeleteProcThreadAttributeList(AttributeList);
Marshal.FreeHGlobal(AttributeList);
AttributeList = IntPtr.Zero;
}
}
}
[StructLayout(LayoutKind.Sequential)]
private struct ProcessInformation
{
public IntPtr ProcessHandle;
public IntPtr ThreadHandle;
public uint ProcessId;
public uint ThreadId;
}
[DllImport("kernel32.dll", SetLastError = true)]
private static extern void DeleteProcThreadAttributeList(IntPtr lpAttributeList);
[DllImport("kernel32.dll", SetLastError = true)]
private static extern bool CloseHandle(IntPtr hObject);
[DllImport("kernel32.dll", SetLastError = true)]
private static extern bool GetExitCodeProcess(IntPtr hProcess, out uint lpExitCode);
[DllImport("kernel32.dll", SetLastError = true)]
private static extern bool ReadFile(SafeFileHandle hFile, byte[] lpBuffer, uint nNumberOfBytesToRead, out uint lpNumberOfBytesRead, IntPtr lpOverlapped);
[DllImport("kernel32.dll", SetLastError = true)]
private static extern bool WriteFile(SafeFileHandle hFile, byte[] lpBuffer, uint nNumberOfBytesToWrite, out uint lpNumberOfBytesWritten, IntPtr lpOverlapped);
private sealed class ConPtyPipePair
{
public ConPtyPipePair(SafeFileHandle readHandle, SafeFileHandle writeHandle)
{
ReadHandle = readHandle;
WriteHandle = writeHandle;
}
public SafeFileHandle ReadHandle { get; }
public SafeFileHandle WriteHandle { get; }
}
}

View File

@ -0,0 +1,199 @@
using System.Diagnostics;
using System.Text;
using System.Runtime.Versioning;
using Microsoft.Win32.SafeHandles;
namespace TermRemoteCtl.Agent.Terminal;
[SupportedOSPlatform("windows")]
internal sealed class ConPtyProcessSession : IConPtySession
{
private readonly string _sessionId;
private readonly SafePseudoConsoleHandle _pseudoConsole;
private readonly SafeFileHandle _inputWriterHandle;
private readonly SafeFileHandle _outputReaderHandle;
private readonly Process _process;
private readonly IntPtr _processHandle;
private readonly CancellationTokenSource _shutdown = new();
private readonly SemaphoreSlim _inputGate = new(1, 1);
private Task? _outputPumpTask;
private bool _started;
private bool _disposed;
public ConPtyProcessSession(
string sessionId,
SafePseudoConsoleHandle pseudoConsole,
SafeFileHandle inputWriterHandle,
SafeFileHandle outputReaderHandle,
Process process,
IntPtr processHandle)
{
_sessionId = sessionId;
_pseudoConsole = pseudoConsole;
_inputWriterHandle = inputWriterHandle;
_outputReaderHandle = outputReaderHandle;
_process = process;
_processHandle = processHandle;
}
public event EventHandler<TerminalOutputEventArgs>? OutputReceived;
public int? TryGetExitCode()
{
if (_processHandle == IntPtr.Zero)
{
return null;
}
return ConPtyInterop.TryGetExitCode(_processHandle, out var exitCode) ? unchecked((int)exitCode) : null;
}
public async Task StartAsync(CancellationToken cancellationToken)
{
ThrowIfDisposed();
if (_started)
{
return;
}
_started = true;
_outputPumpTask = Task.Run(() => PumpOutputAsync(_shutdown.Token));
await Task.CompletedTask.ConfigureAwait(false);
}
public async Task WriteInputAsync(string input, CancellationToken cancellationToken)
{
ThrowIfDisposed();
await _inputGate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
var payload = Encoding.UTF8.GetBytes(input);
await Task.Run(() =>
{
if (!ConPtyInterop.WritePipe(_inputWriterHandle, payload, out var written) || written != payload.Length)
{
throw new IOException("Failed to write terminal input to the ConPTY pipe.");
}
}, cancellationToken).ConfigureAwait(false);
}
finally
{
_inputGate.Release();
}
}
public Task ResizeAsync(int columns, int rows, CancellationToken cancellationToken)
{
ThrowIfDisposed();
if (columns <= 0)
{
throw new ArgumentOutOfRangeException(nameof(columns));
}
if (rows <= 0)
{
throw new ArgumentOutOfRangeException(nameof(rows));
}
ConPtyInterop.ResizePseudoConsole(_pseudoConsole, columns, rows);
return Task.CompletedTask;
}
public async ValueTask DisposeAsync()
{
if (_disposed)
{
return;
}
_disposed = true;
_shutdown.Cancel();
_inputWriterHandle.Dispose();
_outputReaderHandle.Dispose();
_pseudoConsole.Dispose();
try
{
if (!_process.HasExited)
{
_process.Kill(entireProcessTree: true);
}
}
catch (InvalidOperationException)
{
}
catch (NotSupportedException)
{
}
try
{
if (_outputPumpTask is not null)
{
await _outputPumpTask.ConfigureAwait(false);
}
}
catch (OperationCanceledException)
{
}
if (_process is not null)
{
try
{
await _process.WaitForExitAsync().ConfigureAwait(false);
}
catch (InvalidOperationException)
{
}
}
if (_processHandle != IntPtr.Zero)
{
ConPtyInterop.CloseNativeHandle(_processHandle);
}
_process?.Dispose();
_shutdown.Dispose();
_inputGate.Dispose();
}
private async Task PumpOutputAsync(CancellationToken cancellationToken)
{
var buffer = new byte[4096];
try
{
while (!cancellationToken.IsCancellationRequested)
{
var read = await Task.Run(() =>
{
return ConPtyInterop.ReadPipe(_outputReaderHandle, buffer, out var bytesRead) ? bytesRead : 0;
}, cancellationToken).ConfigureAwait(false);
if (read <= 0)
{
return;
}
var chunk = Encoding.UTF8.GetString(buffer, 0, read);
OutputReceived?.Invoke(this, new TerminalOutputEventArgs(_sessionId, chunk));
}
}
catch (OperationCanceledException)
{
}
catch (IOException)
{
}
}
private void ThrowIfDisposed()
{
if (_disposed)
{
throw new ObjectDisposedException(nameof(ConPtyProcessSession));
}
}
}

View File

@ -0,0 +1,20 @@
using System.Runtime.Versioning;
using Microsoft.Win32.SafeHandles;
namespace TermRemoteCtl.Agent.Terminal;
[SupportedOSPlatform("windows")]
internal sealed class SafePseudoConsoleHandle : SafeHandleZeroOrMinusOneIsInvalid
{
public SafePseudoConsoleHandle(IntPtr handle)
: base(ownsHandle: true)
{
SetHandle(handle);
}
protected override bool ReleaseHandle()
{
ConPtyInterop.ClosePseudoConsole(handle);
return true;
}
}

View File

@ -0,0 +1,17 @@
using System.Runtime.Versioning;
namespace TermRemoteCtl.Agent.Terminal;
[SupportedOSPlatform("windows")]
internal sealed class ConPtySessionFactory : IConPtySessionFactory
{
public IConPtySession Create(string sessionId)
{
ConPtyInterop.EnsureSupported();
return new HelperBackedConPtySession(
sessionId,
ConPtyInterop.ResolveShellPath(),
ConPtyInterop.ResolveShellArguments(),
HelperPathResolver.ResolveHelperExePath());
}
}

View File

@ -0,0 +1,211 @@
using System.Diagnostics;
using System.IO.Pipes;
using System.Text;
using System.Text.Json;
using System.Runtime.Versioning;
namespace TermRemoteCtl.Agent.Terminal;
[SupportedOSPlatform("windows")]
internal sealed class HelperBackedConPtySession : IConPtySession
{
private readonly string _sessionId;
private readonly string _shellPath;
private readonly string _shellArguments;
private readonly string _helperExePath;
private readonly string _commandPipeName = $"termremotectl-cmd-{Guid.NewGuid():N}";
private readonly string _outputPipeName = $"termremotectl-out-{Guid.NewGuid():N}";
private Process? _helperProcess;
private NamedPipeServerStream? _commandPipe;
private NamedPipeServerStream? _outputPipe;
private StreamWriter? _commandWriter;
private StreamReader? _outputReader;
private Task? _outputPumpTask;
private bool _started;
private bool _disposed;
public HelperBackedConPtySession(string sessionId, string shellPath, string shellArguments, string helperExePath)
{
_sessionId = sessionId;
_shellPath = shellPath;
_shellArguments = shellArguments;
_helperExePath = helperExePath;
}
public event EventHandler<TerminalOutputEventArgs>? OutputReceived;
public async Task StartAsync(CancellationToken cancellationToken)
{
ThrowIfDisposed();
if (_started)
{
return;
}
_commandPipe = new NamedPipeServerStream(_commandPipeName, PipeDirection.Out, 1, PipeTransmissionMode.Byte, PipeOptions.Asynchronous);
_outputPipe = new NamedPipeServerStream(_outputPipeName, PipeDirection.In, 1, PipeTransmissionMode.Byte, PipeOptions.Asynchronous);
var startInfo = new ProcessStartInfo(_helperExePath)
{
UseShellExecute = false,
CreateNoWindow = true
};
startInfo.ArgumentList.Add("--command-pipe");
startInfo.ArgumentList.Add(_commandPipeName);
startInfo.ArgumentList.Add("--output-pipe");
startInfo.ArgumentList.Add(_outputPipeName);
startInfo.ArgumentList.Add("--shell-path");
startInfo.ArgumentList.Add(_shellPath);
startInfo.ArgumentList.Add("--shell-args");
startInfo.ArgumentList.Add(_shellArguments);
startInfo.ArgumentList.Add("--columns");
startInfo.ArgumentList.Add("120");
startInfo.ArgumentList.Add("--rows");
startInfo.ArgumentList.Add("30");
_helperProcess = Process.Start(startInfo) ?? throw new InvalidOperationException("Failed to start ConPTY helper process.");
using var startupTimeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
startupTimeout.CancelAfter(TimeSpan.FromSeconds(10));
await Task.WhenAll(
_commandPipe.WaitForConnectionAsync(startupTimeout.Token),
_outputPipe.WaitForConnectionAsync(startupTimeout.Token)).ConfigureAwait(false);
_commandWriter = new StreamWriter(_commandPipe, new UTF8Encoding(false), leaveOpen: true) { AutoFlush = true };
_outputReader = new StreamReader(_outputPipe, new UTF8Encoding(false), detectEncodingFromByteOrderMarks: false, leaveOpen: true);
var handshake = await _outputReader.ReadLineAsync(startupTimeout.Token).ConfigureAwait(false);
if (handshake is null)
{
throw new InvalidOperationException("ConPTY helper closed before sending a startup handshake.");
}
var handshakeMessage = JsonSerializer.Deserialize<HelperOutputMessage>(handshake);
if (handshakeMessage is null)
{
throw new InvalidOperationException("ConPTY helper returned an invalid startup handshake.");
}
if (string.Equals(handshakeMessage.Type, "error", StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException($"ConPTY helper startup failed: {handshakeMessage.Data}");
}
if (!string.Equals(handshakeMessage.Type, "ready", StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException($"Unexpected ConPTY helper startup handshake: {handshake}");
}
_outputPumpTask = Task.Run(() => PumpOutputAsync(_outputReader, cancellationToken), cancellationToken);
_started = true;
}
public async Task WriteInputAsync(string input, CancellationToken cancellationToken)
{
if (_commandWriter is null)
{
throw new InvalidOperationException("Session has not been started.");
}
var payload = JsonSerializer.Serialize(new HelperCommandMessage("input", input, null, null));
await _commandWriter.WriteLineAsync(payload.AsMemory(), cancellationToken).ConfigureAwait(false);
}
public async Task ResizeAsync(int columns, int rows, CancellationToken cancellationToken)
{
if (_commandWriter is null)
{
throw new InvalidOperationException("Session has not been started.");
}
var payload = JsonSerializer.Serialize(new HelperCommandMessage("resize", null, columns, rows));
await _commandWriter.WriteLineAsync(payload.AsMemory(), cancellationToken).ConfigureAwait(false);
}
public async ValueTask DisposeAsync()
{
if (_disposed)
{
return;
}
_disposed = true;
if (_commandWriter is not null)
{
try
{
await _commandWriter.WriteLineAsync(JsonSerializer.Serialize(new HelperCommandMessage("shutdown", null, null, null))).ConfigureAwait(false);
}
catch
{
}
}
if (_helperProcess is not null && !_helperProcess.HasExited)
{
try
{
_helperProcess.Kill(entireProcessTree: true);
}
catch
{
}
}
if (_outputPumpTask is not null)
{
await _outputPumpTask.ConfigureAwait(false);
}
_commandWriter?.Dispose();
_outputReader?.Dispose();
_commandPipe?.Dispose();
_outputPipe?.Dispose();
_helperProcess?.Dispose();
}
private async Task PumpOutputAsync(StreamReader reader, CancellationToken cancellationToken)
{
while (!cancellationToken.IsCancellationRequested)
{
string? line;
try
{
line = await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
return;
}
if (line is null)
{
return;
}
var message = JsonSerializer.Deserialize<HelperOutputMessage>(line);
if (message is null)
{
continue;
}
if (string.Equals(message.Type, "output", StringComparison.OrdinalIgnoreCase) && message.Data is not null)
{
OutputReceived?.Invoke(this, new TerminalOutputEventArgs(_sessionId, message.Data));
}
}
}
private void ThrowIfDisposed()
{
if (_disposed)
{
throw new ObjectDisposedException(nameof(HelperBackedConPtySession));
}
}
private sealed record HelperCommandMessage(string Type, string? Data, int? Columns, int? Rows);
private sealed record HelperOutputMessage(string Type, string? Data);
}

View File

@ -0,0 +1,33 @@
namespace TermRemoteCtl.Agent.Terminal;
internal static class HelperPathResolver
{
public static string ResolveHelperExePath()
{
var helperPath = Path.Combine(AppContext.BaseDirectory, "ConPtyHelper", "TermRemoteCtl.ConPtyHelper.exe");
if (File.Exists(helperPath))
{
return helperPath;
}
var current = new DirectoryInfo(AppContext.BaseDirectory);
while (current is not null)
{
var debugCandidate = Path.Combine(current.FullName, "src", "TermRemoteCtl.ConPtyHelper", "bin", "Debug", "net8.0", "TermRemoteCtl.ConPtyHelper.exe");
if (File.Exists(debugCandidate))
{
return debugCandidate;
}
var releaseCandidate = Path.Combine(current.FullName, "src", "TermRemoteCtl.ConPtyHelper", "bin", "Release", "net8.0", "TermRemoteCtl.ConPtyHelper.exe");
if (File.Exists(releaseCandidate))
{
return releaseCandidate;
}
current = current.Parent;
}
throw new FileNotFoundException($"ConPTY helper executable could not be located at '{helperPath}' or any source build output.");
}
}

View File

@ -0,0 +1,17 @@
namespace TermRemoteCtl.Agent.Terminal;
internal interface IConPtySession : IAsyncDisposable
{
event EventHandler<TerminalOutputEventArgs>? OutputReceived;
Task StartAsync(CancellationToken cancellationToken);
Task WriteInputAsync(string input, CancellationToken cancellationToken);
Task ResizeAsync(int columns, int rows, CancellationToken cancellationToken);
}
internal interface IConPtySessionFactory
{
IConPtySession Create(string sessionId);
}

View File

@ -0,0 +1,26 @@
namespace TermRemoteCtl.Agent.Terminal;
public interface ISessionHost
{
event EventHandler<TerminalOutputEventArgs>? OutputReceived;
Task StartAsync(string sessionId, CancellationToken cancellationToken);
Task WriteInputAsync(string sessionId, string input, CancellationToken cancellationToken);
Task ResizeAsync(string sessionId, int columns, int rows, CancellationToken cancellationToken);
}
public sealed class TerminalOutputEventArgs : EventArgs
{
public TerminalOutputEventArgs(string sessionId, string chunk)
{
ArgumentException.ThrowIfNullOrWhiteSpace(sessionId);
Chunk = chunk ?? throw new ArgumentNullException(nameof(chunk));
SessionId = sessionId;
}
public string SessionId { get; }
public string Chunk { get; }
}

View File

@ -0,0 +1,95 @@
using System.Collections.Concurrent;
using System.Runtime.Versioning;
using TermRemoteCtl.Agent.Sessions;
namespace TermRemoteCtl.Agent.Terminal;
[SupportedOSPlatform("windows")]
internal sealed class PowerShellSessionHost : ISessionHost, IAsyncDisposable
{
private readonly IConPtySessionFactory _sessionFactory;
private readonly SessionRegistry _sessionRegistry;
private readonly ConcurrentDictionary<string, IConPtySession> _sessions = new(StringComparer.Ordinal);
public PowerShellSessionHost(IConPtySessionFactory sessionFactory, SessionRegistry sessionRegistry)
{
_sessionFactory = sessionFactory;
_sessionRegistry = sessionRegistry;
}
public event EventHandler<TerminalOutputEventArgs>? OutputReceived;
public async Task StartAsync(string sessionId, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(sessionId);
ConPtyInterop.EnsureSupported();
if (_sessions.ContainsKey(sessionId))
{
return;
}
var session = _sessionFactory.Create(sessionId);
if (!_sessions.TryAdd(sessionId, session))
{
await session.DisposeAsync().ConfigureAwait(false);
return;
}
session.OutputReceived += HandleSessionOutput;
try
{
await session.StartAsync(cancellationToken).ConfigureAwait(false);
}
catch
{
session.OutputReceived -= HandleSessionOutput;
_sessions.TryRemove(sessionId, out _);
await session.DisposeAsync().ConfigureAwait(false);
throw;
}
}
public async Task WriteInputAsync(string sessionId, string input, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(sessionId);
ArgumentNullException.ThrowIfNull(input);
if (!_sessions.TryGetValue(sessionId, out var session))
{
throw new KeyNotFoundException($"Session '{sessionId}' is not running.");
}
await session.WriteInputAsync(input, cancellationToken).ConfigureAwait(false);
}
public async Task ResizeAsync(string sessionId, int columns, int rows, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(sessionId);
if (!_sessions.TryGetValue(sessionId, out var session))
{
throw new KeyNotFoundException($"Session '{sessionId}' is not running.");
}
await session.ResizeAsync(columns, rows, cancellationToken).ConfigureAwait(false);
}
public async ValueTask DisposeAsync()
{
foreach (var session in _sessions.Values)
{
session.OutputReceived -= HandleSessionOutput;
await session.DisposeAsync().ConfigureAwait(false);
}
_sessions.Clear();
}
private void HandleSessionOutput(object? sender, TerminalOutputEventArgs args)
{
_ = _sessionRegistry.AppendOutputAsync(args.SessionId, args.Chunk, CancellationToken.None);
OutputReceived?.Invoke(this, args);
}
}

View File

@ -0,0 +1,150 @@
using System.Text;
namespace TermRemoteCtl.Agent.Terminal;
public sealed class TerminalFrameBatcher : IAsyncDisposable
{
private readonly TimeSpan _interval;
private readonly Func<string, Task> _flushAction;
private readonly SemaphoreSlim _flushGate = new(1, 1);
private readonly object _gate = new();
private readonly StringBuilder _buffer = new();
private CancellationTokenSource? _scheduledFlushCts;
private bool _disposed;
public TerminalFrameBatcher(TimeSpan interval, Func<string, Task> flushAction)
{
if (interval <= TimeSpan.Zero)
{
throw new ArgumentOutOfRangeException(nameof(interval));
}
_interval = interval;
_flushAction = flushAction ?? throw new ArgumentNullException(nameof(flushAction));
}
public void Append(string chunk)
{
ArgumentNullException.ThrowIfNull(chunk);
lock (_gate)
{
ThrowIfDisposed();
_buffer.Append(chunk);
if (_scheduledFlushCts is null)
{
ScheduleFlushLocked();
}
}
}
public async Task FlushAsync()
{
CancellationTokenSource? scheduledFlushCts;
lock (_gate)
{
ThrowIfDisposed();
scheduledFlushCts = _scheduledFlushCts;
_scheduledFlushCts = null;
}
scheduledFlushCts?.Cancel();
scheduledFlushCts?.Dispose();
await FlushBufferedContentAsync().ConfigureAwait(false);
}
public async ValueTask DisposeAsync()
{
CancellationTokenSource? scheduledFlushCts;
lock (_gate)
{
if (_disposed)
{
return;
}
_disposed = true;
scheduledFlushCts = _scheduledFlushCts;
_scheduledFlushCts = null;
}
scheduledFlushCts?.Cancel();
scheduledFlushCts?.Dispose();
await _flushGate.WaitAsync().ConfigureAwait(false);
_flushGate.Release();
}
private void ScheduleFlushLocked()
{
var scheduledFlushCts = new CancellationTokenSource();
_scheduledFlushCts = scheduledFlushCts;
_ = Task.Run(async () =>
{
try
{
await Task.Delay(_interval, scheduledFlushCts.Token).ConfigureAwait(false);
}
catch (TaskCanceledException)
{
return;
}
lock (_gate)
{
if (!ReferenceEquals(_scheduledFlushCts, scheduledFlushCts))
{
return;
}
_scheduledFlushCts = null;
}
try
{
await FlushBufferedContentAsync().ConfigureAwait(false);
}
finally
{
scheduledFlushCts.Dispose();
}
});
}
private async Task FlushBufferedContentAsync()
{
string payload;
lock (_gate)
{
if (_buffer.Length == 0)
{
return;
}
payload = _buffer.ToString();
_buffer.Clear();
}
await _flushGate.WaitAsync().ConfigureAwait(false);
try
{
await _flushAction(payload).ConfigureAwait(false);
}
finally
{
_flushGate.Release();
}
}
private void ThrowIfDisposed()
{
if (_disposed)
{
throw new ObjectDisposedException(nameof(TerminalFrameBatcher));
}
}
}

View File

@ -1,8 +1,9 @@
{
"Agent": {
"DataRoot": "C:\\ProgramData\\TermRemoteCtl",
"BindAddress": "0.0.0.0",
"HttpsPort": 9443,
"BindAddress": "localhost",
"HttpsPort": 0,
"HttpPort": 5067,
"WebSocketFrameFlushMilliseconds": 33,
"RingBufferLineLimit": 4000
}

View File

@ -0,0 +1,437 @@
using System.ComponentModel;
using System.IO.Pipes;
using System.Runtime.InteropServices;
using System.Text;
using System.Text.Json;
using Microsoft.Win32.SafeHandles;
namespace TermRemoteCtl.ConPtyHelper;
internal static class Program
{
private const uint EXTENDED_STARTUPINFO_PRESENT = 0x00080000;
private const uint WAIT_OBJECT_0 = 0x00000000;
private const int ProcThreadAttributePseudoConsole = 0x00020016;
private static async Task<int> Main(string[] args)
{
if (!OperatingSystem.IsWindows())
{
return 1;
}
var options = HelperOptions.Parse(args);
using var outputPipe = new NamedPipeClientStream(".", options.OutputPipeName, PipeDirection.Out, PipeOptions.Asynchronous);
using var commandPipe = new NamedPipeClientStream(".", options.CommandPipeName, PipeDirection.In, PipeOptions.Asynchronous);
await outputPipe.ConnectAsync(10_000).ConfigureAwait(false);
await commandPipe.ConnectAsync(10_000).ConfigureAwait(false);
await using var writer = new StreamWriter(outputPipe, new UTF8Encoding(false), leaveOpen: true) { AutoFlush = true };
using var reader = new StreamReader(commandPipe, new UTF8Encoding(false), detectEncodingFromByteOrderMarks: false, leaveOpen: true);
var session = ConPtyRuntime.Start(options.ShellPath, options.ShellArguments, options.Columns, options.Rows);
try
{
await writer.WriteLineAsync(JsonSerializer.Serialize(new HelperOutputMessage("ready", null))).ConfigureAwait(false);
var outputTask = Task.Run(() => PumpOutputAsync(session, writer));
var commandTask = Task.Run(() => PumpCommandsAsync(session, reader));
await Task.WhenAny(outputTask, commandTask).ConfigureAwait(false);
}
catch (Exception ex)
{
await writer.WriteLineAsync(JsonSerializer.Serialize(new HelperOutputMessage("error", ex.ToString()))).ConfigureAwait(false);
return 1;
}
finally
{
session.Dispose();
}
return 0;
}
private static async Task PumpOutputAsync(ConPtyRuntime session, StreamWriter writer)
{
var buffer = new byte[4096];
while (true)
{
var read = session.TryReadOutput(buffer, TimeSpan.FromMilliseconds(50));
if (read > 0)
{
var chunk = Encoding.UTF8.GetString(buffer, 0, read);
await writer.WriteLineAsync(JsonSerializer.Serialize(new HelperOutputMessage("output", chunk))).ConfigureAwait(false);
continue;
}
if (session.HasExited)
{
await writer.WriteLineAsync(JsonSerializer.Serialize(new HelperOutputMessage("exit", $"0x{session.ExitCode:X8}"))).ConfigureAwait(false);
return;
}
}
}
private static async Task PumpCommandsAsync(ConPtyRuntime session, StreamReader reader)
{
while (true)
{
var line = await reader.ReadLineAsync().ConfigureAwait(false);
if (line is null)
{
return;
}
var message = JsonSerializer.Deserialize<HelperCommandMessage>(line);
if (message is null)
{
continue;
}
if (string.Equals(message.Type, "input", StringComparison.OrdinalIgnoreCase) && message.Data is not null)
{
session.WriteInput(message.Data);
continue;
}
if (string.Equals(message.Type, "resize", StringComparison.OrdinalIgnoreCase) && message.Columns is > 0 && message.Rows is > 0)
{
session.Resize(message.Columns.Value, message.Rows.Value);
continue;
}
if (string.Equals(message.Type, "shutdown", StringComparison.OrdinalIgnoreCase))
{
return;
}
}
}
private sealed record HelperCommandMessage(string Type, string? Data, int? Columns, int? Rows);
private sealed record HelperOutputMessage(string Type, string? Data);
private sealed class HelperOptions
{
public required string CommandPipeName { get; init; }
public required string OutputPipeName { get; init; }
public required string ShellPath { get; init; }
public required string ShellArguments { get; init; }
public int Columns { get; init; } = 120;
public int Rows { get; init; } = 30;
public static HelperOptions Parse(string[] args)
{
var values = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
for (var i = 0; i < args.Length - 1; i += 2)
{
values[args[i]] = args[i + 1];
}
return new HelperOptions
{
CommandPipeName = values["--command-pipe"],
OutputPipeName = values["--output-pipe"],
ShellPath = values["--shell-path"],
ShellArguments = values.TryGetValue("--shell-args", out var shellArgs) ? shellArgs : "",
Columns = values.TryGetValue("--columns", out var columns) ? int.Parse(columns) : 120,
Rows = values.TryGetValue("--rows", out var rows) ? int.Parse(rows) : 30
};
}
}
private sealed class ConPtyRuntime : IDisposable
{
private readonly SafePseudoConsoleHandle _pseudoConsole;
private readonly SafeFileHandle _inputWriteHandle;
private readonly SafeFileHandle _outputReadHandle;
private readonly IntPtr _processHandle;
private readonly IntPtr _threadHandle;
private ConPtyRuntime(
SafePseudoConsoleHandle pseudoConsole,
SafeFileHandle inputWriteHandle,
SafeFileHandle outputReadHandle,
IntPtr processHandle,
IntPtr threadHandle)
{
_pseudoConsole = pseudoConsole;
_inputWriteHandle = inputWriteHandle;
_outputReadHandle = outputReadHandle;
_processHandle = processHandle;
_threadHandle = threadHandle;
}
public bool HasExited => WaitForSingleObject(_processHandle, 0) == WAIT_OBJECT_0;
public uint ExitCode
{
get
{
_ = GetExitCodeProcess(_processHandle, out var exitCode);
return exitCode;
}
}
public static ConPtyRuntime Start(string shellPath, string shellArguments, int columns, int rows)
{
var inputPipe = CreatePipePair();
var outputPipe = CreatePipePair();
var pseudoConsole = CreatePseudoConsole(new Coord((short)columns, (short)rows), inputPipe.ReadHandle, outputPipe.WriteHandle);
STARTUPINFOEX startupInfo = default;
startupInfo.StartupInfo.cb = (uint)Marshal.SizeOf<STARTUPINFOEX>();
nuint attributeListSize = 0;
_ = InitializeProcThreadAttributeList(IntPtr.Zero, 1, 0, ref attributeListSize);
startupInfo.lpAttributeList = Marshal.AllocHGlobal((int)attributeListSize);
try
{
if (!InitializeProcThreadAttributeList(startupInfo.lpAttributeList, 1, 0, ref attributeListSize))
{
throw new Win32Exception(Marshal.GetLastWin32Error());
}
if (!UpdateProcThreadAttribute(
startupInfo.lpAttributeList,
0,
(IntPtr)ProcThreadAttributePseudoConsole,
pseudoConsole.DangerousGetHandle(),
(IntPtr)IntPtr.Size,
IntPtr.Zero,
IntPtr.Zero))
{
throw new Win32Exception(Marshal.GetLastWin32Error());
}
var commandLine = new StringBuilder($"\"{shellPath}\" {shellArguments}".Trim());
if (!CreateProcess(
null,
commandLine,
IntPtr.Zero,
IntPtr.Zero,
false,
EXTENDED_STARTUPINFO_PRESENT,
IntPtr.Zero,
null,
ref startupInfo,
out var processInfo))
{
throw new Win32Exception(Marshal.GetLastWin32Error());
}
inputPipe.ReadHandle.Dispose();
outputPipe.WriteHandle.Dispose();
return new ConPtyRuntime(pseudoConsole, inputPipe.WriteHandle, outputPipe.ReadHandle, processInfo.hProcess, processInfo.hThread);
}
finally
{
if (startupInfo.lpAttributeList != IntPtr.Zero)
{
DeleteProcThreadAttributeList(startupInfo.lpAttributeList);
Marshal.FreeHGlobal(startupInfo.lpAttributeList);
}
}
}
public void WriteInput(string data)
{
var bytes = Encoding.UTF8.GetBytes(data);
if (!WriteFile(_inputWriteHandle, bytes, (uint)bytes.Length, out _, IntPtr.Zero))
{
throw new Win32Exception(Marshal.GetLastWin32Error());
}
}
public int TryReadOutput(byte[] buffer, TimeSpan timeout)
{
var deadline = DateTime.UtcNow.Add(timeout);
while (DateTime.UtcNow < deadline)
{
if (!PeekNamedPipe(_outputReadHandle, IntPtr.Zero, 0, IntPtr.Zero, out var availableBytes, IntPtr.Zero))
{
throw new Win32Exception(Marshal.GetLastWin32Error());
}
if (availableBytes > 0)
{
if (!ReadFile(_outputReadHandle, buffer, (uint)buffer.Length, out var bytesRead, IntPtr.Zero))
{
throw new Win32Exception(Marshal.GetLastWin32Error());
}
return checked((int)bytesRead);
}
Thread.Sleep(25);
}
return 0;
}
public void Resize(int columns, int rows)
{
ResizePseudoConsole(_pseudoConsole.DangerousGetHandle(), new Coord((short)columns, (short)rows));
}
public void Dispose()
{
_inputWriteHandle.Dispose();
_outputReadHandle.Dispose();
_pseudoConsole.Dispose();
if (_threadHandle != IntPtr.Zero)
{
CloseHandle(_threadHandle);
}
if (_processHandle != IntPtr.Zero)
{
CloseHandle(_processHandle);
}
}
}
private sealed class SafePseudoConsoleHandle : SafeHandleZeroOrMinusOneIsInvalid
{
public SafePseudoConsoleHandle(IntPtr handle) : base(true)
{
SetHandle(handle);
}
protected override bool ReleaseHandle()
{
ClosePseudoConsole(handle);
return true;
}
}
private sealed record PipePair(SafeFileHandle ReadHandle, SafeFileHandle WriteHandle);
private static PipePair CreatePipePair()
{
if (!CreatePipe(out var readPipe, out var writePipe, IntPtr.Zero, 0))
{
throw new Win32Exception(Marshal.GetLastWin32Error());
}
return new PipePair(new SafeFileHandle(readPipe, true), new SafeFileHandle(writePipe, true));
}
private static SafePseudoConsoleHandle CreatePseudoConsole(Coord size, SafeFileHandle inputReadHandle, SafeFileHandle outputWriteHandle)
{
var result = CreatePseudoConsole(size, inputReadHandle.DangerousGetHandle(), outputWriteHandle.DangerousGetHandle(), 0, out var pseudoConsole);
if (result != 0)
{
throw new Win32Exception(result);
}
return new SafePseudoConsoleHandle(pseudoConsole);
}
[StructLayout(LayoutKind.Sequential)]
private struct Coord
{
public Coord(short x, short y)
{
X = x;
Y = y;
}
public short X;
public short Y;
}
[StructLayout(LayoutKind.Sequential)]
private struct STARTUPINFO
{
public uint cb;
public IntPtr lpReserved;
public IntPtr lpDesktop;
public IntPtr lpTitle;
public uint dwX;
public uint dwY;
public uint dwXSize;
public uint dwYSize;
public uint dwXCountChars;
public uint dwYCountChars;
public uint dwFillAttribute;
public uint dwFlags;
public ushort wShowWindow;
public ushort cbReserved2;
public IntPtr lpReserved2;
public IntPtr hStdInput;
public IntPtr hStdOutput;
public IntPtr hStdError;
}
[StructLayout(LayoutKind.Sequential)]
private struct STARTUPINFOEX
{
public STARTUPINFO StartupInfo;
public IntPtr lpAttributeList;
}
[StructLayout(LayoutKind.Sequential)]
private struct PROCESS_INFORMATION
{
public IntPtr hProcess;
public IntPtr hThread;
public uint dwProcessId;
public uint dwThreadId;
}
[DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
private static extern bool CreateProcess(
string? lpApplicationName,
StringBuilder lpCommandLine,
IntPtr lpProcessAttributes,
IntPtr lpThreadAttributes,
bool bInheritHandles,
uint dwCreationFlags,
IntPtr lpEnvironment,
string? lpCurrentDirectory,
ref STARTUPINFOEX lpStartupInfo,
out PROCESS_INFORMATION lpProcessInformation);
[DllImport("kernel32.dll", SetLastError = true)]
private static extern bool CreatePipe(out IntPtr hReadPipe, out IntPtr hWritePipe, IntPtr lpPipeAttributes, int nSize);
[DllImport("kernel32.dll", SetLastError = true)]
private static extern int CreatePseudoConsole(Coord size, IntPtr hInput, IntPtr hOutput, uint dwFlags, out IntPtr phPC);
[DllImport("kernel32.dll", SetLastError = true)]
private static extern void ResizePseudoConsole(IntPtr hPC, Coord size);
[DllImport("kernel32.dll", SetLastError = true)]
private static extern bool InitializeProcThreadAttributeList(IntPtr lpAttributeList, int dwAttributeCount, int dwFlags, ref nuint lpSize);
[DllImport("kernel32.dll", SetLastError = true)]
private static extern bool UpdateProcThreadAttribute(IntPtr lpAttributeList, uint dwFlags, IntPtr attribute, IntPtr lpValue, IntPtr cbSize, IntPtr lpPreviousValue, IntPtr lpReturnSize);
[DllImport("kernel32.dll", SetLastError = true)]
private static extern void DeleteProcThreadAttributeList(IntPtr lpAttributeList);
[DllImport("kernel32.dll", SetLastError = true)]
private static extern bool ReadFile(SafeFileHandle hFile, byte[] lpBuffer, uint nNumberOfBytesToRead, out uint lpNumberOfBytesRead, IntPtr lpOverlapped);
[DllImport("kernel32.dll", SetLastError = true)]
private static extern bool WriteFile(SafeFileHandle hFile, byte[] lpBuffer, uint nNumberOfBytesToWrite, out uint lpNumberOfBytesWritten, IntPtr lpOverlapped);
[DllImport("kernel32.dll", SetLastError = true)]
private static extern bool PeekNamedPipe(SafeFileHandle hNamedPipe, IntPtr lpBuffer, uint nBufferSize, IntPtr lpBytesRead, out uint lpTotalBytesAvail, IntPtr lpBytesLeftThisMessage);
[DllImport("kernel32.dll", SetLastError = true)]
private static extern uint WaitForSingleObject(IntPtr hHandle, uint dwMilliseconds);
[DllImport("kernel32.dll", SetLastError = true)]
private static extern bool GetExitCodeProcess(IntPtr hProcess, out uint lpExitCode);
[DllImport("kernel32.dll", SetLastError = true)]
private static extern bool CloseHandle(IntPtr hObject);
[DllImport("kernel32.dll", SetLastError = true)]
private static extern void ClosePseudoConsole(IntPtr hPC);
}

View File

@ -0,0 +1,8 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>

View File

@ -0,0 +1,3 @@
using System.Runtime.Versioning;
[assembly: SupportedOSPlatform("windows")]

View File

@ -0,0 +1,282 @@
using System.Diagnostics;
using System.Net;
using System.Net.Http.Json;
using System.Net.WebSockets;
using System.Net.Sockets;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.DependencyInjection;
namespace TermRemoteCtl.Agent.IntegrationTests.Realtime;
public sealed class TerminalSmokeCheckTests
{
[Fact]
public async Task BuiltAgentExe_Starts_And_Attaches_To_Terminal_WebSocket()
{
if (!OperatingSystem.IsWindows())
{
return;
}
await using var fixture = new BuiltAgentFixture();
await fixture.StartAsync();
try
{
var session = await fixture.CreateSessionAsync("smoke-shell");
using var socket = await fixture.ConnectTerminalAsync(session.SessionId);
var attached = await fixture.ReceiveTextAsync(socket, TimeSpan.FromSeconds(20));
var attachedPayload = JsonSerializer.Deserialize<TerminalAttachResponse>(attached, new JsonSerializerOptions(JsonSerializerDefaults.Web));
Assert.NotNull(attachedPayload);
Assert.Equal("attached", attachedPayload!.Type);
Assert.Equal(session.SessionId, attachedPayload.SessionId);
await fixture.SendTextAsync(socket, JsonSerializer.Serialize(new { type = "input", input = "Write-Output smoke\r" }));
var output = await fixture.ReceiveTextContainingAsync(socket, "smoke", TimeSpan.FromSeconds(20));
Assert.Contains("smoke", output, StringComparison.OrdinalIgnoreCase);
}
catch (Exception ex)
{
throw new InvalidOperationException($"{ex.Message}{Environment.NewLine}{fixture.GetDiagnostics()}", ex);
}
}
private sealed class BuiltAgentFixture : IAsyncDisposable
{
private readonly string _projectRoot;
private readonly string _projectFile;
private readonly string _exePath;
private readonly string _dataRoot = Path.Combine(Path.GetTempPath(), "TermRemoteCtl.Smoke", Guid.NewGuid().ToString("N"));
private readonly int _httpsPort = GetFreePort();
private Process? _process;
public BuiltAgentFixture()
{
var sourceRoot = Path.GetFullPath(Path.Combine(
AppContext.BaseDirectory,
"..",
"..",
"..",
"..",
"..",
"src",
"TermRemoteCtl.Agent"));
_projectRoot = sourceRoot;
_projectFile = Path.Combine(_projectRoot, "TermRemoteCtl.Agent.csproj");
_exePath = Path.Combine(_projectRoot, "bin", "Debug", "net8.0", "TermRemoteCtl.Agent.exe");
}
public async Task StartAsync()
{
await BuildAsync().ConfigureAwait(false);
var startInfo = new ProcessStartInfo(_exePath)
{
WorkingDirectory = _projectRoot,
UseShellExecute = false
};
startInfo.Environment["ASPNETCORE_ENVIRONMENT"] = "Development";
startInfo.Environment["Agent__DataRoot"] = _dataRoot;
startInfo.Environment["Agent__BindAddress"] = "127.0.0.1";
startInfo.Environment["Agent__HttpsPort"] = _httpsPort.ToString();
startInfo.Environment["Agent__WebSocketFrameFlushMilliseconds"] = "33";
startInfo.Environment["Agent__RingBufferLineLimit"] = "4000";
_process = Process.Start(startInfo) ?? throw new InvalidOperationException("Failed to start agent process.");
await WaitForHealthAsync(TimeSpan.FromSeconds(30)).ConfigureAwait(false);
}
public async Task<(string SessionId, string Name)> CreateSessionAsync(string name)
{
using var handler = new HttpClientHandler
{
ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator
};
using var client = new HttpClient(handler)
{
BaseAddress = new Uri($"https://127.0.0.1:{_httpsPort}")
};
using var response = await client.PostAsJsonAsync("/api/sessions", new { name }).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
var payload = await response.Content.ReadFromJsonAsync<SessionResponse>(new JsonSerializerOptions(JsonSerializerDefaults.Web)).ConfigureAwait(false);
if (payload is null)
{
throw new InvalidOperationException("Missing session payload.");
}
return (payload.SessionId, payload.Name);
}
public async Task<ClientWebSocket> ConnectTerminalAsync(string sessionId)
{
var socket = new ClientWebSocket();
socket.Options.RemoteCertificateValidationCallback = (_, _, _, _) => true;
await socket.ConnectAsync(new Uri($"wss://127.0.0.1:{_httpsPort}/ws/terminal?sessionId={sessionId}"), CancellationToken.None).ConfigureAwait(false);
return socket;
}
public async Task SendTextAsync(WebSocket socket, string payload)
{
var bytes = Encoding.UTF8.GetBytes(payload);
await socket.SendAsync(bytes, WebSocketMessageType.Text, true, CancellationToken.None).ConfigureAwait(false);
}
public async Task<string> ReceiveTextAsync(WebSocket socket, TimeSpan timeout)
{
return await ReceiveUntilAsync(socket, static _ => true, timeout).ConfigureAwait(false);
}
public async Task<string> ReceiveTextContainingAsync(WebSocket socket, string expected, TimeSpan timeout)
{
return await ReceiveUntilAsync(socket, value => value.Contains(expected, StringComparison.OrdinalIgnoreCase), timeout).ConfigureAwait(false);
}
public async ValueTask DisposeAsync()
{
if (_process is not null && !_process.HasExited)
{
try
{
_process.Kill(entireProcessTree: true);
}
catch
{
}
await _process.WaitForExitAsync().ConfigureAwait(false);
}
if (Directory.Exists(_dataRoot))
{
Directory.Delete(_dataRoot, true);
}
}
private async Task WaitForHealthAsync(TimeSpan timeout)
{
using var handler = new HttpClientHandler
{
ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator
};
using var client = new HttpClient(handler)
{
BaseAddress = new Uri($"https://127.0.0.1:{_httpsPort}")
};
var deadline = DateTimeOffset.UtcNow.Add(timeout);
while (DateTimeOffset.UtcNow < deadline)
{
try
{
using var response = await client.GetAsync("/health").ConfigureAwait(false);
if (response.StatusCode == HttpStatusCode.OK)
{
return;
}
}
catch
{
}
if (_process is not null && _process.HasExited)
{
throw new InvalidOperationException($"Agent exited early with code {_process.ExitCode}.");
}
await Task.Delay(250).ConfigureAwait(false);
}
throw new TimeoutException("Agent did not become healthy in time.");
}
public string GetDiagnostics()
{
return _process is null
? "Agent process was not started."
: $"Agent process state: HasExited={_process.HasExited}, ExitCode={(_process.HasExited ? _process.ExitCode : 0)}";
}
private async Task<string> ReceiveUntilAsync(WebSocket socket, Func<string, bool> predicate, TimeSpan timeout)
{
var buffer = new byte[4096];
using var aggregate = new MemoryStream();
var deadline = DateTimeOffset.UtcNow.Add(timeout);
while (DateTimeOffset.UtcNow < deadline)
{
var remaining = deadline - DateTimeOffset.UtcNow;
var receiveTask = socket.ReceiveAsync(buffer, CancellationToken.None);
var completed = await Task.WhenAny(receiveTask, Task.Delay(remaining)).ConfigureAwait(false);
if (completed != receiveTask)
{
break;
}
var result = await receiveTask.ConfigureAwait(false);
if (result.MessageType == WebSocketMessageType.Close)
{
break;
}
aggregate.Write(buffer, 0, result.Count);
if (result.EndOfMessage)
{
var text = Encoding.UTF8.GetString(aggregate.ToArray());
if (predicate(text))
{
return text;
}
aggregate.SetLength(0);
}
}
throw new TimeoutException("Expected terminal frame was not received.");
}
private static int GetFreePort()
{
var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
listener.Stop();
return port;
}
private async Task BuildAsync()
{
var startInfo = new ProcessStartInfo("dotnet")
{
WorkingDirectory = _projectRoot,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false
};
startInfo.ArgumentList.Add("build");
startInfo.ArgumentList.Add(_projectFile);
using var process = Process.Start(startInfo) ?? throw new InvalidOperationException("Failed to start agent build.");
var stdout = await process.StandardOutput.ReadToEndAsync().ConfigureAwait(false);
var stderr = await process.StandardError.ReadToEndAsync().ConfigureAwait(false);
await process.WaitForExitAsync().ConfigureAwait(false);
if (process.ExitCode != 0)
{
throw new InvalidOperationException($"Agent build failed.{Environment.NewLine}STDOUT:{Environment.NewLine}{stdout}{Environment.NewLine}STDERR:{Environment.NewLine}{stderr}");
}
}
}
private sealed record SessionResponse(string SessionId, string Name);
private sealed record TerminalAttachResponse(string SessionId, string Type);
}

View File

@ -0,0 +1,156 @@
using System.Net;
using System.Net.WebSockets;
using System.Text;
using System.Text.Json;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using TermRemoteCtl.Agent.Sessions;
using TermRemoteCtl.Agent.Terminal;
namespace TermRemoteCtl.Agent.IntegrationTests.Realtime;
public sealed class TerminalWebSocketHandlerTests
{
[Fact]
public async Task Attach_Streams_Output_And_Forwards_Input()
{
await using var fixture = new TerminalApiFixture();
var registry = fixture.Services.GetRequiredService<SessionRegistry>();
var session = registry.Create("Shell", DateTimeOffset.UtcNow);
using WebSocket socket = await fixture.Server.CreateWebSocketClient().ConnectAsync(
new Uri($"ws://localhost/ws/terminal?sessionId={session.SessionId}"),
CancellationToken.None);
var attachedFrame = await ReceiveTextAsync(socket, CancellationToken.None);
var attachedPayload = JsonSerializer.Deserialize<TerminalAttachResponse>(
attachedFrame,
new JsonSerializerOptions(JsonSerializerDefaults.Web));
Assert.NotNull(attachedPayload);
Assert.Equal("attached", attachedPayload!.Type);
Assert.Equal(session.SessionId, attachedPayload.SessionId);
fixture.TerminalHost.EmitOutput(session.SessionId, "abc");
fixture.TerminalHost.EmitOutput(session.SessionId, "def");
var outputFrame = await ReceiveTextAsync(socket, CancellationToken.None);
Assert.Equal("abcdef", outputFrame);
var inputMessage = JsonSerializer.Serialize(new { type = "input", input = "dir" });
await socket.SendAsync(Encoding.UTF8.GetBytes(inputMessage), WebSocketMessageType.Text, true, CancellationToken.None);
await WaitForConditionAsync(() => fixture.TerminalHost.Inputs.Contains(("input", session.SessionId, "dir")), TimeSpan.FromSeconds(2));
}
private static async Task<string> ReceiveTextAsync(WebSocket socket, CancellationToken cancellationToken)
{
var buffer = new byte[4096];
using var stream = new MemoryStream();
while (true)
{
var result = await socket.ReceiveAsync(buffer, cancellationToken);
if (result.MessageType == WebSocketMessageType.Close)
{
throw new InvalidOperationException("Socket closed before a text message was received.");
}
stream.Write(buffer, 0, result.Count);
if (result.EndOfMessage)
{
return Encoding.UTF8.GetString(stream.ToArray());
}
}
}
private static async Task WaitForConditionAsync(Func<bool> condition, TimeSpan timeout)
{
var deadline = DateTimeOffset.UtcNow.Add(timeout);
while (DateTimeOffset.UtcNow < deadline)
{
if (condition())
{
return;
}
await Task.Delay(25);
}
throw new TimeoutException("Condition was not met.");
}
private sealed class TerminalApiFixture : WebApplicationFactory<Program>
{
private readonly string _dataRoot = Path.Combine(Path.GetTempPath(), "TermRemoteCtl.Tests", Guid.NewGuid().ToString("N"));
private readonly TestTerminalSessionHost _terminalHost = new();
public TestTerminalSessionHost TerminalHost => _terminalHost;
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.UseEnvironment("Development");
builder.ConfigureAppConfiguration((_, configBuilder) =>
{
configBuilder.AddInMemoryCollection(new Dictionary<string, string?>
{
["Agent:DataRoot"] = _dataRoot,
["Agent:BindAddress"] = "127.0.0.1",
["Agent:HttpsPort"] = "9443",
["Agent:WebSocketFrameFlushMilliseconds"] = "33",
["Agent:RingBufferLineLimit"] = "4000"
});
});
builder.ConfigureServices(services =>
{
services.RemoveAll<ISessionHost>();
services.AddSingleton<ISessionHost>(_terminalHost);
});
}
public new async ValueTask DisposeAsync()
{
await base.DisposeAsync();
if (Directory.Exists(_dataRoot))
{
Directory.Delete(_dataRoot, true);
}
}
}
private sealed class TestTerminalSessionHost : ISessionHost
{
private readonly List<(string Kind, string SessionId, string Value)> _inputs = new();
public event EventHandler<TerminalOutputEventArgs>? OutputReceived;
public IReadOnlyList<(string Kind, string SessionId, string Value)> Inputs => _inputs;
public Task StartAsync(string sessionId, CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
public Task WriteInputAsync(string sessionId, string input, CancellationToken cancellationToken)
{
_inputs.Add(("input", sessionId, input));
return Task.CompletedTask;
}
public Task ResizeAsync(string sessionId, int columns, int rows, CancellationToken cancellationToken)
{
_inputs.Add(("resize", sessionId, $"{columns}x{rows}"));
return Task.CompletedTask;
}
public void EmitOutput(string sessionId, string chunk)
{
OutputReceived?.Invoke(this, new TerminalOutputEventArgs(sessionId, chunk));
}
}
private sealed record TerminalAttachResponse(string SessionId, string Type);
}

View File

@ -0,0 +1,173 @@
using System.Net.WebSockets;
using System.Net.Http.Json;
using System.Text;
using System.Text.Json;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using TermRemoteCtl.Agent.Terminal;
namespace TermRemoteCtl.Agent.IntegrationTests;
public sealed class SessionFlowTests
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
[Fact]
public async Task Create_Attach_Reconnect_Sequence_Returns_Consistent_Session_Metadata()
{
await using var fixture = new AgentFixture();
using var client = fixture.CreateClient();
var createResponse = await client.PostAsJsonAsync("/api/sessions", new { name = "codex-main" });
createResponse.EnsureSuccessStatusCode();
var created = await createResponse.Content.ReadFromJsonAsync<SessionResponse>(JsonOptions);
Assert.NotNull(created);
var listedSessions = await client.GetFromJsonAsync<List<SessionResponse>>("/api/sessions", JsonOptions);
Assert.NotNull(listedSessions);
Assert.Contains(listedSessions!, session => session.SessionId == created!.SessionId && session.Name == created.Name && session.Status == created.Status && session.CreatedAtUtc == created.CreatedAtUtc && session.UpdatedAtUtc == created.UpdatedAtUtc);
var initialSnapshot = created;
using (var firstSocket = await fixture.ConnectTerminalAsync(created.SessionId))
{
var attached = await ReceiveTextAsync(firstSocket);
var attachedPayload = JsonSerializer.Deserialize<TerminalAttachResponse>(attached, JsonOptions);
Assert.NotNull(attachedPayload);
Assert.Equal(created.SessionId, attachedPayload!.SessionId);
Assert.Equal("attached", attachedPayload.Type);
}
using (var secondSocket = await fixture.ConnectTerminalAsync(created.SessionId))
{
var reattached = await ReceiveTextAsync(secondSocket);
var reattachedPayload = JsonSerializer.Deserialize<TerminalAttachResponse>(reattached, JsonOptions);
Assert.NotNull(reattachedPayload);
Assert.Equal(initialSnapshot.SessionId, reattachedPayload!.SessionId);
Assert.Equal("attached", reattachedPayload.Type);
}
var replayedSessions = await client.GetFromJsonAsync<List<SessionResponse>>("/api/sessions", JsonOptions);
Assert.NotNull(replayedSessions);
Assert.Contains(replayedSessions!, session => session.SessionId == initialSnapshot.SessionId && session.Name == initialSnapshot.Name && session.Status == initialSnapshot.Status && session.CreatedAtUtc == initialSnapshot.CreatedAtUtc && session.UpdatedAtUtc == initialSnapshot.UpdatedAtUtc);
Assert.Equal(1, fixture.SessionHost.StartCountFor(initialSnapshot.SessionId));
}
private static async Task<string> ReceiveTextAsync(WebSocket socket)
{
var buffer = new byte[4096];
using var stream = new MemoryStream();
while (true)
{
var result = await socket.ReceiveAsync(buffer, CancellationToken.None);
if (result.MessageType == WebSocketMessageType.Close)
{
throw new InvalidOperationException("Socket closed before a text frame was received.");
}
stream.Write(buffer, 0, result.Count);
if (result.EndOfMessage)
{
return Encoding.UTF8.GetString(stream.ToArray());
}
}
}
private sealed class AgentFixture : WebApplicationFactory<Program>
{
private readonly string _dataRoot = Path.Combine(Path.GetTempPath(), "TermRemoteCtl.Tests", Guid.NewGuid().ToString("N"));
private readonly RecordingSessionHost _sessionHost = new();
public RecordingSessionHost SessionHost => _sessionHost;
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.UseEnvironment("Development");
builder.ConfigureAppConfiguration((_, configBuilder) =>
{
configBuilder.AddInMemoryCollection(new Dictionary<string, string?>
{
["Agent:DataRoot"] = _dataRoot,
["Agent:BindAddress"] = "127.0.0.1",
["Agent:HttpsPort"] = "9443",
["Agent:WebSocketFrameFlushMilliseconds"] = "33",
["Agent:RingBufferLineLimit"] = "4000"
});
});
builder.ConfigureServices(services =>
{
services.RemoveAll<ISessionHost>();
services.AddSingleton<ISessionHost>(_sessionHost);
});
}
public async Task<WebSocket> ConnectTerminalAsync(string sessionId)
{
return await Server.CreateWebSocketClient()
.ConnectAsync(new Uri($"ws://localhost/ws/terminal?sessionId={sessionId}"), CancellationToken.None);
}
public new async ValueTask DisposeAsync()
{
await base.DisposeAsync();
if (Directory.Exists(_dataRoot))
{
Directory.Delete(_dataRoot, true);
}
}
}
private sealed class RecordingSessionHost : ISessionHost
{
private readonly Dictionary<string, int> _startCounts = new(StringComparer.Ordinal);
private readonly HashSet<string> _startedSessions = new(StringComparer.Ordinal);
public event EventHandler<TerminalOutputEventArgs>? OutputReceived
{
add { }
remove { }
}
public Task StartAsync(string sessionId, CancellationToken cancellationToken)
{
if (_startedSessions.Add(sessionId))
{
_startCounts[sessionId] = 1;
}
return Task.CompletedTask;
}
public Task WriteInputAsync(string sessionId, string input, CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
public Task ResizeAsync(string sessionId, int columns, int rows, CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
public int StartCountFor(string sessionId)
{
return _startCounts.TryGetValue(sessionId, out var count) ? count : 0;
}
}
private sealed record SessionResponse(
string SessionId,
string Name,
string Status,
DateTimeOffset CreatedAtUtc,
DateTimeOffset UpdatedAtUtc);
private sealed record TerminalAttachResponse(string SessionId, string Type);
}

View File

@ -0,0 +1,67 @@
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using TermRemoteCtl.Agent.History;
using TermRemoteCtl.Agent.Sessions;
namespace TermRemoteCtl.Agent.IntegrationTests;
public sealed class SessionHistoryApiTests
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
[Fact]
public async Task GetHistory_Returns_Recent_Lines_And_HasMoreAbove()
{
await using var fixture = new AgentFixture();
using var client = fixture.CreateClient();
var registry = fixture.Services.GetRequiredService<SessionRegistry>();
var session = registry.Create("codex-main", DateTimeOffset.UtcNow);
await registry.AppendOutputAsync(session.SessionId, "one\ntwo\nthree\n", CancellationToken.None);
var response = await client.GetFromJsonAsync<SessionHistoryResponse>(
$"/api/sessions/{session.SessionId}/history?lineCount=2",
JsonOptions);
Assert.NotNull(response);
Assert.Equal(session.SessionId, response!.SessionId);
Assert.Equal(["two", "three"], response.Lines);
Assert.True(response.HasMoreAbove);
}
private sealed class AgentFixture : WebApplicationFactory<Program>
{
private readonly string _dataRoot = Path.Combine(Path.GetTempPath(), "TermRemoteCtl.Tests", Guid.NewGuid().ToString("N"));
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.UseEnvironment("Development");
builder.ConfigureAppConfiguration((_, configBuilder) =>
{
configBuilder.AddInMemoryCollection(new Dictionary<string, string?>
{
["Agent:DataRoot"] = _dataRoot,
["Agent:BindAddress"] = "127.0.0.1",
["Agent:HttpsPort"] = "9443",
["Agent:WebSocketFrameFlushMilliseconds"] = "33",
["Agent:RingBufferLineLimit"] = "4000"
});
});
}
public new async ValueTask DisposeAsync()
{
await base.DisposeAsync();
if (Directory.Exists(_dataRoot))
{
Directory.Delete(_dataRoot, true);
}
}
}
private sealed record SessionHistoryResponse(string SessionId, IReadOnlyList<string> Lines, bool HasMoreAbove);
}

View File

@ -16,6 +16,7 @@ public class AgentOptionsPipelineTests
DataRoot = "C:\\ProgramData\\TermRemoteCtl",
BindAddress = "127.0.0.1",
HttpsPort = 9443,
HttpPort = 5067,
WebSocketFrameFlushMilliseconds = 33,
RingBufferLineLimit = 4000
};
@ -27,6 +28,26 @@ public class AgentOptionsPipelineTests
Assert.Equal(9443, uri.Port);
}
[Fact]
public void BuildListenUri_Uses_Http_Scheme_When_HttpsPort_Is_Disabled()
{
var options = new AgentOptions
{
DataRoot = "C:\\ProgramData\\TermRemoteCtl",
BindAddress = "10.0.2.2",
HttpsPort = 0,
HttpPort = 5067,
WebSocketFrameFlushMilliseconds = 33,
RingBufferLineLimit = 4000
};
var uri = AgentEndpointConfiguration.BuildListenUri(options);
Assert.Equal(Uri.UriSchemeHttp, uri.Scheme);
Assert.Equal("10.0.2.2", uri.Host);
Assert.Equal(5067, uri.Port);
}
[Fact]
public void ResolveOptions_Uses_DataRoot_Fallback_When_Config_Omits_DataRoot()
{
@ -34,6 +55,7 @@ public class AgentOptionsPipelineTests
{
["Agent:BindAddress"] = "127.0.0.1",
["Agent:HttpsPort"] = "9443",
["Agent:HttpPort"] = "5067",
["Agent:WebSocketFrameFlushMilliseconds"] = "33",
["Agent:RingBufferLineLimit"] = "4000"
});
@ -49,14 +71,42 @@ public class AgentOptionsPipelineTests
"TermRemoteCtl");
Assert.Equal(expectedDataRoot, options.DataRoot);
Assert.Equal(5067, options.HttpPort);
}
[Fact]
public void ResolveOptions_Allows_Loopback_Scoped_Http_Only_Local_Test_Mode()
{
var configuration = BuildConfiguration(new Dictionary<string, string?>
{
["Agent:BindAddress"] = "localhost",
["Agent:HttpsPort"] = "0",
["Agent:HttpPort"] = "5067",
["Agent:WebSocketFrameFlushMilliseconds"] = "33",
["Agent:RingBufferLineLimit"] = "4000"
});
var services = new ServiceCollection();
services.AddAgentOptions(configuration);
using var provider = services.BuildServiceProvider();
var options = provider.GetRequiredService<IOptions<AgentOptions>>().Value;
Assert.Equal(0, options.HttpsPort);
Assert.Equal(5067, options.HttpPort);
Assert.Equal("localhost", options.BindAddress);
Assert.Equal(Uri.UriSchemeHttp, AgentEndpointConfiguration.BuildListenUri(options).Scheme);
}
[Theory]
[InlineData("0", "33", "HttpsPort")]
[InlineData("9443", "0", "WebSocketFrameFlushMilliseconds")]
[InlineData("9443", "33", "RingBufferLineLimit")]
[InlineData("0", "0", "33", "HttpsPort")]
[InlineData("5067", "5067", "33", "must not be the same")]
[InlineData("9443", "-1", "33", "HttpPort")]
[InlineData("9443", "5067", "0", "WebSocketFrameFlushMilliseconds")]
[InlineData("9443", "5067", "33", "RingBufferLineLimit")]
public void ResolveOptions_Fails_When_Config_Is_Invalid(
string httpsPort,
string httpPort,
string flushMilliseconds,
string expectedFailure)
{
@ -64,6 +114,7 @@ public class AgentOptionsPipelineTests
{
["Agent:BindAddress"] = "127.0.0.1",
["Agent:HttpsPort"] = httpsPort,
["Agent:HttpPort"] = httpPort,
["Agent:WebSocketFrameFlushMilliseconds"] = flushMilliseconds,
["Agent:RingBufferLineLimit"] = expectedFailure == "RingBufferLineLimit" ? "0" : "4000"
});

View File

@ -11,7 +11,8 @@ public class AgentOptionsValidatorTests
{
DataRoot = "",
BindAddress = "0.0.0.0",
HttpsPort = 9443
HttpsPort = 9443,
HttpPort = 5067
};
var validator = new AgentOptionsValidator();
@ -32,6 +33,7 @@ public class AgentOptionsValidatorTests
DataRoot = "C:\\ProgramData\\TermRemoteCtl",
BindAddress = bindAddress,
HttpsPort = 9443,
HttpPort = 5067,
WebSocketFrameFlushMilliseconds = 33,
RingBufferLineLimit = 4000
};
@ -43,4 +45,89 @@ public class AgentOptionsValidatorTests
Assert.True(result.Failed);
Assert.Contains(result.Failures, failure => failure.Contains("BindAddress"));
}
[Theory]
[InlineData(-1)]
[InlineData(65536)]
public void Validate_Fails_When_HttpPort_Is_Invalid(int httpPort)
{
var options = new AgentOptions
{
DataRoot = "C:\\ProgramData\\TermRemoteCtl",
BindAddress = "0.0.0.0",
HttpsPort = 9443,
HttpPort = httpPort,
WebSocketFrameFlushMilliseconds = 33,
RingBufferLineLimit = 4000
};
var validator = new AgentOptionsValidator();
var result = validator.Validate("Agent", options);
Assert.True(result.Failed);
Assert.Contains(result.Failures, failure => failure.Contains("HttpPort"));
}
[Fact]
public void Validate_Succeeds_When_HttpsPort_Is_Disabled_And_HttpPort_Is_Configured()
{
var options = new AgentOptions
{
DataRoot = "C:\\ProgramData\\TermRemoteCtl",
BindAddress = "localhost",
HttpsPort = 0,
HttpPort = 5067,
WebSocketFrameFlushMilliseconds = 33,
RingBufferLineLimit = 4000
};
var validator = new AgentOptionsValidator();
var result = validator.Validate("Agent", options);
Assert.False(result.Failed);
}
[Fact]
public void Validate_Fails_When_Http_And_Https_Are_Both_Disabled()
{
var options = new AgentOptions
{
DataRoot = "C:\\ProgramData\\TermRemoteCtl",
BindAddress = "0.0.0.0",
HttpsPort = 0,
HttpPort = 0,
WebSocketFrameFlushMilliseconds = 33,
RingBufferLineLimit = 4000
};
var validator = new AgentOptionsValidator();
var result = validator.Validate("Agent", options);
Assert.True(result.Failed);
Assert.Contains(result.Failures, failure => failure.Contains("HttpsPort") || failure.Contains("HttpPort"));
}
[Fact]
public void Validate_Fails_When_Http_And_Https_Share_The_Same_Port()
{
var options = new AgentOptions
{
DataRoot = "C:\\ProgramData\\TermRemoteCtl",
BindAddress = "localhost",
HttpsPort = 5067,
HttpPort = 5067,
WebSocketFrameFlushMilliseconds = 33,
RingBufferLineLimit = 4000
};
var validator = new AgentOptionsValidator();
var result = validator.Validate("Agent", options);
Assert.True(result.Failed);
Assert.Contains(result.Failures, failure => failure.Contains("must not be the same"));
}
}

View File

@ -0,0 +1,3 @@
using System.Runtime.Versioning;
[assembly: SupportedOSPlatform("windows")]

View File

@ -1,4 +1,7 @@
using TermRemoteCtl.Agent.Sessions;
using Microsoft.Extensions.Options;
using TermRemoteCtl.Agent.Configuration;
using TermRemoteCtl.Agent.History;
namespace TermRemoteCtl.Agent.Tests.Sessions;
@ -7,7 +10,8 @@ public class SessionRegistryTests
[Fact]
public void Create_Returns_Record_And_List_Is_Ordered_By_Name()
{
var registry = new SessionRegistry();
using var harness = SessionRegistryHarness.Create();
var registry = harness.Registry;
var now = new DateTimeOffset(2026, 03, 27, 03, 00, 00, TimeSpan.Zero);
var zebra = registry.Create("Zebra", now);
@ -21,7 +25,8 @@ public class SessionRegistryTests
[Fact]
public void Create_Sets_Record_Metadata()
{
var registry = new SessionRegistry();
using var harness = SessionRegistryHarness.Create();
var registry = harness.Registry;
var now = new DateTimeOffset(2026, 03, 27, 03, 15, 00, TimeSpan.Zero);
var record = registry.Create("Shell", now);
@ -32,4 +37,71 @@ public class SessionRegistryTests
Assert.Equal(now, record.CreatedAtUtc);
Assert.Equal(now, record.UpdatedAtUtc);
}
[Fact]
public async Task AppendOutputAsync_Stores_Recent_Scrollback_Lines()
{
using var harness = SessionRegistryHarness.Create(lineLimit: 3);
var registry = harness.Registry;
var session = registry.Create("Shell", DateTimeOffset.UtcNow);
await registry.AppendOutputAsync(session.SessionId, "one\ntwo\nthree\n", CancellationToken.None);
var history = registry.GetHistory(session.SessionId, 2);
Assert.Equal(["two", "three"], history.Lines);
Assert.True(history.HasMoreAbove);
}
[Fact]
public async Task AppendOutputAsync_Persists_History_To_Log_File()
{
using var harness = SessionRegistryHarness.Create();
var registry = harness.Registry;
var session = registry.Create("Shell", DateTimeOffset.UtcNow);
await registry.AppendOutputAsync(session.SessionId, "dir\r\n", CancellationToken.None);
var logPath = Path.Combine(harness.DataRoot, "sessions", $"{session.SessionId}.log");
var content = await File.ReadAllTextAsync(logPath);
Assert.Equal("dir\r\n", content);
}
private sealed class SessionRegistryHarness : IDisposable
{
private SessionRegistryHarness(string dataRoot, SessionRegistry registry)
{
DataRoot = dataRoot;
Registry = registry;
}
public string DataRoot { get; }
public SessionRegistry Registry { get; }
public static SessionRegistryHarness Create(int lineLimit = 4000)
{
var dataRoot = Path.Combine(Path.GetTempPath(), "TermRemoteCtl.Tests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dataRoot);
var options = Options.Create(new AgentOptions
{
DataRoot = dataRoot,
RingBufferLineLimit = lineLimit,
});
var historyStore = new SessionHistoryStore(dataRoot);
var registry = new SessionRegistry(historyStore, options);
return new SessionRegistryHarness(dataRoot, registry);
}
public void Dispose()
{
if (Directory.Exists(DataRoot))
{
Directory.Delete(DataRoot, true);
}
}
}
}

View File

@ -0,0 +1,20 @@
using System.Security.Principal;
using TermRemoteCtl.Agent.Terminal;
namespace TermRemoteCtl.Agent.Tests.Terminal;
public class ConPtyInteropTests
{
[Fact]
public void GetCurrentWindowsIdentity_Returns_Identity_On_Windows()
{
if (!OperatingSystem.IsWindows())
{
return;
}
WindowsIdentity identity = ConPtyInterop.GetCurrentWindowsIdentity();
Assert.False(string.IsNullOrWhiteSpace(identity.Name));
}
}

View File

@ -0,0 +1,88 @@
using Microsoft.Extensions.Options;
using TermRemoteCtl.Agent.Configuration;
using TermRemoteCtl.Agent.History;
using TermRemoteCtl.Agent.Sessions;
using TermRemoteCtl.Agent.Terminal;
namespace TermRemoteCtl.Agent.Tests.Terminal;
public class ConPtySessionFactoryTests
{
[Fact]
public async Task FactoryBackedHost_Starts_Shell_On_Windows()
{
if (!OperatingSystem.IsWindows())
{
return;
}
using var harness = HostHarness.Create();
await using var host = harness.Host;
await host.StartAsync("smoke", CancellationToken.None);
}
[Fact]
public async Task FactoryBackedHost_WriteInput_Emits_Output()
{
if (!OperatingSystem.IsWindows())
{
return;
}
var output = new TaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously);
using var harness = HostHarness.Create();
await using var host = harness.Host;
host.OutputReceived += (_, args) =>
{
if (args.Chunk.Contains("smoke", StringComparison.OrdinalIgnoreCase))
{
output.TrySetResult(args.Chunk);
}
};
await host.StartAsync("smoke", CancellationToken.None);
await Task.Delay(1000);
await host.WriteInputAsync("smoke", "Write-Output smoke\r\n", CancellationToken.None);
var completed = await Task.WhenAny(output.Task, Task.Delay(TimeSpan.FromSeconds(20)));
Assert.True(ReferenceEquals(output.Task, completed), "Timed out waiting for shell output.");
Assert.Contains("smoke", await output.Task, StringComparison.OrdinalIgnoreCase);
}
private sealed class HostHarness : IDisposable
{
private HostHarness(string dataRoot, PowerShellSessionHost host)
{
DataRoot = dataRoot;
Host = host;
}
public string DataRoot { get; }
public PowerShellSessionHost Host { get; }
public static HostHarness Create()
{
var dataRoot = Path.Combine(Path.GetTempPath(), "TermRemoteCtl.Tests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dataRoot);
var options = Options.Create(new AgentOptions
{
DataRoot = dataRoot,
RingBufferLineLimit = 4000,
});
var registry = new SessionRegistry(new SessionHistoryStore(dataRoot), options);
var host = new PowerShellSessionHost(new ConPtySessionFactory(), registry);
return new HostHarness(dataRoot, host);
}
public void Dispose()
{
if (Directory.Exists(DataRoot))
{
Directory.Delete(DataRoot, true);
}
}
}
}

View File

@ -0,0 +1,126 @@
using Microsoft.Extensions.Options;
using TermRemoteCtl.Agent.Configuration;
using TermRemoteCtl.Agent.History;
using TermRemoteCtl.Agent.Sessions;
using TermRemoteCtl.Agent.Terminal;
namespace TermRemoteCtl.Agent.Tests.Terminal;
public class PowerShellSessionHostTests
{
[Fact]
public async Task ResizeAsync_Forwards_To_ConPty_Session()
{
var factory = new FakeConPtySessionFactory();
using var harness = HostHarness.Create(factory);
await using var host = harness.Host;
await host.StartAsync("alpha", CancellationToken.None);
await host.ResizeAsync("alpha", 120, 40, CancellationToken.None);
Assert.Equal((120, 40), factory.Session.ResizeCalls.Single());
}
[Fact]
public async Task Session_Output_Is_Captured_In_Registry_History()
{
var factory = new FakeConPtySessionFactory();
using var harness = HostHarness.Create(factory, lineLimit: 3);
var session = harness.Registry.Create("Shell", DateTimeOffset.UtcNow);
await using var host = harness.Host;
await host.StartAsync(session.SessionId, CancellationToken.None);
factory.Session.EmitOutput(session.SessionId, "one\ntwo\nthree\n");
var history = harness.Registry.GetHistory(session.SessionId, 2);
Assert.Equal(["two", "three"], history.Lines);
Assert.True(history.HasMoreAbove);
}
private sealed class HostHarness : IDisposable
{
private HostHarness(string dataRoot, SessionRegistry registry, PowerShellSessionHost host)
{
DataRoot = dataRoot;
Registry = registry;
Host = host;
}
public string DataRoot { get; }
public SessionRegistry Registry { get; }
public PowerShellSessionHost Host { get; }
public static HostHarness Create(FakeConPtySessionFactory factory, int lineLimit = 4000)
{
var dataRoot = Path.Combine(Path.GetTempPath(), "TermRemoteCtl.Tests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dataRoot);
var options = Options.Create(new AgentOptions
{
DataRoot = dataRoot,
RingBufferLineLimit = lineLimit,
});
var registry = new SessionRegistry(new SessionHistoryStore(dataRoot), options);
var host = new PowerShellSessionHost(factory, registry);
return new HostHarness(dataRoot, registry, host);
}
public void Dispose()
{
if (Directory.Exists(DataRoot))
{
Directory.Delete(DataRoot, true);
}
}
}
private sealed class FakeConPtySessionFactory : IConPtySessionFactory
{
public FakeConPtySession Session { get; } = new();
public IConPtySession Create(string sessionId)
{
Session.SessionId = sessionId;
return Session;
}
}
private sealed class FakeConPtySession : IConPtySession
{
public string SessionId { get; set; } = string.Empty;
public event EventHandler<TerminalOutputEventArgs>? OutputReceived;
public List<(int Columns, int Rows)> ResizeCalls { get; } = new();
public Task StartAsync(CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
public Task WriteInputAsync(string input, CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
public Task ResizeAsync(int columns, int rows, CancellationToken cancellationToken)
{
ResizeCalls.Add((columns, rows));
return Task.CompletedTask;
}
public void EmitOutput(string sessionId, string chunk)
{
OutputReceived?.Invoke(this, new TerminalOutputEventArgs(sessionId, chunk));
}
public ValueTask DisposeAsync()
{
return ValueTask.CompletedTask;
}
}
}

View File

@ -0,0 +1,46 @@
using TermRemoteCtl.Agent.Terminal;
namespace TermRemoteCtl.Agent.Tests.Terminal;
public class TerminalFrameBatcherTests
{
[Fact]
public async Task FlushAsync_Combines_Multiple_Writes_Into_One_Frame()
{
var frames = new List<string>();
await using var batcher = new TerminalFrameBatcher(
TimeSpan.FromMilliseconds(33),
frame =>
{
frames.Add(frame);
return Task.CompletedTask;
});
batcher.Append("abc");
batcher.Append("def");
await batcher.FlushAsync();
Assert.Equal(["abcdef"], frames);
}
[Fact]
public async Task Append_Emits_Batched_Frame_After_Interval()
{
var frames = new List<string>();
await using var batcher = new TerminalFrameBatcher(
TimeSpan.FromMilliseconds(10),
frame =>
{
frames.Add(frame);
return Task.CompletedTask;
});
batcher.Append("hello");
batcher.Append(" world");
await Task.Delay(50);
Assert.Equal(["hello world"], frames);
}
}

View File

@ -0,0 +1,4 @@
bytesRead=88
[?9001h[?1004h[?25lsmoke-from-probe
]0;C:\WINDOWS\system32\cmd.exe[?25h
exitCode=0x00000000

View File

@ -0,0 +1,11 @@
# Windows Agent Setup
## Local Development
1. Install .NET 8 SDK.
2. Run `dotnet run --project apps/windows_agent/src/TermRemoteCtl.Agent/TermRemoteCtl.Agent.csproj`.
3. Confirm the data root exists under `C:\ProgramData\TermRemoteCtl`.
## First Runtime Goal
Run the agent under Windows 11 while an interactive desktop user is signed in.

View File

@ -0,0 +1,97 @@
# Terminal Next Steps Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Finish the terminal feature from "developer-ready" to "stable for manual product testing" by closing emulator-local transport gaps, shrinking `TerminalPage`, and fixing issues found during smoke testing.
**Architecture:** Keep the helper-backed ConPTY backend and the current HTTP/HTTPS + WebSocket protocol. Continue moving terminal UI state out of `TerminalPage` into focused controllers/coordinators, while preserving the current live attach, reconnect, resize, and history behavior.
**Tech Stack:** Flutter, Riverpod, Dio, xterm, ASP.NET Core, WebSocket, helper-backed ConPTY.
---
### Task 1: Stabilize Local Emulator Test Mode
**Files:**
- Modify: `apps/mobile_app/android/app/src/main/AndroidManifest.xml`
- Modify: `apps/windows_agent/src/TermRemoteCtl.Agent/appsettings.json`
- Modify: `apps/windows_agent/src/TermRemoteCtl.Agent/Configuration/AgentOptions.cs`
- Modify: `apps/windows_agent/src/TermRemoteCtl.Agent/Configuration/AgentOptionsValidator.cs`
- Modify: `apps/windows_agent/src/TermRemoteCtl.Agent/Configuration/AgentOptionsServiceCollectionExtensions.cs`
- Modify: `apps/windows_agent/src/TermRemoteCtl.Agent/Configuration/AgentEndpointConfiguration.cs`
- Test: `apps/windows_agent/tests/TermRemoteCtl.Agent.Tests/Configuration/AgentOptionsValidatorTests.cs`
- Test: `apps/windows_agent/tests/TermRemoteCtl.Agent.Tests/Configuration/AgentOptionsPipelineTests.cs`
- [ ] Verify the current local-test path still works with `http://10.0.2.2:5067`.
Run: `Invoke-WebRequest -UseBasicParsing 'http://127.0.0.1:5067/health'`
Expected: `{"status":"ok"}`
- [ ] Run the configuration-focused .NET tests.
Run: `dotnet test apps/windows_agent/tests/TermRemoteCtl.Agent.Tests/TermRemoteCtl.Agent.Tests.csproj --filter "AgentOptionsValidatorTests|AgentOptionsPipelineTests"`
Expected: PASS
- [ ] Rebuild the Android release APK used for local emulator testing.
Run: `C:\tools\flutter\bin\flutter.bat build apk --release`
Expected: `Built build\app\outputs\flutter-apk\app-release.apk`
### Task 2: Finish Extracting TerminalPage Orchestration
**Files:**
- Modify: `apps/mobile_app/lib/features/terminal/terminal_page.dart`
- Modify: `apps/mobile_app/lib/features/terminal/terminal_interaction_controller.dart`
- Modify: `apps/mobile_app/lib/features/terminal/terminal_controller.dart`
- Create or modify as needed: `apps/mobile_app/lib/features/terminal/terminal_session_coordinator.dart`
- Test: `apps/mobile_app/test/features/terminal/terminal_interaction_controller_test.dart`
- Test: `apps/mobile_app/test/widget_test.dart`
- [ ] Move reconnect scheduling, history loading window size, and socket attach lifecycle out of `TerminalPage` and into a focused coordinator/controller.
- [ ] Keep `TerminalPage` primarily responsible for widget composition and command callbacks.
- [ ] Preserve existing behaviors:
- attach waits for ack
- resize sends immediately after attach
- scrollback mode can load older lines
- new live output while browsing history shows a return-to-live affordance
- [ ] Run Flutter tests after each extraction step.
Run: `C:\tools\flutter\bin\flutter.bat test test/features/terminal/terminal_interaction_controller_test.dart test/widget_test.dart`
Expected: PASS
### Task 3: Improve User-Facing Error Guidance
**Files:**
- Modify: `apps/mobile_app/lib/features/sessions/session_list_page.dart`
- Modify: `apps/mobile_app/lib/features/terminal/terminal_page.dart`
- Modify: `apps/mobile_app/lib/core/network/agent_api_client.dart`
- Test: `apps/mobile_app/test/widget_test.dart`
- [ ] Replace raw Dio/socket exception dumps in the UI with actionable messages for the main local-test cases:
- agent unreachable
- HTTPS certificate rejected
- session history failed but live attach may still recover
- [ ] Add copy that explicitly tells emulator users to try `http://10.0.2.2:5067` when relevant.
- [ ] Verify widget coverage still passes.
Run: `C:\tools\flutter\bin\flutter.bat test test/widget_test.dart`
Expected: PASS
### Task 4: Run Manual Smoke and Fix What Breaks
**Files:**
- Reference: `docs/testing/manual-smoke-checklist.md`
- Modify: terminal/mobile/agent files only as required by issues discovered in smoke testing
- [ ] Run the current manual smoke checklist against the emulator-local HTTP setup.
- [ ] Record which steps fail and fix them one by one.
- [ ] After each fix, re-run the smallest relevant automated test set before re-testing manually.
- [ ] Final verification set for this phase:
Run: `C:\tools\flutter\bin\flutter.bat test test/widget_test.dart test/features/terminal/terminal_interaction_controller_test.dart test/features/terminal/terminal_controller_test.dart test/features/terminal/terminal_socket_session_test.dart test/core/network/agent_api_client_test.dart`
Expected: PASS
- [ ] Final backend verification set for this phase:
Run: `dotnet test apps/windows_agent/tests/TermRemoteCtl.Agent.Tests/TermRemoteCtl.Agent.Tests.csproj`
Expected: PASS
Run: `dotnet test apps/windows_agent/tests/TermRemoteCtl.Agent.IntegrationTests/TermRemoteCtl.Agent.IntegrationTests.csproj --filter "SessionHistoryApiTests|SessionFlowTests|TerminalWebSocketHandlerTests"`
Expected: PASS

View File

@ -0,0 +1,11 @@
# Manual Smoke Checklist
1. Start the Windows agent on the primary Windows machine.
2. Pair the iPhone app with a fresh one-time pairing code.
3. Create `codex-main` and `cloud-code` sessions.
4. Start a noisy command in `codex-main`.
5. Background the app for one minute.
6. Reopen the app and confirm the same session is still alive.
7. Scroll upward and confirm older history loads.
8. Trigger one preset command and confirm it appears in the terminal.
9. Terminate one session and confirm only that session exits.