Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 13 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,15 @@ directory and listener with `TINYRACK_CODER_HOME` and
`TINYRACK_CODER_LISTEN`. Without an override, Linux uses the XDG config/state
directories, macOS uses Application Support, and Windows uses AppData.

Use the gear button in the connected app to configure multiple OpenAI or
OpenAI-compatible endpoints. Each provider can use Responses or Chat
Completions, discover model IDs through `/models`, and retain manually entered
models and capability overrides. Explicit model diagnostics make a small
streaming tool-call request and may incur provider charges.
The app shell does not require a daemon connection. Desktop enables its
app-owned embedded daemon by default, while mobile remains remote-only. Global
Settings can disable the embedded daemon and save any number of independent
`ws://` or `wss://` remote daemon profiles. Offline profiles and the last
selected host remain navigable.

Provider setup belongs to a connected daemon. Embedded clients carry a separate
local-admin token and can mutate provider credentials; ordinary remote clients
carry only the bearer token and see provider settings read-only.

Desktop and mobile use separate targets so the mobile bootstrap never starts a
daemon. Run these commands from `apps/coder_app`:
Expand All @@ -42,8 +46,7 @@ flutter run -d linux -t lib/main_desktop.dart
flutter run -t lib/main_mobile.dart
```

LAN mode is intentionally plain `ws://` for trusted local networks only. Bind a
standalone daemon to `0.0.0.0:7337` explicitly and provide a 256-bit token via
`TINYRACK_CODER_TOKEN`. Do not expose this listener to the public internet.
Remote clients can inspect the provider catalog but provider and credential
mutations are restricted to loopback connections.
The daemon intentionally does not implement TLS or certificate bypasses. Keep it
bound to loopback and terminate TLS in a reverse proxy for remote access. See
[`docs/remote-daemon.md`](docs/remote-daemon.md) for Caddy/Nginx WebSocket,
authentication-header, and development-data reset examples.
111 changes: 96 additions & 15 deletions apps/coder_app/integration_test/debug_e2e_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ import 'dart:io';

import 'package:coder_agent/coder_agent.dart';
import 'package:coder_app/src/app.dart';
import 'package:coder_app/src/desktop_bootstrap.dart';
import 'package:coder_app/src/app_services.dart';
import 'package:coder_app/src/host_models.dart';
import 'package:coder_app/src/host_ports.dart';
import 'package:coder_client/coder_client.dart';
import 'package:coder_daemon/coder_daemon.dart';
import 'package:flutter/material.dart';
Expand All @@ -14,13 +16,19 @@ void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();

testWidgets(
'embedded daemon streams, approves a patch, and restores timeline',
'app switches hosts, streams, approves a patch, and restores timeline',
(tester) async {
FlutterSecureStorage.setMockInitialValues(<String, String>{});
final home = await Directory.systemTemp.createTemp('coder-e2e-home-');
final workspace = await Directory.systemTemp.createTemp(
'coder-e2e-workspace-',
);
final remoteHome = await Directory.systemTemp.createTemp(
'coder-e2e-remote-home-',
);
final remoteWorkspace = await Directory.systemTemp.createTemp(
'coder-e2e-remote-workspace-',
);
final handle = await EmbeddedDaemonHandle.start(
DaemonConfig(
homeDirectory: home.path,
Expand All @@ -30,47 +38,108 @@ void main() {
),
provider: _PatchProvider(),
);
final remoteHandle = await DaemonApplication.start(
DaemonConfig(
homeDirectory: remoteHome.path,
port: 0,
bearerToken: 'remote-token-0123456789abcdef0123456789',
useEnvironmentCredentials: false,
),
provider: _PatchProvider(),
);
addTearDown(() async {
await handle.stop();
await remoteHandle.stop();
if (home.existsSync()) home.deleteSync(recursive: true);
if (remoteHome.existsSync()) remoteHome.deleteSync(recursive: true);
if (workspace.existsSync()) workspace.deleteSync(recursive: true);
if (remoteWorkspace.existsSync()) {
remoteWorkspace.deleteSync(recursive: true);
}
});
final endpoint = HostEndpoint(
websocketUri: handle.boundEndpoint,
token: handle.bearerToken,
);
final setupClient = await CoderClient.connect(
endpoint: endpoint,
credentials: DaemonCredentials(
bearerToken: handle.bearerToken,
adminToken: handle.adminToken,
),
clientId: 'e2e-setup',
clientKind: 'integration-test',
);
await setupClient.registerWorkspace(
id: 'workspace-e2e',
workspaceId: 'workspace-e2e',
checkoutId: 'checkout-e2e',
rootPath: workspace.path,
name: 'E2E Workspace',
);
await setupClient.close();
final remoteSetupClient = await CoderClient.connect(
endpoint: HostEndpoint(websocketUri: remoteHandle.boundEndpoint),
credentials: const DaemonCredentials(
bearerToken: 'remote-token-0123456789abcdef0123456789',
),
clientId: 'remote-e2e-setup',
clientKind: 'integration-test',
);
await remoteSetupClient.registerWorkspace(
workspaceId: 'remote-workspace-e2e',
checkoutId: 'remote-checkout-e2e',
rootPath: remoteWorkspace.path,
name: 'Remote Workspace',
);
await remoteSetupClient.close();

final now = DateTime.utc(2026, 8, 3);
final appStore = MemoryAppStore(
profiles: <RemoteDaemonProfile>[
RemoteDaemonProfile(
id: 'remote',
label: 'Remote daemon',
websocketUri: remoteHandle.boundEndpoint,
autoConnect: true,
createdAt: now,
updatedAt: now,
),
],
tokens: const <String, String>{
'remote': 'remote-token-0123456789abcdef0123456789',
},
);

await tester.pumpWidget(
CoderApp(
bootstrap: DesktopBootstrap(
launcher: _ExistingLauncher(handle),
services: AppServices(
settings: appStore,
profiles: appStore,
credentials: appStore,
clients: const WebSocketHostClientFactory(),
clientKind: 'desktop-integration-test',
embeddedLauncher: _ExistingLauncher(handle),
),
),
);
await _pumpUntil(tester, find.text('내장 daemon'));
await _pumpUntil(tester, find.text('Remote daemon'));
await _pumpUntil(tester, find.text('E2E Workspace'));
await tester.tap(find.text('E2E Workspace'));
await _pumpUntil(tester, find.text('새 agent를 만들어 시작하세요.'));
await tester.tap(find.byTooltip('Agent 생성'));
await _pumpUntil(tester, find.text('Remote Workspace'));
await tester.tap(find.text('E2E Workspace').last);
await tester.pumpAndSettle();
await tester.tap(find.text('생성'));
await _pumpUntil(
tester,
find.text('요청을 입력해 coding agent를 시작하세요.'),
find.text(workspace.path),
);
await tester.tap(find.text('E2E Workspace').last);
await _pumpUntil(tester, find.text('새 session 시작'));
await tester.tap(find.text('새 session 시작'));
await tester.pumpAndSettle();
await tester.tap(find.text('생성'));
await _pumpUntil(tester, find.text('코딩 요청을 입력하세요.'));

await tester.enterText(find.byType(TextField).last, 'Create result.txt');
await tester.tap(find.byIcon(Icons.arrow_upward));
await tester.testTextInput.receiveAction(TextInputAction.done);
await _pumpUntil(tester, find.text('승인 필요 · apply_patch'));
await tester.tap(find.text('승인'));
await _pumpUntil(
Expand All @@ -84,10 +153,14 @@ void main() {

final reconnected = await CoderClient.connect(
endpoint: endpoint,
credentials: DaemonCredentials(
bearerToken: handle.bearerToken,
adminToken: handle.adminToken,
),
clientId: 'e2e-reconnect',
clientKind: 'integration-test',
);
final agents = await reconnected.listAgents(workspaceId: 'workspace-e2e');
final agents = await reconnected.listAgents(worktreeId: 'checkout-e2e');
expect(agents, hasLength(1));
final timeline = await reconnected.subscribeTimeline(agents.single.id);
expect(timeline.map((event) => event.type), contains('turn.completed'));
Expand Down Expand Up @@ -129,10 +202,18 @@ final class _ExistingSession implements EmbeddedDaemonSession {
final EmbeddedDaemonHandle handle;

@override
String get bearerToken => handle.bearerToken;
DaemonCredentials get credentials => DaemonCredentials(
bearerToken: handle.bearerToken,
adminToken: handle.adminToken,
);

@override
HostEndpoint get endpoint => HostEndpoint(
websocketUri: handle.boundEndpoint,
);

@override
Uri get boundEndpoint => handle.boundEndpoint;
String get serverId => handle.serverId;

@override
Future<void> stop() => handle.stop();
Expand Down
43 changes: 37 additions & 6 deletions apps/coder_app/integration_test/remote_bootstrap_smoke_test.dart
Original file line number Diff line number Diff line change
@@ -1,18 +1,49 @@
import 'package:coder_app/src/app.dart';
import 'package:coder_app/src/remote_bootstrap.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:coder_app/src/app_services.dart';
import 'package:coder_app/src/host_models.dart';
import 'package:coder_app/src/host_ports.dart';
import 'package:coder_client/coder_client.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';

void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();

testWidgets('mobile bootstrap remains remote-only', (tester) async {
FlutterSecureStorage.setMockInitialValues(<String, String>{});
await tester.pumpWidget(CoderApp(bootstrap: RemoteBootstrap()));
final store = MemoryAppStore(
settings: const AppSettings(embeddedDaemonEnabled: false),
);
await tester.pumpWidget(
CoderApp(
services: AppServices(
settings: store,
profiles: store,
credentials: store,
clients: const _UnusedClients(),
clientKind: 'mobile-integration-test',
),
),
);
await tester.pumpAndSettle();

expect(find.text('모바일은 원격 daemon에만 연결합니다.'), findsOneWidget);
expect(find.text('Daemon WebSocket 주소'), findsOneWidget);
expect(find.text('설정된 daemon이 없습니다.'), findsOneWidget);
await tester.tap(find.byTooltip('설정'));
await tester.pumpAndSettle();
await tester.tap(find.text('Daemon'));
await tester.pumpAndSettle();
expect(find.text('내장 daemon'), findsNothing);
expect(find.text('원격 daemon 추가'), findsOneWidget);
});
}

final class _UnusedClients implements HostClientFactory {
const _UnusedClients();

@override
Future<CoderApi> connect({
required HostEndpoint endpoint,
required DaemonCredentials credentials,
required String clientId,
required String clientKind,
}) => throw StateError('No host should connect in a remote-only smoke test.');
}
6 changes: 3 additions & 3 deletions apps/coder_app/lib/main_desktop.dart
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import 'package:coder_app/src/app.dart';
import 'package:coder_app/src/bootstrap.dart';
import 'package:coder_app/src/app_services.dart';
import 'package:coder_app/src/desktop_bootstrap.dart';
import 'package:flutter/material.dart';

/// Starts the desktop widget tree with an injectable bootstrap.
Future<void> runDesktopApp({AppBootstrap? bootstrap}) async {
Future<void> runDesktopApp({AppServices? services}) async {
WidgetsFlutterBinding.ensureInitialized();
runApp(CoderApp(bootstrap: bootstrap ?? DesktopBootstrap()));
runApp(CoderApp(services: services ?? await createDesktopServices()));
}

/// Starts the production desktop application.
Expand Down
6 changes: 3 additions & 3 deletions apps/coder_app/lib/main_mobile.dart
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import 'package:coder_app/src/app.dart';
import 'package:coder_app/src/bootstrap.dart';
import 'package:coder_app/src/app_services.dart';
import 'package:coder_app/src/remote_bootstrap.dart';
import 'package:flutter/material.dart';

/// Starts the mobile widget tree with an injectable remote-only bootstrap.
Future<void> runMobileApp({AppBootstrap? bootstrap}) async {
Future<void> runMobileApp({AppServices? services}) async {
WidgetsFlutterBinding.ensureInitialized();
runApp(CoderApp(bootstrap: bootstrap ?? RemoteBootstrap()));
runApp(CoderApp(services: services ?? await createRemoteServices()));
}

/// Starts the production mobile application.
Expand Down
Loading
Loading