oto/apps/runner/test/oto_agent_registration_test.dart
toki 79f85337e4 feat: runner proto-socket transport hardening - compatibility fallback and test updates
- Rename _defaultJobLoop to _compatibilityPollingLoop for clarity
- Add compatibility fallback logging when proto-socket is unavailable
- Move 04+01_compat_boundary to archive (completed)
- Update agent domain rules and smoke test config
- Add registration client timeout
- Update server routes and tests for proto-socket hardening
- Update agent registration and server connection smoke tests
2026-06-21 04:49:43 +09:00

1504 lines
50 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:test/test.dart';
import 'package:oto/oto/agent/agent_config.dart';
import 'package:oto/oto/agent/agent_runner.dart';
import 'package:oto/oto/agent/edge_registration_client.dart' show EdgeEndpoint;
import 'package:oto/oto/agent/registration_client.dart';
import 'package:oto/oto/agent/oto_server_job_client.dart';
import 'package:oto/oto/agent/remote_run_executor.dart';
import 'package:oto/oto/agent/runner_capability_provider.dart';
import 'package:oto/oto/commands/runner_command_capability_provider.dart';
import 'package:oto/oto/application.dart';
import 'package:oto/oto/core/build_result.dart';
import 'package:oto/oto/core/execution_context.dart';
import 'package:oto/oto/agent/oto/runner.pb.dart' as oto;
class FakeAgentSession implements AgentSession {
@override
final RegistrationResult result;
bool closed = false;
FakeAgentSession(this.result);
@override
Future<void> close() async {
closed = true;
}
}
class FakeRegistrationClient extends RegistrationClient {
final RegistrationResult Function(AgentConfig config) _onRegister;
FakeAgentSession? lastSession;
FakeRegistrationClient(this._onRegister);
@override
Future<AgentSession> openSession(AgentConfig config) async {
final session = FakeAgentSession(_onRegister(config));
lastSession = session;
return session;
}
}
class FakeRunnerCapabilityProvider implements RunnerCapabilityProvider {
final RunnerCapabilitySnapshot _snapshot;
FakeRunnerCapabilityProvider(this._snapshot);
@override
RunnerCapabilitySnapshot snapshot() => _snapshot;
}
void main() {
const validBootstrapYaml = '''
agent:
id: "agent-123"
alias: "my-agent"
enrollment_token: "token-456"
edge:
url: "127.0.0.1:8080"
runtime:
install_dir: "/usr/bin"
workspace_root: "/var/oto"
log_dir: "/var/log/oto"
''';
late AgentConfig validConfig;
setUp(() {
validConfig = AgentConfig.fromYamlContent(validBootstrapYaml);
});
group('EdgeEndpoint', () {
test('EdgeEndpoint parses host and port from config edge url', () {
final endpoint1 = EdgeEndpoint.parse('127.0.0.1:8080');
expect(endpoint1.host, '127.0.0.1');
expect(endpoint1.port, 8080);
final endpoint2 = EdgeEndpoint.parse('http://edge-server:9000');
expect(endpoint2.host, 'edge-server');
expect(endpoint2.port, 9000);
final endpoint3 = EdgeEndpoint.parse('https://secure-edge');
expect(endpoint3.host, 'secure-edge');
expect(endpoint3.port, 443);
final endpoint4 = EdgeEndpoint.parse('edge-no-port');
expect(endpoint4.host, 'edge-no-port');
expect(endpoint4.port, 80);
});
});
group('EdgeRegistrationClient.register one-shot', () {
test('register returns session result and closes the session', () async {
final fakeClient = FakeRegistrationClient((config) {
return RegistrationResult.accepted('node-123', 'alias-123', {
'concurrency': 1,
});
});
final result = await fakeClient.register(validConfig);
expect(result.accepted, isTrue);
expect(result.nodeId, 'node-123');
expect(fakeClient.lastSession, isNotNull);
expect(fakeClient.lastSession!.closed, isTrue);
});
});
group('OtoServerRegistrationClient (Compatibility Fallback HTTP)', () {
test('builds OTO Server registration request from config', () {
final client = OtoServerRegistrationClient(
commandTypes: ['Shell', 'Git'],
);
final request = client.buildRegisterRunnerRequest(validConfig);
expect(request.enrollmentToken, 'token-456');
expect(request.runnerId, 'agent-123');
expect(request.alias, 'my-agent');
expect(request.protocolVersion, otoRunnerProtocolVersion);
expect(request.capability.name, otoRunnerCapabilityName);
expect(request.commandCatalog.commandTypes, ['Shell', 'Git']);
});
test('builds registration request from injected capability provider', () {
final client = OtoServerRegistrationClient(
capabilityProvider: FakeRunnerCapabilityProvider(
RunnerCapabilitySnapshot(
name: 'custom-runner',
version: '2.0.0',
commandTypes: ['Print', 'ShellFile'],
),
),
);
final request = client.buildRegisterRunnerRequest(validConfig);
expect(request.capability.name, 'custom-runner');
expect(request.capability.version, '2.0.0');
expect(request.commandCatalog.commandTypes, ['Print', 'ShellFile']);
});
test(
'builds registration request using CommandCatalogRunnerCapabilityProvider',
() {
final client = OtoServerRegistrationClient(
capabilityProvider: const CommandCatalogRunnerCapabilityProvider(),
);
final request = client.buildRegisterRunnerRequest(validConfig);
expect(request.enrollmentToken, 'token-456');
expect(request.runnerId, 'agent-123');
expect(request.capability.name, otoRunnerCapabilityName);
expect(request.capability.version, otoRunnerCapabilityVersion);
expect(request.commandCatalog.commandTypes, isNotEmpty);
expect(request.commandCatalog.commandTypes, contains('Shell'));
},
);
test(
'posts registration payload using CommandCatalogRunnerCapabilityProvider',
() async {
final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
final captured = Completer<Map<String, dynamic>>();
final serverSub = server.listen((request) async {
if (request.uri.path == '/api/v1/runners/register') {
final body = await utf8.decoder.bind(request).join();
captured.complete(jsonDecode(body) as Map<String, dynamic>);
request.response
..headers.contentType = ContentType.json
..write(jsonEncode({'accepted': true, 'runner_id': 'agent-123'}));
} else {
request.response
..headers.contentType = ContentType.json
..write(jsonEncode({'success': true}));
}
await request.response.close();
});
try {
final config = AgentConfig(
agent: validConfig.agent,
server: ServerConnectionConfig(
url: 'http://${server.address.host}:${server.port}',
),
runtime: validConfig.runtime,
);
final client = OtoServerRegistrationClient(
capabilityProvider: const CommandCatalogRunnerCapabilityProvider(),
);
final result = await client.register(config);
expect(result.accepted, isTrue);
final payload = await captured.future;
expect(payload['capability'], containsPair('name', 'oto-runner'));
expect(
(payload['command_catalog']['command_types'] as List<dynamic>),
isNotEmpty,
);
expect(
(payload['command_catalog']['command_types'] as List<dynamic>),
contains('Shell'),
);
} finally {
await serverSub.cancel();
await server.close(force: true);
}
},
);
test('posts registration request to OTO Server endpoint', () async {
final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
final captured = Completer<Map<String, dynamic>>();
final serverSub = server.listen((request) async {
expect(request.method, 'POST');
if (request.uri.path == '/api/v1/runners/register') {
final body = await utf8.decoder.bind(request).join();
captured.complete(jsonDecode(body) as Map<String, dynamic>);
request.response
..headers.contentType = ContentType.json
..write(
jsonEncode({
'accepted': true,
'runner_id': 'agent-123',
'alias': 'my-agent',
}),
);
} else {
request.response
..headers.contentType = ContentType.json
..write(jsonEncode({'success': true}));
}
await request.response.close();
});
try {
final config = AgentConfig(
agent: validConfig.agent,
server: ServerConnectionConfig(
url: 'http://${server.address.host}:${server.port}',
),
runtime: validConfig.runtime,
);
final client = OtoServerRegistrationClient(
commandTypes: ['Shell', 'Git'],
);
final result = await client.register(config);
expect(result.accepted, isTrue);
expect(result.runnerId, 'agent-123');
final payload = await captured.future;
expect(payload['enrollment_token'], 'token-456');
expect(payload['runner_id'], 'agent-123');
expect(payload['alias'], 'my-agent');
expect(payload['protocol_version'], otoRunnerProtocolVersion);
expect(payload['capability'], containsPair('name', 'oto-runner'));
expect(
payload['capability'],
containsPair('version', otoRunnerCapabilityVersion),
);
expect(
payload['command_catalog'],
containsPair('command_types', ['Shell', 'Git']),
);
} finally {
await serverSub.cancel();
await server.close(force: true);
}
});
test(
'registration client handles incompatible runner error response',
() async {
final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
final serverSub = server.listen((request) async {
request.response
..headers.contentType = ContentType.json
..write(
jsonEncode({
'accepted': false,
'reject_reason':
'unsupported protocol version: "oto.runner.v2"',
'error': {
'code': 'incompatible_runner',
'message': 'unsupported protocol version: "oto.runner.v2"',
},
}),
);
await request.response.close();
});
try {
final config = AgentConfig(
agent: validConfig.agent,
server: ServerConnectionConfig(
url: 'http://${server.address.host}:${server.port}',
),
runtime: validConfig.runtime,
);
final client = OtoServerRegistrationClient(
commandTypes: ['Shell', 'Git'],
);
final result = await client.register(config);
expect(result.accepted, isFalse);
expect(
result.rejectReason,
'unsupported protocol version: "oto.runner.v2"',
);
} finally {
await serverSub.cancel();
await server.close(force: true);
}
},
);
test('sends first heartbeat and disconnects on close', () async {
final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
final registerReceived = Completer<void>();
final heartbeatReceived = Completer<void>();
final disconnectReceived = Completer<void>();
final serverSub = server.listen((request) async {
if (request.method == 'POST' &&
request.uri.path == '/api/v1/runners/register') {
registerReceived.complete();
request.response
..headers.contentType = ContentType.json
..write(
jsonEncode({
'accepted': true,
'runner_id': 'agent-123',
'alias': 'my-agent',
}),
);
} else if (request.method == 'POST' &&
request.uri.path == '/api/v1/runners/agent-123/heartbeat') {
heartbeatReceived.complete();
request.response
..headers.contentType = ContentType.json
..write(jsonEncode({'success': true}));
} else if (request.method == 'POST' &&
request.uri.path == '/api/v1/runners/agent-123/disconnect') {
disconnectReceived.complete();
request.response
..headers.contentType = ContentType.json
..write(jsonEncode({'success': true}));
}
await request.response.close();
});
try {
final config = AgentConfig(
agent: validConfig.agent,
server: ServerConnectionConfig(
url: 'http://${server.address.host}:${server.port}',
),
runtime: validConfig.runtime,
);
final client = OtoServerRegistrationClient(
commandTypes: ['Shell', 'Git'],
heartbeatInterval: const Duration(milliseconds: 100),
);
final session = await client.openSession(config);
expect(session, isA<OtoServerJobSession>());
// First heartbeat should be sent automatically
await heartbeatReceived.future.timeout(const Duration(seconds: 2));
// Close session
await session.close();
// Disconnect should be sent
await disconnectReceived.future.timeout(const Duration(seconds: 2));
} finally {
await serverSub.cancel();
await server.close(force: true);
}
});
test(
'does not send heartbeat or disconnect on rejected registration',
() async {
final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
var heartbeatReceived = false;
var disconnectReceived = false;
final serverSub = server.listen((request) async {
if (request.method == 'POST' &&
request.uri.path == '/api/v1/runners/register') {
request.response
..headers.contentType = ContentType.json
..write(
jsonEncode({
'accepted': false,
'reject_reason': 'Invalid token',
}),
);
} else if (request.uri.path.contains('heartbeat')) {
heartbeatReceived = true;
request.response
..headers.contentType = ContentType.json
..write(jsonEncode({'success': true}));
} else if (request.uri.path.contains('disconnect')) {
disconnectReceived = true;
request.response
..headers.contentType = ContentType.json
..write(jsonEncode({'success': true}));
}
await request.response.close();
});
try {
final config = AgentConfig(
agent: validConfig.agent,
server: ServerConnectionConfig(
url: 'http://${server.address.host}:${server.port}',
),
runtime: validConfig.runtime,
);
final client = OtoServerRegistrationClient(
commandTypes: ['Shell', 'Git'],
heartbeatInterval: const Duration(milliseconds: 50),
);
final session = await client.openSession(config);
expect(session.result.accepted, isFalse);
// Wait a short duration to ensure no heartbeat is sent
await Future<void>.delayed(const Duration(milliseconds: 200));
expect(heartbeatReceived, isFalse);
await session.close();
// Wait a short duration to ensure no disconnect is sent
await Future<void>.delayed(const Duration(milliseconds: 100));
expect(disconnectReceived, isFalse);
} finally {
await serverSub.cancel();
await server.close(force: true);
}
},
);
test('session close cancels future heartbeats', () async {
final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
var heartbeatCount = 0;
final serverSub = server.listen((request) async {
if (request.method == 'POST' &&
request.uri.path == '/api/v1/runners/register') {
request.response
..headers.contentType = ContentType.json
..write(jsonEncode({'accepted': true, 'runner_id': 'agent-123'}));
} else if (request.uri.path.contains('heartbeat')) {
heartbeatCount++;
request.response
..headers.contentType = ContentType.json
..write(jsonEncode({'success': true}));
} else if (request.uri.path.contains('disconnect')) {
request.response
..headers.contentType = ContentType.json
..write(jsonEncode({'success': true}));
}
await request.response.close();
});
try {
final config = AgentConfig(
agent: validConfig.agent,
server: ServerConnectionConfig(
url: 'http://${server.address.host}:${server.port}',
),
runtime: validConfig.runtime,
);
final client = OtoServerRegistrationClient(
commandTypes: ['Shell', 'Git'],
heartbeatInterval: const Duration(milliseconds: 50),
);
final session = await client.openSession(config);
// Wait for first heartbeat(s)
await Future<void>.delayed(const Duration(milliseconds: 150));
final countBeforeClose = heartbeatCount;
expect(countBeforeClose, greaterThan(0));
await session.close();
// Wait longer to see if any more heartbeats occur
await Future<void>.delayed(const Duration(milliseconds: 150));
expect(heartbeatCount, equals(countBeforeClose));
} finally {
await serverSub.cancel();
await server.close(force: true);
}
});
test('job client claims jobs and reports build results', () async {
final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
final claimReceived = Completer<Map<String, dynamic>>();
final reportReceived = Completer<Map<String, dynamic>>();
final logReceived = Completer<Map<String, dynamic>>();
final artifactReceived = Completer<Map<String, dynamic>>();
final serverSub = server.listen((request) async {
final bodyText = await utf8.decoder.bind(request).join();
final body = bodyText.isEmpty
? <String, dynamic>{}
: jsonDecode(bodyText) as Map<String, dynamic>;
request.response.headers.contentType = ContentType.json;
if (request.method == 'POST' &&
request.uri.path == '/api/v1/runners/agent-123/jobs/claim') {
claimReceived.complete(body);
request.response.write(
jsonEncode({
'accepted': true,
'runner_id': 'agent-123',
'job_id': 'job-123',
'execution_id': 'exec-123',
'state': 'running',
'run_request': {
'runner_id': 'agent-123',
'job_id': 'job-123',
'execution_id': 'exec-123',
'pipeline_yaml': 'commands:\n - type: Shell',
'variables': {'FLAVOR': 'release'},
'command_types': ['Shell', 'Git'],
},
}),
);
} else if (request.method == 'POST' &&
request.uri.path ==
'/api/v1/runners/agent-123/executions/exec-123/report') {
reportReceived.complete(body);
request.response.write(
jsonEncode({
'accepted': true,
'runner_id': 'agent-123',
'job_id': 'job-123',
'execution_id': 'exec-123',
'state': 'succeeded',
}),
);
} else if (request.method == 'POST' &&
request.uri.path ==
'/api/v1/runners/agent-123/executions/exec-123/logs') {
logReceived.complete(body);
request.response.statusCode = HttpStatus.created;
request.response.write(jsonEncode({'accepted': true}));
} else if (request.method == 'POST' &&
request.uri.path ==
'/api/v1/runners/agent-123/executions/exec-123/artifacts') {
artifactReceived.complete(body);
request.response.statusCode = HttpStatus.created;
request.response.write(jsonEncode({'accepted': true}));
} else {
request.response.statusCode = HttpStatus.notFound;
request.response.write(jsonEncode({'error': 'not found'}));
}
await request.response.close();
});
final client = OtoServerJobClient(
serverUrl: 'http://${server.address.host}:${server.port}',
runnerId: 'agent-123',
);
try {
final claim = await client.claimJob(
jobId: 'job-123',
executionId: 'exec-123',
);
expect(claim.accepted, isTrue);
expect(claim.state, 'running');
expect(claim.runRequest, isNotNull);
expect(claim.runRequest!.jobId, 'job-123');
expect(claim.runRequest!.executionId, 'exec-123');
expect(claim.runRequest!.runnerId, 'agent-123');
expect(claim.runRequest!.pipelineYaml, 'commands:\n - type: Shell');
expect(claim.runRequest!.variables['FLAVOR'], 'release');
expect(claim.runRequest!.commandTypes, ['Shell', 'Git']);
final result = BuildResult.success(
stepEvents: [
StepEvent(
stepId: 0,
workflowIndex: 0,
stepType: 'command',
event: 'completed',
timestamp: '2026-06-05T12:00:00Z',
commandId: 'build',
commandType: 'Shell',
),
],
);
final report = await client.reportExecution(
jobId: 'job-123',
executionId: 'exec-123',
result: result,
);
expect(report.accepted, isTrue);
expect(report.state, 'succeeded');
await client.appendLog(
executionId: 'exec-123',
line: 'build completed',
);
await client.reportArtifact(
executionId: 'exec-123',
name: 'binary',
path: '/dist/app',
);
final claimPayload = await claimReceived.future;
expect(claimPayload['runner_id'], 'agent-123');
expect(claimPayload['job_id'], 'job-123');
expect(claimPayload['execution_id'], 'exec-123');
final reportPayload = await reportReceived.future;
expect(reportPayload['success'], isTrue);
expect(reportPayload['exit_code'], 0);
expect(reportPayload['message'], 'Build completed successfully.');
expect(reportPayload['execution_result'], isA<Map<String, dynamic>>());
final stepEvents = reportPayload['step_events'] as List<dynamic>;
expect(stepEvents, hasLength(1));
expect(stepEvents.first, containsPair('commandId', 'build'));
final logPayload = await logReceived.future;
expect(logPayload['line'], 'build completed');
final artifactPayload = await artifactReceived.future;
expect(artifactPayload['name'], 'binary');
expect(artifactPayload['path'], '/dist/app');
} finally {
client.close();
await serverSub.cancel();
await server.close(force: true);
}
});
test(
'OTO runner protocol exposes run, cancel, status, self-update message fields',
() {
final runRequest = oto.RunRequest()
..runnerId = 'agent-123'
..jobId = 'job-123'
..executionId = 'exec-123'
..pipelineYamlPath = '/path/to/pipeline.yaml'
..pipelineYaml = 'commands:\n - type: Shell'
..variables.addAll({'foo': 'bar'})
..commandTypes.addAll(['Shell', 'Git']);
expect(runRequest.runnerId, 'agent-123');
expect(runRequest.jobId, 'job-123');
expect(runRequest.executionId, 'exec-123');
expect(runRequest.pipelineYamlPath, '/path/to/pipeline.yaml');
expect(runRequest.pipelineYaml, 'commands:\n - type: Shell');
expect(runRequest.variables['foo'], 'bar');
expect(runRequest.commandTypes, ['Shell', 'Git']);
final claimResponse = oto.JobClaimResponse()
..accepted = true
..runRequest = runRequest;
expect(claimResponse.runRequest.jobId, 'job-123');
final cancelRequest = oto.CancelRunRequest()
..runnerId = 'agent-123'
..executionId = 'exec-123'
..reason = 'user cancelled';
expect(cancelRequest.reason, 'user cancelled');
final cancelResponse = oto.CancelRunResponse()
..success = false
..error = (oto.ProtocolError()
..code = 'CANCEL_FAILED'
..message = 'execution not found');
expect(cancelResponse.error.code, 'CANCEL_FAILED');
expect(cancelResponse.error.message, 'execution not found');
final statusRequest = oto.RunnerStatusRequest()..runnerId = 'agent-123';
expect(statusRequest.runnerId, 'agent-123');
final statusResponse = oto.RunnerStatusResponse()
..runnerId = 'agent-123'
..status = 'RUNNING'
..currentExecutionId = 'exec-123';
expect(statusResponse.status, 'RUNNING');
final selfUpdateRequest = oto.SelfUpdateRequest()
..runnerId = 'agent-123'
..version = 'v2.0.0'
..downloadUrl = 'http://example.com/download';
expect(selfUpdateRequest.version, 'v2.0.0');
final selfUpdateResponse = oto.SelfUpdateResponse()..success = true;
expect(selfUpdateResponse.success, isTrue);
},
);
test(
'registration rejects enrollment_token_invalid without exposing token value',
() async {
final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
const secretToken = 'super-secret-enrollment-token-9876';
final serverSub = server.listen((request) async {
request.response
..headers.contentType = ContentType.json
..write(
jsonEncode({
'accepted': false,
'reject_reason': 'enrollment_token_invalid',
'error': {
'code': 'enrollment_token_invalid',
'message': 'invalid or expired enrollment token',
},
}),
);
await request.response.close();
});
try {
final config = AgentConfig(
agent: const AgentIdentityConfig(
id: 'test-agent',
enrollmentToken: secretToken,
),
server: ServerConnectionConfig(
url: 'http://${server.address.host}:${server.port}',
),
runtime: validConfig.runtime,
);
final client = OtoServerRegistrationClient(commandTypes: ['Shell']);
final result = await client.register(config);
expect(result.accepted, isFalse);
// fromOtoJson picks reject_reason before error.message
expect(result.rejectReason, 'enrollment_token_invalid');
// enrollment token value must not appear in rejection message
expect(result.rejectReason, isNot(contains(secretToken)));
} finally {
await serverSub.cancel();
await server.close(force: true);
}
},
);
test(
'registration client fallback error parsing from ProtocolError map object',
() async {
final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
final serverSub = server.listen((request) async {
request.response
..headers.contentType = ContentType.json
..write(
jsonEncode({
'accepted': false,
'error': {
'code': 'REGISTRATION_FAILED',
'message': 'Failed to register via protocol error',
},
}),
);
await request.response.close();
});
try {
final config = AgentConfig(
agent: validConfig.agent,
server: ServerConnectionConfig(
url: 'http://${server.address.host}:${server.port}',
),
runtime: validConfig.runtime,
);
final client = OtoServerRegistrationClient(
commandTypes: ['Shell', 'Git'],
);
final result = await client.register(config);
expect(result.accepted, isFalse);
expect(result.rejectReason, 'Failed to register via protocol error');
} finally {
await serverSub.cancel();
await server.close(force: true);
}
},
);
test(
'job client fallback error parsing from ProtocolError map object',
() async {
final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
final serverSub = server.listen((request) async {
request.response.headers.contentType = ContentType.json;
if (request.uri.path.contains('/jobs/claim')) {
request.response.write(
jsonEncode({
'accepted': false,
'error': {
'code': 'CLAIM_FAILED',
'message': 'Failed to claim job via protocol error',
},
}),
);
} else if (request.uri.path.contains('/report')) {
request.response.write(
jsonEncode({
'accepted': false,
'error': {
'code': 'REPORT_FAILED',
'message': 'Failed to report execution via protocol error',
},
}),
);
} else if (request.uri.path.contains('/logs')) {
request.response.statusCode = HttpStatus.created;
request.response.write(
jsonEncode({
'accepted': false,
'error': {
'code': 'LOG_FAILED',
'message': 'Failed to append log via protocol error',
},
}),
);
} else if (request.uri.path.contains('/artifacts')) {
request.response.statusCode = HttpStatus.created;
request.response.write(
jsonEncode({
'accepted': false,
'error': {
'code': 'ARTIFACT_FAILED',
'message': 'Failed to report artifact via protocol error',
},
}),
);
}
await request.response.close();
});
final client = OtoServerJobClient(
serverUrl: 'http://${server.address.host}:${server.port}',
runnerId: 'agent-123',
);
try {
final claim = await client.claimJob(
jobId: 'job-123',
executionId: 'exec-123',
);
expect(claim.accepted, isFalse);
expect(claim.errorMessage, 'Failed to claim job via protocol error');
final report = await client.reportExecution(
jobId: 'job-123',
executionId: 'exec-123',
result: BuildResult.failure('fail', StackTrace.empty),
);
expect(report.accepted, isFalse);
expect(
report.errorMessage,
'Failed to report execution via protocol error',
);
await expectLater(
client.appendLog(executionId: 'exec-123', line: 'test log'),
throwsA(
isA<HttpException>().having(
(e) => e.message,
'message',
'Failed to append log via protocol error',
),
),
);
await expectLater(
client.reportArtifact(
executionId: 'exec-123',
name: 'art',
path: 'path',
),
throwsA(
isA<HttpException>().having(
(e) => e.message,
'message',
'Failed to report artifact via protocol error',
),
),
);
} finally {
client.close();
await serverSub.cancel();
await server.close(force: true);
}
},
);
test(
'job client cancels, fetches status, and requests self-update',
() async {
final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
final cancelReceived = Completer<Map<String, dynamic>>();
final statusReceived = Completer<void>();
final updateReceived = Completer<Map<String, dynamic>>();
final serverSub = server.listen((request) async {
final bodyText = await utf8.decoder.bind(request).join();
final body = bodyText.isEmpty
? <String, dynamic>{}
: jsonDecode(bodyText) as Map<String, dynamic>;
request.response.headers.contentType = ContentType.json;
if (request.method == 'POST' &&
request.uri.path ==
'/api/v1/runners/agent-123/executions/exec-123/cancel') {
cancelReceived.complete(body);
request.response.write(jsonEncode({'success': true}));
} else if (request.method == 'GET' &&
request.uri.path == '/api/v1/runners/agent-123/status') {
statusReceived.complete();
request.response.write(
jsonEncode({
'accepted': true,
'runner_id': 'agent-123',
'status': 'online',
'current_job_id': 'job-123',
'current_execution_id': 'exec-123',
'message': 'running smoothly',
}),
);
} else if (request.method == 'POST' &&
request.uri.path == '/api/v1/runners/agent-123/self-update') {
updateReceived.complete(body);
request.response.write(
jsonEncode({
'success': true,
'accepted': true,
'deferred': false,
'target_version': 'v2.0.0',
'message': 'update scheduled',
}),
);
} else {
request.response.statusCode = HttpStatus.notFound;
request.response.write(jsonEncode({'error': 'not found'}));
}
await request.response.close();
});
final client = OtoServerJobClient(
serverUrl: 'http://${server.address.host}:${server.port}',
runnerId: 'agent-123',
);
try {
final cancelRes = await client.cancelRun(
executionId: 'exec-123',
reason: 'user cancel request',
);
expect(cancelRes.success, isTrue);
final statusRes = await client.fetchRunnerStatus();
expect(statusRes.accepted, isTrue);
expect(statusRes.runnerId, 'agent-123');
expect(statusRes.status, 'online');
expect(statusRes.currentJobId, 'job-123');
expect(statusRes.currentExecutionId, 'exec-123');
expect(statusRes.message, 'running smoothly');
final updateRes = await client.requestSelfUpdate(
version: 'v2.0.0',
downloadUrl: 'https://example.com/binary',
);
expect(updateRes.success, isTrue);
expect(updateRes.accepted, isTrue);
expect(updateRes.deferred, isFalse);
expect(updateRes.targetVersion, 'v2.0.0');
expect(updateRes.message, 'update scheduled');
final cancelPayload = await cancelReceived.future;
expect(cancelPayload['runner_id'], 'agent-123');
expect(cancelPayload['execution_id'], 'exec-123');
expect(cancelPayload['reason'], 'user cancel request');
final updatePayload = await updateReceived.future;
expect(updatePayload['runner_id'], 'agent-123');
expect(updatePayload['version'], 'v2.0.0');
expect(updatePayload['download_url'], 'https://example.com/binary');
} finally {
client.close();
await serverSub.cancel();
await server.close(force: true);
}
},
);
});
group('AgentRunner', () {
test('AgentRunner maps config token to register request', () async {
AgentConfig? capturedConfig;
final fakeClient = FakeRegistrationClient((config) {
capturedConfig = config;
return RegistrationResult.accepted('node-123', 'alias-123', {
'concurrency': 1,
});
});
final runner = DefaultAgentRunner(
client: fakeClient,
onLog: (msg) {},
shutdownFuture: Future.value(),
);
await runner.run(validConfig);
expect(capturedConfig, isNotNull);
expect(capturedConfig!.agent.enrollmentToken, 'token-456');
});
test('AgentRunner keeps session open until shutdown then closes', () async {
final fakeClient = FakeRegistrationClient((config) {
return RegistrationResult.accepted('node-123', 'alias-123', {
'concurrency': 1,
});
});
final shutdown = Completer<void>();
final runner = DefaultAgentRunner(
client: fakeClient,
onLog: (msg) {},
shutdownFuture: shutdown.future,
);
final runFuture = runner.run(validConfig);
// 종료 신호 전에는 session이 살아 있어야 한다.
await Future<void>.delayed(Duration.zero);
expect(fakeClient.lastSession, isNotNull);
expect(fakeClient.lastSession!.closed, isFalse);
shutdown.complete();
await runFuture;
expect(fakeClient.lastSession!.closed, isTrue);
});
test('AgentRunner runs injected job loop after registration', () async {
final fakeClient = FakeRegistrationClient((config) {
return RegistrationResult.accepted('node-123', 'alias-123', {
'concurrency': 1,
});
});
var jobLoopRan = false;
final runner = DefaultAgentRunner(
client: fakeClient,
onLog: (msg) {},
runJobs: (config, session) async {
jobLoopRan = true;
expect(config.agent.id, 'agent-123');
expect(session.result.accepted, isTrue);
},
shutdownFuture: Future.value(),
);
await runner.run(validConfig);
expect(jobLoopRan, isTrue);
});
test(
'AgentRunner reports rejected registration and closes session',
() async {
final fakeClient = FakeRegistrationClient((config) {
return RegistrationResult.rejected('Invalid token');
});
final runner = DefaultAgentRunner(
client: fakeClient,
onLog: (msg) {},
shutdownFuture: Future.value(),
);
await expectLater(
() => runner.run(validConfig),
throwsA(
isA<RegistrationException>().having(
(e) => e.message,
'message',
'Invalid token',
),
),
);
expect(fakeClient.lastSession, isNotNull);
expect(fakeClient.lastSession!.closed, isTrue);
},
);
});
group('RunRequestParser', () {
test('parses inline YAML with variables', () {
final parser = RunRequestParser(workspaceRoot: '/tmp/workspace');
final request = oto.RunRequest()
..pipelineYaml = 'commands:\n - type: Shell'
..variables['MY_VAR'] = 'my_value';
final result = parser.parse(request);
expect(result.yamlContent, 'commands:\n - type: Shell');
expect(result.variables, {'MY_VAR': 'my_value'});
});
test('rejects path traversal in pipelineYamlPath', () {
final parser = RunRequestParser(workspaceRoot: '/var/workspace');
final request = oto.RunRequest()
..pipelineYamlPath = '../../../etc/passwd';
expect(() => parser.parse(request), throwsA(isA<ArgumentError>()));
});
test('accepts canonical path that stays within workspaceRoot', () {
final tempDir = Directory.systemTemp.createTempSync('oto_parse_');
addTearDown(() => tempDir.delete(recursive: true));
final yamlFile = File('${tempDir.path}/pipeline.yaml');
yamlFile.writeAsStringSync('commands:\n - type: Print');
final parser = RunRequestParser(workspaceRoot: tempDir.path);
final request = oto.RunRequest()..pipelineYamlPath = 'pipeline.yaml';
final result = parser.parse(request);
expect(result.yamlContent, 'commands:\n - type: Print');
});
test(
'rejects sibling-prefix path escape (e.g. /var/workspace2 vs /var/workspace)',
() {
final parser = RunRequestParser(workspaceRoot: '/var/workspace');
final request = oto.RunRequest()
..pipelineYamlPath = '/var/workspace2/evil.yaml';
expect(() => parser.parse(request), throwsA(isA<ArgumentError>()));
},
);
});
group('RemoteRunExecutor.mergeVariablesIntoYaml', () {
// The merged document is JSON, which Application.build re-parses with the
// same parser used here. Asserting on the parsed property map proves the
// structured merge instead of relying on fragile string layout.
Map<String, dynamic> mergedProperty(
String yaml,
Map<String, String> variables,
) {
final merged = RemoteRunExecutor.mergeVariablesIntoYaml(yaml, variables);
final root = Application.getMapFromYamlA(merged);
expect(root, isNotNull, reason: 'merged document must parse as a map');
final property = root!['property'];
expect(
property,
isA<Map>(),
reason: 'merged document must have property map',
);
return Map<String, dynamic>.from(property as Map);
}
test('block-style property is overridden by remote variables', () {
const yaml = '''
property:
workspace: .
FLAVOR: original
commands:
- type: Print
''';
final property = mergedProperty(yaml, {'FLAVOR': 'release'});
expect(property['FLAVOR'], 'release');
// Untouched existing entries are preserved.
expect(property['workspace'], '.');
});
test('missing property section is created from remote variables', () {
const yaml = '''
commands:
- type: Print
''';
final property = mergedProperty(yaml, {'FLAVOR': 'release'});
expect(property['FLAVOR'], 'release');
});
test('inline property map is overridden and stays valid YAML', () {
// The previous string-injection merge produced invalid YAML for this
// valid inline-map form; the structured merge must keep it parseable.
const yaml = '''
property: {workspace: ., FLAVOR: original}
commands:
- type: Print
''';
final property = mergedProperty(yaml, {'FLAVOR': 'release'});
expect(property['FLAVOR'], 'release');
expect(property['workspace'], '.');
});
test('empty variables leave the YAML untouched', () {
const yaml = 'property:\n workspace: .\ncommands:\n - type: Print\n';
expect(RemoteRunExecutor.mergeVariablesIntoYaml(yaml, const {}), yaml);
});
test('invalid non-map property is left untouched for build validation', () {
// A present-but-non-map property is invalid. The merge must not coerce it
// into a clean map (which would bypass Application._validateBuildMap); it
// returns the original YAML so build validation still rejects it.
const yaml = '''
property:
- bad
commands:
- type: Print
''';
final merged = RemoteRunExecutor.mergeVariablesIntoYaml(yaml, {
'FLAVOR': 'release',
});
expect(merged, yaml);
// Confirm the preserved property is still the invalid (non-map) form.
final root = Application.getMapFromYamlA(merged);
expect(root!['property'], isA<List>());
});
});
group('RemoteRunExecutor.parseArtifactDeclarations', () {
test('parses valid artifact declarations from property.artifacts', () {
const yaml = '''
property:
workspace: .
artifacts:
- name: smoke-report
path: dist/report.json
commands:
- command: Print
id: hello
param:
message: hi
pipeline:
id: main
workflow:
- exe: hello
''';
final artifacts = RemoteRunExecutor.parseArtifactDeclarations(
yaml,
workspaceRoot: '/tmp/workspace',
);
expect(artifacts, hasLength(1));
expect(artifacts.first.name, 'smoke-report');
expect(artifacts.first.path, 'dist/report.json');
});
test('returns empty list when no artifacts declared', () {
const yaml = '''
property:
workspace: .
commands:
- type: Print
''';
expect(
RemoteRunExecutor.parseArtifactDeclarations(
yaml,
workspaceRoot: '/tmp/workspace',
),
isEmpty,
);
});
test('rejects artifact with missing name', () {
const yaml = '''
property:
artifacts:
- path: dist/out.txt
commands:
- type: Print
''';
expect(
() => RemoteRunExecutor.parseArtifactDeclarations(
yaml,
workspaceRoot: '/tmp/workspace',
),
throwsA(isA<ArgumentError>()),
);
});
test('rejects artifact with empty path', () {
const yaml = '''
property:
artifacts:
- name: report
path: ""
commands:
- type: Print
''';
expect(
() => RemoteRunExecutor.parseArtifactDeclarations(
yaml,
workspaceRoot: '/tmp/workspace',
),
throwsA(isA<ArgumentError>()),
);
});
test('rejects non-list artifacts declaration', () {
const yaml = '''
property:
artifacts: not-a-list
commands:
- type: Print
''';
expect(
() => RemoteRunExecutor.parseArtifactDeclarations(
yaml,
workspaceRoot: '/tmp/workspace',
),
throwsA(isA<ArgumentError>()),
);
});
test('rejects artifact path that escapes the workspace', () {
const yaml = '''
property:
artifacts:
- name: evil
path: ../../etc/passwd
commands:
- type: Print
''';
expect(
() => RemoteRunExecutor.parseArtifactDeclarations(
yaml,
workspaceRoot: '/var/workspace',
),
throwsA(isA<ArgumentError>()),
);
});
test('rejects sibling-prefix absolute path escape', () {
const yaml = '''
property:
artifacts:
- name: evil
path: /var/workspace2/out.txt
commands:
- type: Print
''';
expect(
() => RemoteRunExecutor.parseArtifactDeclarations(
yaml,
workspaceRoot: '/var/workspace',
),
throwsA(isA<ArgumentError>()),
);
});
});
group('DefaultAgentRunner shutdown-aware loop', () {
test(
'no-job polling exits promptly when shutdownFuture completes',
() async {
// Set up a FakeOtoServerJobSession that always returns no-job.
final shutdown = Completer<void>();
final fakeRegistrationClient = _FakeJobRegistrationClient(
result: RegistrationResult.accepted('node-1', null, {}),
jobClient: _SingleResponseJobClient([]),
onShutdown: shutdown,
);
final runner = DefaultAgentRunner(
client: fakeRegistrationClient,
onLog: (_) {},
shutdownFuture: shutdown.future,
);
// Complete shutdown after a short delay.
Future.delayed(const Duration(milliseconds: 50), shutdown.complete);
final start = DateTime.now();
await runner.run(validConfig);
final elapsed = DateTime.now().difference(start);
// Should exit well before the 3-second polling delay.
expect(elapsed.inMilliseconds, lessThan(1000));
expect(fakeRegistrationClient.lastSession!.closed, isTrue);
},
);
test(
'claimNextJob is invoked by compatibility polling loop and queued job is executed',
() async {
final shutdown = Completer<void>();
var jobExecuted = false;
final fakeRegistrationClient = _FakeJobRegistrationClient(
result: RegistrationResult.accepted('node-1', null, {}),
jobClient: _SingleResponseJobClient([
JobClaimResult(
accepted: true,
jobId: 'job-loop-1',
executionId: 'exec-loop-1',
state: 'running',
runRequest: (oto.RunRequest()
..pipelineYaml =
'property:\n workspace: .\ncommands:\n - command: Print\n id: hello\n param:\n message: loop-test\npipeline:\n id: p\n workflow:\n - exe: hello'),
),
]),
onShutdown: shutdown,
onJobExecuted: () {
jobExecuted = true;
shutdown.complete();
},
);
final runner = DefaultAgentRunner(
client: fakeRegistrationClient,
onLog: (_) {},
shutdownFuture: shutdown.future,
);
await runner.run(validConfig);
expect(jobExecuted, isTrue);
},
);
});
}
class _SingleResponseJobClient extends OtoServerJobClient {
final List<JobClaimResult> _responses;
int _callCount = 0;
void Function()? _onExecuted;
_SingleResponseJobClient(this._responses)
: super(serverUrl: 'http://localhost', runnerId: 'fake');
@override
Future<JobClaimResult> claimNextJob() async {
if (_callCount < _responses.length) {
return _responses[_callCount++];
}
return const JobClaimResult(
accepted: false,
jobId: '',
executionId: '',
state: '',
);
}
@override
Future<ExecutionReportResult> reportExecution({
required String jobId,
required String executionId,
required BuildResult result,
}) async {
_onExecuted?.call();
return ExecutionReportResult(
accepted: true,
jobId: jobId,
executionId: executionId,
state: 'succeeded',
);
}
@override
Future<void> appendLog({
required String executionId,
required String line,
}) async {}
}
class _FakeOtoServerJobSession implements OtoServerJobSession {
@override
final RegistrationResult result;
@override
final OtoServerJobClient jobs;
bool closed = false;
_FakeOtoServerJobSession(this.result, this.jobs);
@override
Future<void> close() async {
closed = true;
}
}
class _FakeJobRegistrationClient extends RegistrationClient {
final RegistrationResult result;
final _SingleResponseJobClient jobClient;
final Completer<void> onShutdown;
final void Function()? onJobExecuted;
_FakeOtoServerJobSession? lastSession;
_FakeJobRegistrationClient({
required this.result,
required this.jobClient,
required this.onShutdown,
this.onJobExecuted,
});
@override
Future<AgentSession> openSession(AgentConfig config) async {
jobClient._onExecuted = onJobExecuted;
final session = _FakeOtoServerJobSession(result, jobClient);
lastSession = session;
return session;
}
}