update: bootstrap script and runner registry changes

This commit is contained in:
toki 2026-06-21 07:35:51 +09:00
parent 7e51ef913d
commit 2eb9cd3c33
12 changed files with 755 additions and 150 deletions

View file

@ -2,18 +2,58 @@
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$serverUrl = ''
$socketUrl = ''
$agentId = ''
$serverUrl = if ($env:OTO_BOOTSTRAP_SERVER_URL) { $env:OTO_BOOTSTRAP_SERVER_URL } else { '' }
$socketUrl = if ($env:OTO_BOOTSTRAP_SOCKET_URL) { $env:OTO_BOOTSTRAP_SOCKET_URL } else { '' }
$agentId = if ($env:OTO_AGENT_ID) { $env:OTO_AGENT_ID } else { '' }
$enrollmentToken = ''
$releaseBaseUrl = ''
$agentAlias = ''
$releaseBaseUrl = if ($env:OTO_BOOTSTRAP_RELEASE_BASE_URL) { $env:OTO_BOOTSTRAP_RELEASE_BASE_URL } else { '' }
$agentAlias = if ($env:OTO_AGENT_ALIAS) { $env:OTO_AGENT_ALIAS } else { '' }
$installDir = Join-Path $env:LOCALAPPDATA 'oto\bin'
$configPath = Join-Path $env:LOCALAPPDATA 'oto\agent\config.yaml'
$workspaceRoot = Join-Path $env:LOCALAPPDATA 'oto\workspace'
$logDir = Join-Path $env:LOCALAPPDATA 'oto\agent\log'
$background = $true
function Get-HashHex([string]$value) {
$sha = [System.Security.Cryptography.SHA256]::Create()
try {
$bytes = [System.Text.Encoding]::UTF8.GetBytes($value)
$hashBytes = $sha.ComputeHash($bytes)
return -join ($hashBytes | ForEach-Object { $_.ToString('x2') })
} finally {
$sha.Dispose()
}
}
function Get-DeviceIdentitySource {
$source = ''
try {
$machineGuid = (Get-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Cryptography' -Name MachineGuid -ErrorAction Stop).MachineGuid
if ($machineGuid) { $source = [string]$machineGuid }
} catch {
$source = ''
}
if (-not $source -and $env:COMPUTERNAME) {
$source = $env:COMPUTERNAME
}
if (-not $source) {
$source = [System.Net.Dns]::GetHostName()
}
return $source
}
function Get-DefaultAgentId {
$hash = Get-HashHex "oto:windows:$(Get-DeviceIdentitySource)"
return "oto-$($hash.Substring(0, [Math]::Min(32, $hash.Length)))"
}
function Get-DefaultAgentAlias {
if ($env:COMPUTERNAME) { return $env:COMPUTERNAME }
$hostName = [System.Net.Dns]::GetHostName()
if ($hostName) { return $hostName }
return 'oto-runner'
}
$i = 0
while ($i -lt $args.Count) {
switch ($args[$i]) {
@ -21,7 +61,7 @@ while ($i -lt $args.Count) {
'--server-url' { $serverUrl = $args[$i + 1]; $i += 2 }
'--socket-url' { $socketUrl = $args[$i + 1]; $i += 2 }
'--agent-id' { $agentId = $args[$i + 1]; $i += 2 }
'--enrollment-token' { $enrollmentToken = $args[$i + 1]; $i += 2 }
'--token' { $enrollmentToken = $args[$i + 1]; $i += 2 }
'--release-base-url' { $releaseBaseUrl = $args[$i + 1]; $i += 2 }
'--agent-alias' { $agentAlias = $args[$i + 1]; $i += 2 }
'--install-dir' { $installDir = $args[$i + 1]; $i += 2 }
@ -33,8 +73,23 @@ while ($i -lt $args.Count) {
}
}
if (-not $serverUrl -or -not $agentId -or -not $enrollmentToken -or -not $releaseBaseUrl) {
Write-Error 'Error: Missing required arguments: --server-url, --agent-id, --enrollment-token, --release-base-url'
if (-not $serverUrl) { $serverUrl = '__OTO_BOOTSTRAP_DEFAULT_SERVER_URL__' }
if (-not $socketUrl) { $socketUrl = '__OTO_BOOTSTRAP_DEFAULT_SOCKET_URL__' }
if (-not $releaseBaseUrl) { $releaseBaseUrl = '__OTO_BOOTSTRAP_DEFAULT_RELEASE_BASE_URL__' }
if ($serverUrl -eq '__OTO_BOOTSTRAP_DEFAULT_SERVER_URL__') { $serverUrl = '' }
if ($socketUrl -eq '__OTO_BOOTSTRAP_DEFAULT_SOCKET_URL__') { $socketUrl = '' }
if ($releaseBaseUrl -eq '__OTO_BOOTSTRAP_DEFAULT_RELEASE_BASE_URL__') { $releaseBaseUrl = '' }
if (-not $enrollmentToken) {
Write-Error 'Error: Missing required argument: --token'
exit 1
}
if (-not $serverUrl) {
Write-Error 'Error: Missing OTO Core URL. Use --server-url or fetch this script from the OTO Core bootstrap endpoint.'
exit 1
}
if (-not $releaseBaseUrl) {
Write-Error 'Error: Missing release base URL. Use --release-base-url or fetch this script from the OTO Core bootstrap endpoint.'
exit 1
}
@ -43,6 +98,13 @@ if (-not $releaseBaseUrl.StartsWith('https://')) {
exit 1
}
if (-not $agentId) { $agentId = Get-DefaultAgentId }
if (-not $agentAlias) { $agentAlias = Get-DefaultAgentAlias }
if (-not $agentId) {
Write-Error 'Error: Unable to derive agent id from this device. Use --agent-id to override.'
exit 1
}
$arch = $env:PROCESSOR_ARCHITECTURE
$archName = switch ($arch) {
'AMD64' { 'x64' }

View file

@ -1,12 +1,12 @@
#!/usr/bin/env bash
set -euo pipefail
server_url=""
socket_url=""
agent_id=""
server_url="${OTO_BOOTSTRAP_SERVER_URL:-}"
socket_url="${OTO_BOOTSTRAP_SOCKET_URL:-}"
agent_id="${OTO_AGENT_ID:-}"
enrollment_token=""
release_base_url=""
agent_alias=""
release_base_url="${OTO_BOOTSTRAP_RELEASE_BASE_URL:-}"
agent_alias="${OTO_AGENT_ALIAS:-}"
install_dir="${HOME}/.oto/bin"
config_path="${HOME}/.oto/agent/config.yaml"
workspace_root="${HOME}/.oto/workspace"
@ -14,10 +14,13 @@ log_dir="${HOME}/.oto/agent/log"
background="true"
usage() {
echo "Usage: $0 --server-url <url> --socket-url <url> --agent-id <id> --enrollment-token <token> --release-base-url <url> [options]"
echo "Usage: $0 --token <token> [options]"
echo "Options:"
echo " --socket-url <url> Runner proto-socket URL (default: derived by agent)"
echo " --agent-alias <alias> Alias for the agent"
echo " --server-url <url> OTO Core HTTP URL (default: served by bootstrap endpoint or OTO_BOOTSTRAP_SERVER_URL)"
echo " --socket-url <url> Runner proto-socket URL (default: served by bootstrap endpoint or OTO_BOOTSTRAP_SOCKET_URL)"
echo " --release-base-url <url> OTO release asset base URL (default: served by bootstrap endpoint or OTO_BOOTSTRAP_RELEASE_BASE_URL)"
echo " --agent-id <id> Agent ID override (default: derived from this device)"
echo " --agent-alias <alias> Alias override (default: device name)"
echo " --install-dir <path> Directory to install OTO agent (default: \$HOME/.oto/bin)"
echo " --config-path <path> Path to config file (default: \$HOME/.oto/agent/config.yaml)"
echo " --workspace-root <path> Workspace root directory (default: \$HOME/.oto/workspace)"
@ -35,6 +38,75 @@ yaml_escape() {
printf '%s' "$value"
}
hash_value() {
local value="$1"
if command -v sha256sum >/dev/null 2>&1; then
printf '%s' "$value" | sha256sum | awk '{print $1}'
elif command -v shasum >/dev/null 2>&1; then
printf '%s' "$value" | shasum -a 256 | awk '{print $1}'
else
printf '%s' "$value" | cksum | awk '{print $1}'
fi
}
device_identity_source() {
local source=""
if [[ "${os_name:-}" = "macos" ]] && command -v ioreg >/dev/null 2>&1; then
source="$(ioreg -rd1 -c IOPlatformExpertDevice 2>/dev/null | awk -F'"' '/IOPlatformUUID/ { print $(NF-1); exit }' || true)"
fi
if [[ -z "$source" && "${os_name:-}" = "linux" ]]; then
if [[ -r /etc/machine-id ]]; then
source="$(tr -d '[:space:]' < /etc/machine-id)"
elif [[ -r /var/lib/dbus/machine-id ]]; then
source="$(tr -d '[:space:]' < /var/lib/dbus/machine-id)"
fi
fi
if [[ -z "$source" ]]; then
source="$(hostname 2>/dev/null || true)"
fi
printf '%s' "$source"
}
default_agent_id() {
local source hash
source="$(device_identity_source)"
hash="$(hash_value "oto:${os_name}:${source}")"
printf 'oto-%s' "${hash:0:32}"
}
default_agent_alias() {
local alias=""
if [[ "${os_name:-}" = "macos" ]] && command -v scutil >/dev/null 2>&1; then
alias="$(scutil --get ComputerName 2>/dev/null || true)"
fi
if [[ -z "$alias" ]]; then
alias="$(hostname -s 2>/dev/null || hostname 2>/dev/null || true)"
fi
if [[ -z "$alias" ]]; then
alias="oto-runner"
fi
printf '%s' "$alias"
}
if [[ -z "$server_url" ]]; then
server_url='__OTO_BOOTSTRAP_DEFAULT_SERVER_URL__'
fi
if [[ -z "$socket_url" ]]; then
socket_url='__OTO_BOOTSTRAP_DEFAULT_SOCKET_URL__'
fi
if [[ -z "$release_base_url" ]]; then
release_base_url='__OTO_BOOTSTRAP_DEFAULT_RELEASE_BASE_URL__'
fi
if [[ "$server_url" = "__OTO_BOOTSTRAP_DEFAULT_SERVER_URL__" ]]; then
server_url=""
fi
if [[ "$socket_url" = "__OTO_BOOTSTRAP_DEFAULT_SOCKET_URL__" ]]; then
socket_url=""
fi
if [[ "$release_base_url" = "__OTO_BOOTSTRAP_DEFAULT_RELEASE_BASE_URL__" ]]; then
release_base_url=""
fi
while [[ $# -gt 0 ]]; do
case "$1" in
--server-url|--edge-url)
@ -49,7 +121,7 @@ while [[ $# -gt 0 ]]; do
agent_id="$2"
shift 2
;;
--enrollment-token)
--token)
enrollment_token="$2"
shift 2
;;
@ -89,8 +161,18 @@ while [[ $# -gt 0 ]]; do
esac
done
if [[ -z "$server_url" || -z "$agent_id" || -z "$enrollment_token" || -z "$release_base_url" ]]; then
echo "Error: Missing required arguments." >&2
if [[ -z "$enrollment_token" ]]; then
echo "Error: Missing required argument: --token." >&2
usage
exit 1
fi
if [[ -z "$server_url" ]]; then
echo "Error: Missing OTO Core URL. Use --server-url or fetch this script from the OTO Core bootstrap endpoint." >&2
usage
exit 1
fi
if [[ -z "$release_base_url" ]]; then
echo "Error: Missing release base URL. Use --release-base-url or fetch this script from the OTO Core bootstrap endpoint." >&2
usage
exit 1
fi
@ -125,6 +207,17 @@ esac
asset_name="oto-${os_name}-${arch_name}.tar.gz"
if [[ -z "$agent_id" ]]; then
agent_id="$(default_agent_id)"
fi
if [[ -z "$agent_alias" ]]; then
agent_alias="$(default_agent_alias)"
fi
if [[ -z "$agent_id" ]]; then
echo "Error: Unable to derive agent id from this device. Use --agent-id to override." >&2
exit 1
fi
download_url="${release_base_url}/${asset_name}"
tmp_dir=$(mktemp -d)
trap 'rm -rf "$tmp_dir"' EXIT
@ -139,6 +232,13 @@ mkdir -p "$install_dir"
cp "$tmp_dir/oto" "$install_dir/oto"
chmod +x "$install_dir/oto"
if [[ "$os_name" = "macos" ]]; then
xattr -dr com.apple.quarantine "$install_dir/oto" 2>/dev/null || true
if command -v codesign >/dev/null 2>&1; then
codesign --force --sign - "$install_dir/oto" >/dev/null 2>&1 || true
fi
fi
echo "Verifying OTO binary..."
if ! "$install_dir/oto" --version > /dev/null 2>&1; then
echo "Error: OTO binary verification failed." >&2

View file

@ -17,9 +17,10 @@ void main() {
});
test('should contain required flags', () {
expect(content, contains('--token'));
expect(content, contains('--server-url'));
expect(content, contains('--agent-id'));
expect(content, contains('--enrollment-token'));
expect(content, isNot(contains('--enrollment-token')));
expect(content, contains('--release-base-url'));
});

View file

@ -44,9 +44,13 @@ Future<_BootstrapRunResult> _runBootstrapWithFakeUname(
required String unameM,
bool provideTarGz = true,
String serverUrl = 'https://server.example.com',
String socketUrl = 'tcp://socket.example.com:18080',
String releaseBaseUrl = 'https://example.com/release',
String agentId = 'test-agent',
String token = 'test-token-secret',
int fakeOtoVersionExitCode = 0,
bool passConnectionArgs = true,
bool passAgentId = true,
}) async {
final tmpDir = await Directory.systemTemp.createTemp('oto_matrix_test_');
try {
@ -67,6 +71,12 @@ esac
''');
await Process.run('chmod', ['+x', fakeUname.path]);
final fakeHostname = File('${fakeBinDir.path}/hostname');
await fakeHostname.writeAsString('''#!/bin/sh
echo "fake-device"
''');
await Process.run('chmod', ['+x', fakeHostname.path]);
// curl asset basename을
final assetLog = File('${tmpDir.path}/requested_asset.txt');
@ -119,18 +129,31 @@ $copyLine
final env = Map<String, String>.from(Platform.environment)
..['PATH'] = '${fakeBinDir.path}:${Platform.environment['PATH'] ?? ''}'
..['HOME'] = tempHome.path;
if (!passConnectionArgs) {
env['OTO_BOOTSTRAP_SERVER_URL'] = serverUrl;
env['OTO_BOOTSTRAP_SOCKET_URL'] = socketUrl;
env['OTO_BOOTSTRAP_RELEASE_BASE_URL'] = releaseBaseUrl;
}
final result = await Process.run('bash', [
final args = <String>[
scriptFile.absolute.path,
if (passConnectionArgs) ...[
'--server-url',
serverUrl,
],
if (passAgentId) ...[
'--agent-id',
agentId,
'--enrollment-token',
],
'--token',
token,
if (passConnectionArgs) ...[
'--release-base-url',
'https://example.com/release',
], environment: env);
releaseBaseUrl,
],
];
final result = await Process.run('bash', args, environment: env);
String? requestedAsset;
if (await assetLog.exists()) {
@ -178,10 +201,11 @@ void main() {
content = await scriptFile.readAsString();
});
test('should contain required flags', () {
expect(content, contains('--server-url'));
test('should contain required flags and optional connection overrides', () {
expect(content, contains('--token'));
expect(content, contains('--agent-id'));
expect(content, contains('--enrollment-token'));
expect(content, isNot(contains('--enrollment-token')));
expect(content, contains('--server-url'));
expect(content, contains('--release-base-url'));
});
@ -286,6 +310,29 @@ void main() {
});
}
test(
'uses served bootstrap defaults so command only needs token',
() async {
final r = await _runBootstrapWithFakeUname(
scriptFile,
unameS: 'Darwin',
unameM: 'arm64',
provideTarGz: true,
passConnectionArgs: false,
passAgentId: false,
);
expect(r.exitCode, 0, reason: 'stderr: ${r.stderr}');
expect(r.requestedAsset, 'oto-macos-arm64.tar.gz');
expect(r.configContent, matches(RegExp(r'id: "oto-[0-9a-f]{32}"')));
expect(r.configContent, matches(RegExp(r'alias: ".+"')));
expect(r.configContent, contains('url: "https://server.example.com"'));
expect(
r.configContent,
contains('socket_url: "tcp://socket.example.com:18080"'),
);
},
);
test('fails before download for unsupported OS (FreeBSD)', () async {
const token = 'secret-os-token-789';
final r = await _runBootstrapWithFakeUname(
@ -454,8 +501,7 @@ runtime:
final result = await Process.run('bash', [
scriptFile.absolute.path,
'--server-url', 'https://server.example.com',
'--agent-id', 'test-agent',
'--enrollment-token', 'secret-token-123',
'--token', 'secret-token-123',
'--release-base-url', 'http://example.com/release', // HTTP URL
]);
@ -539,9 +585,7 @@ fi
scriptFile.absolute.path,
'--server-url',
'https://server.example.com',
'--agent-id',
'test-agent',
'--enrollment-token',
'--token',
'secret-token-123',
'--release-base-url',
'https://example.com/release',
@ -648,9 +692,7 @@ fi
scriptFile.absolute.path,
'--edge-url',
'https://edge.example.com',
'--agent-id',
'test-agent-alias',
'--enrollment-token',
'--token',
'secret-token-123',
'--release-base-url',
'https://example.com/release',

View file

@ -500,7 +500,7 @@ void main() {
headers: {'content-type': 'application/json'},
body: jsonEncode({
'runner_id': 'test-runner-id',
'enrollment_token': 'test-token-123',
'token': 'test-token-123',
}),
);
@ -509,13 +509,15 @@ void main() {
expect(data['bootstrap_command'], isNotNull);
final bootstrapCommand = data['bootstrap_command'] as String;
// Verify command contains server URL, runner ID, enrollment token
// Verify command contains server URL and token only. Runner ID is
// derived on the node by the bootstrap script.
expect(bootstrapCommand, contains('http://$serverAddr'));
expect(bootstrapCommand, contains('test-runner-id'));
expect(bootstrapCommand, isNot(contains('test-runner-id')));
expect(bootstrapCommand, contains('test-token-123'));
expect(bootstrapCommand, contains('--server-url'));
expect(bootstrapCommand, contains('--agent-id'));
expect(bootstrapCommand, contains('--enrollment-token'));
expect(bootstrapCommand, isNot(contains('--server-url')));
expect(bootstrapCommand, isNot(contains('--agent-id')));
expect(bootstrapCommand, contains('--token'));
expect(bootstrapCommand, isNot(contains('--enrollment-token')));
// 2. Full script execution test using fake curl/tar
final tempDir = await Directory.systemTemp.createTemp(
@ -523,9 +525,6 @@ void main() {
);
final tempHome = Directory('${tempDir.path}/home');
await tempHome.create(recursive: true);
final scriptFile = File('assets/script/shell/oto_agent_bootstrap.sh');
// Create fake OTO executable source
final fakeOtoSource = Directory('${tempDir.path}/fake_oto_src');
await fakeOtoSource.create(recursive: true);
@ -548,9 +547,13 @@ void main() {
// Create fake bin dir and fake curl
final fakeBinDir = Directory('${tempDir.path}/bin');
await fakeBinDir.create(recursive: true);
final realCurlResult = await Process.run('which', ['curl']);
final realCurlPath = realCurlResult.stdout.toString().trim();
expect(realCurlPath, isNotEmpty, reason: 'curl must be available');
final fakeCurl = File('${fakeBinDir.path}/curl');
await fakeCurl.writeAsString('''#!/bin/sh
out_file=""
url=""
while [ \$# -gt 0 ]; do
if [ "\$1" = "-o" ]; then
out_file="\$2"
@ -558,6 +561,7 @@ void main() {
elif [ "\$1" = "-fsSL" ]; then
shift 1
else
url="\$1"
shift 1
fi
done
@ -565,7 +569,7 @@ void main() {
if [ -n "\$out_file" ]; then
cp "${fakeTarGz.path}" "\$out_file"
else
cat "${scriptFile.absolute.path}"
"$realCurlPath" -fsSL "\$url"
fi
''');
await Process.run('chmod', ['+x', fakeCurl.path]);
@ -614,7 +618,7 @@ void main() {
final configContent = await configFile.readAsString();
expect(configContent, contains('server:'));
expect(configContent, contains('url: "http://$serverAddr"'));
expect(configContent, contains('id: "test-runner-id"'));
expect(configContent, matches(RegExp(r'id: "oto-[0-9a-f]{32}"')));
expect(configContent, contains('enrollment_token: "test-token-123"'));
// - Installed binary exists and has execution permissions

View file

@ -2,18 +2,58 @@
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$serverUrl = ''
$socketUrl = ''
$agentId = ''
$serverUrl = if ($env:OTO_BOOTSTRAP_SERVER_URL) { $env:OTO_BOOTSTRAP_SERVER_URL } else { '' }
$socketUrl = if ($env:OTO_BOOTSTRAP_SOCKET_URL) { $env:OTO_BOOTSTRAP_SOCKET_URL } else { '' }
$agentId = if ($env:OTO_AGENT_ID) { $env:OTO_AGENT_ID } else { '' }
$enrollmentToken = ''
$releaseBaseUrl = ''
$agentAlias = ''
$releaseBaseUrl = if ($env:OTO_BOOTSTRAP_RELEASE_BASE_URL) { $env:OTO_BOOTSTRAP_RELEASE_BASE_URL } else { '' }
$agentAlias = if ($env:OTO_AGENT_ALIAS) { $env:OTO_AGENT_ALIAS } else { '' }
$installDir = Join-Path $env:LOCALAPPDATA 'oto\bin'
$configPath = Join-Path $env:LOCALAPPDATA 'oto\agent\config.yaml'
$workspaceRoot = Join-Path $env:LOCALAPPDATA 'oto\workspace'
$logDir = Join-Path $env:LOCALAPPDATA 'oto\agent\log'
$background = $true
function Get-HashHex([string]$value) {
$sha = [System.Security.Cryptography.SHA256]::Create()
try {
$bytes = [System.Text.Encoding]::UTF8.GetBytes($value)
$hashBytes = $sha.ComputeHash($bytes)
return -join ($hashBytes | ForEach-Object { $_.ToString('x2') })
} finally {
$sha.Dispose()
}
}
function Get-DeviceIdentitySource {
$source = ''
try {
$machineGuid = (Get-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Cryptography' -Name MachineGuid -ErrorAction Stop).MachineGuid
if ($machineGuid) { $source = [string]$machineGuid }
} catch {
$source = ''
}
if (-not $source -and $env:COMPUTERNAME) {
$source = $env:COMPUTERNAME
}
if (-not $source) {
$source = [System.Net.Dns]::GetHostName()
}
return $source
}
function Get-DefaultAgentId {
$hash = Get-HashHex "oto:windows:$(Get-DeviceIdentitySource)"
return "oto-$($hash.Substring(0, [Math]::Min(32, $hash.Length)))"
}
function Get-DefaultAgentAlias {
if ($env:COMPUTERNAME) { return $env:COMPUTERNAME }
$hostName = [System.Net.Dns]::GetHostName()
if ($hostName) { return $hostName }
return 'oto-runner'
}
$i = 0
while ($i -lt $args.Count) {
switch ($args[$i]) {
@ -21,7 +61,7 @@ while ($i -lt $args.Count) {
'--server-url' { $serverUrl = $args[$i + 1]; $i += 2 }
'--socket-url' { $socketUrl = $args[$i + 1]; $i += 2 }
'--agent-id' { $agentId = $args[$i + 1]; $i += 2 }
'--enrollment-token' { $enrollmentToken = $args[$i + 1]; $i += 2 }
'--token' { $enrollmentToken = $args[$i + 1]; $i += 2 }
'--release-base-url' { $releaseBaseUrl = $args[$i + 1]; $i += 2 }
'--agent-alias' { $agentAlias = $args[$i + 1]; $i += 2 }
'--install-dir' { $installDir = $args[$i + 1]; $i += 2 }
@ -33,8 +73,23 @@ while ($i -lt $args.Count) {
}
}
if (-not $serverUrl -or -not $agentId -or -not $enrollmentToken -or -not $releaseBaseUrl) {
Write-Error 'Error: Missing required arguments: --server-url, --agent-id, --enrollment-token, --release-base-url'
if (-not $serverUrl) { $serverUrl = '__OTO_BOOTSTRAP_DEFAULT_SERVER_URL__' }
if (-not $socketUrl) { $socketUrl = '__OTO_BOOTSTRAP_DEFAULT_SOCKET_URL__' }
if (-not $releaseBaseUrl) { $releaseBaseUrl = '__OTO_BOOTSTRAP_DEFAULT_RELEASE_BASE_URL__' }
if ($serverUrl -eq '__OTO_BOOTSTRAP_DEFAULT_SERVER_URL__') { $serverUrl = '' }
if ($socketUrl -eq '__OTO_BOOTSTRAP_DEFAULT_SOCKET_URL__') { $socketUrl = '' }
if ($releaseBaseUrl -eq '__OTO_BOOTSTRAP_DEFAULT_RELEASE_BASE_URL__') { $releaseBaseUrl = '' }
if (-not $enrollmentToken) {
Write-Error 'Error: Missing required argument: --token'
exit 1
}
if (-not $serverUrl) {
Write-Error 'Error: Missing OTO Core URL. Use --server-url or fetch this script from the OTO Core bootstrap endpoint.'
exit 1
}
if (-not $releaseBaseUrl) {
Write-Error 'Error: Missing release base URL. Use --release-base-url or fetch this script from the OTO Core bootstrap endpoint.'
exit 1
}
@ -43,6 +98,13 @@ if (-not $releaseBaseUrl.StartsWith('https://')) {
exit 1
}
if (-not $agentId) { $agentId = Get-DefaultAgentId }
if (-not $agentAlias) { $agentAlias = Get-DefaultAgentAlias }
if (-not $agentId) {
Write-Error 'Error: Unable to derive agent id from this device. Use --agent-id to override.'
exit 1
}
$arch = $env:PROCESSOR_ARCHITECTURE
$archName = switch ($arch) {
'AMD64' { 'x64' }

View file

@ -1,12 +1,12 @@
#!/usr/bin/env bash
set -euo pipefail
server_url=""
socket_url=""
agent_id=""
server_url="${OTO_BOOTSTRAP_SERVER_URL:-}"
socket_url="${OTO_BOOTSTRAP_SOCKET_URL:-}"
agent_id="${OTO_AGENT_ID:-}"
enrollment_token=""
release_base_url=""
agent_alias=""
release_base_url="${OTO_BOOTSTRAP_RELEASE_BASE_URL:-}"
agent_alias="${OTO_AGENT_ALIAS:-}"
install_dir="${HOME}/.oto/bin"
config_path="${HOME}/.oto/agent/config.yaml"
workspace_root="${HOME}/.oto/workspace"
@ -14,10 +14,13 @@ log_dir="${HOME}/.oto/agent/log"
background="true"
usage() {
echo "Usage: $0 --server-url <url> --socket-url <url> --agent-id <id> --enrollment-token <token> --release-base-url <url> [options]"
echo "Usage: $0 --token <token> [options]"
echo "Options:"
echo " --socket-url <url> Runner proto-socket URL (default: derived by agent)"
echo " --agent-alias <alias> Alias for the agent"
echo " --server-url <url> OTO Core HTTP URL (default: served by bootstrap endpoint or OTO_BOOTSTRAP_SERVER_URL)"
echo " --socket-url <url> Runner proto-socket URL (default: served by bootstrap endpoint or OTO_BOOTSTRAP_SOCKET_URL)"
echo " --release-base-url <url> OTO release asset base URL (default: served by bootstrap endpoint or OTO_BOOTSTRAP_RELEASE_BASE_URL)"
echo " --agent-id <id> Agent ID override (default: derived from this device)"
echo " --agent-alias <alias> Alias override (default: device name)"
echo " --install-dir <path> Directory to install OTO agent (default: \$HOME/.oto/bin)"
echo " --config-path <path> Path to config file (default: \$HOME/.oto/agent/config.yaml)"
echo " --workspace-root <path> Workspace root directory (default: \$HOME/.oto/workspace)"
@ -35,6 +38,75 @@ yaml_escape() {
printf '%s' "$value"
}
hash_value() {
local value="$1"
if command -v sha256sum >/dev/null 2>&1; then
printf '%s' "$value" | sha256sum | awk '{print $1}'
elif command -v shasum >/dev/null 2>&1; then
printf '%s' "$value" | shasum -a 256 | awk '{print $1}'
else
printf '%s' "$value" | cksum | awk '{print $1}'
fi
}
device_identity_source() {
local source=""
if [[ "${os_name:-}" = "macos" ]] && command -v ioreg >/dev/null 2>&1; then
source="$(ioreg -rd1 -c IOPlatformExpertDevice 2>/dev/null | awk -F'"' '/IOPlatformUUID/ { print $(NF-1); exit }' || true)"
fi
if [[ -z "$source" && "${os_name:-}" = "linux" ]]; then
if [[ -r /etc/machine-id ]]; then
source="$(tr -d '[:space:]' < /etc/machine-id)"
elif [[ -r /var/lib/dbus/machine-id ]]; then
source="$(tr -d '[:space:]' < /var/lib/dbus/machine-id)"
fi
fi
if [[ -z "$source" ]]; then
source="$(hostname 2>/dev/null || true)"
fi
printf '%s' "$source"
}
default_agent_id() {
local source hash
source="$(device_identity_source)"
hash="$(hash_value "oto:${os_name}:${source}")"
printf 'oto-%s' "${hash:0:32}"
}
default_agent_alias() {
local alias=""
if [[ "${os_name:-}" = "macos" ]] && command -v scutil >/dev/null 2>&1; then
alias="$(scutil --get ComputerName 2>/dev/null || true)"
fi
if [[ -z "$alias" ]]; then
alias="$(hostname -s 2>/dev/null || hostname 2>/dev/null || true)"
fi
if [[ -z "$alias" ]]; then
alias="oto-runner"
fi
printf '%s' "$alias"
}
if [[ -z "$server_url" ]]; then
server_url='__OTO_BOOTSTRAP_DEFAULT_SERVER_URL__'
fi
if [[ -z "$socket_url" ]]; then
socket_url='__OTO_BOOTSTRAP_DEFAULT_SOCKET_URL__'
fi
if [[ -z "$release_base_url" ]]; then
release_base_url='__OTO_BOOTSTRAP_DEFAULT_RELEASE_BASE_URL__'
fi
if [[ "$server_url" = "__OTO_BOOTSTRAP_DEFAULT_SERVER_URL__" ]]; then
server_url=""
fi
if [[ "$socket_url" = "__OTO_BOOTSTRAP_DEFAULT_SOCKET_URL__" ]]; then
socket_url=""
fi
if [[ "$release_base_url" = "__OTO_BOOTSTRAP_DEFAULT_RELEASE_BASE_URL__" ]]; then
release_base_url=""
fi
while [[ $# -gt 0 ]]; do
case "$1" in
--server-url|--edge-url)
@ -49,7 +121,7 @@ while [[ $# -gt 0 ]]; do
agent_id="$2"
shift 2
;;
--enrollment-token)
--token)
enrollment_token="$2"
shift 2
;;
@ -89,8 +161,18 @@ while [[ $# -gt 0 ]]; do
esac
done
if [[ -z "$server_url" || -z "$agent_id" || -z "$enrollment_token" || -z "$release_base_url" ]]; then
echo "Error: Missing required arguments." >&2
if [[ -z "$enrollment_token" ]]; then
echo "Error: Missing required argument: --token." >&2
usage
exit 1
fi
if [[ -z "$server_url" ]]; then
echo "Error: Missing OTO Core URL. Use --server-url or fetch this script from the OTO Core bootstrap endpoint." >&2
usage
exit 1
fi
if [[ -z "$release_base_url" ]]; then
echo "Error: Missing release base URL. Use --release-base-url or fetch this script from the OTO Core bootstrap endpoint." >&2
usage
exit 1
fi
@ -125,6 +207,17 @@ esac
asset_name="oto-${os_name}-${arch_name}.tar.gz"
if [[ -z "$agent_id" ]]; then
agent_id="$(default_agent_id)"
fi
if [[ -z "$agent_alias" ]]; then
agent_alias="$(default_agent_alias)"
fi
if [[ -z "$agent_id" ]]; then
echo "Error: Unable to derive agent id from this device. Use --agent-id to override." >&2
exit 1
fi
download_url="${release_base_url}/${asset_name}"
tmp_dir=$(mktemp -d)
trap 'rm -rf "$tmp_dir"' EXIT
@ -139,6 +232,13 @@ mkdir -p "$install_dir"
cp "$tmp_dir/oto" "$install_dir/oto"
chmod +x "$install_dir/oto"
if [[ "$os_name" = "macos" ]]; then
xattr -dr com.apple.quarantine "$install_dir/oto" 2>/dev/null || true
if command -v codesign >/dev/null 2>&1; then
codesign --force --sign - "$install_dir/oto" >/dev/null 2>&1 || true
fi
fi
echo "Verifying OTO binary..."
if ! "$install_dir/oto" --version > /dev/null 2>&1; then
echo "Error: OTO binary verification failed." >&2

View file

@ -24,7 +24,7 @@ func registerRoutes(mux *http.ServeMux, registry *runnerregistry.Registry, store
mux.HandleFunc("/api/v1/runners/bootstrap-command", withCORS(handleRunnerBootstrapCommand(registry)))
mux.HandleFunc("/api/v1/runners/{id}/heartbeat", withCORS(handleRunnerHeartbeat(registry)))
mux.HandleFunc("/api/v1/runners/{id}/disconnect", withCORS(handleRunnerDisconnect(registry)))
mux.HandleFunc("/api/v1/runners/{id}", withCORS(handleGetRunner(registry)))
mux.HandleFunc("/api/v1/runners/{id}", withCORS(handleRunnerResource(registry)))
mux.HandleFunc("/bootstrap/oto-agent.sh", withCORS(handleServeBootstrapScript(embeddedBootstrapProvider{})))
mux.HandleFunc("/bootstrap/oto-agent.ps1", withCORS(handleServeBootstrapPs1(embeddedBootstrapPs1Provider{})))
mux.HandleFunc("/api/v1/", withCORS(handleRouterWithDispatcher(store, registry, dispatcher)))
@ -33,7 +33,7 @@ func registerRoutes(mux *http.ServeMux, registry *runnerregistry.Registry, store
func withCORS(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PATCH, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
w.Header().Set("Access-Control-Max-Age", "600")
if r.Method == http.MethodOptions {

View file

@ -174,6 +174,62 @@ func handleGetRunner(registry *runnerregistry.Registry) http.HandlerFunc {
}
}
func handleUpdateRunner(registry *runnerregistry.Registry) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPatch {
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
return
}
runnerID := r.PathValue("id")
if runnerID == "" {
parts := strings.Split(r.URL.Path, "/")
if len(parts) >= 5 {
runnerID = parts[4]
}
}
if runnerID == "" {
writeResponse(w, http.StatusBadRequest, errorToJSON("missing runner id"))
return
}
var request struct {
Alias string `json:"alias"`
}
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
writeResponse(w, http.StatusBadRequest, errorToJSON("invalid runner update request"))
return
}
alias := strings.TrimSpace(request.Alias)
if alias == "" {
writeResponse(w, http.StatusBadRequest, errorToJSON("missing alias"))
return
}
record, ok := registry.UpdateAlias(runnerID, alias)
if !ok {
writeResponse(w, http.StatusNotFound, errorToJSON("runner not found"))
return
}
writeResponse(w, http.StatusOK, runnerRecordToJSON(record))
}
}
func handleRunnerResource(registry *runnerregistry.Registry) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
handleGetRunner(registry)(w, r)
case http.MethodPatch:
handleUpdateRunner(registry)(w, r)
default:
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
}
}
}
func shellEscape(s string) string {
return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'"
}
@ -189,90 +245,54 @@ func handleRunnerBootstrapCommand(registry *runnerregistry.Registry) http.Handle
return
}
var request otopb.BootstrapCommandRequest
var request struct {
EnrollmentToken string `json:"enrollment_token"`
Token string `json:"token"`
Target string `json:"target"`
}
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
writeResponse(w, http.StatusBadRequest, errorToJSON("invalid bootstrap command request"))
return
}
runnerID := strings.TrimSpace(request.GetRunnerId())
token := strings.TrimSpace(request.GetEnrollmentToken())
if runnerID == "" || token == "" {
writeResponse(w, http.StatusBadRequest, errorToJSON("missing runner id or enrollment token"))
token := strings.TrimSpace(request.Token)
if token == "" {
token = strings.TrimSpace(request.EnrollmentToken)
}
if token == "" {
writeResponse(w, http.StatusBadRequest, errorToJSON("missing token"))
return
}
target := strings.ToLower(strings.TrimSpace(request.GetTarget()))
target := strings.ToLower(strings.TrimSpace(request.Target))
if target != "" && target != "linux" && target != "macos" && target != "windows" {
writeResponse(w, http.StatusBadRequest, errorToJSON(fmt.Sprintf("unsupported bootstrap target: %s", target)))
return
}
scheme := "http"
if r.TLS != nil {
scheme = "https"
}
serverURL := scheme + "://" + r.Host
u, err := url.Parse(serverURL)
if err != nil || u.Host == "" || u.Scheme == "" {
writeResponse(w, http.StatusBadRequest, errorToJSON("invalid server URL"))
return
}
// Host validation to prevent command injection and host header spoofing
for _, char := range u.Host {
if !((char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z') || (char >= '0' && char <= '9') ||
char == '.' || char == '-' || char == ':' || char == '[' || char == ']') {
writeResponse(w, http.StatusBadRequest, errorToJSON("invalid characters in server Host"))
return
}
}
releaseBaseURL := os.Getenv("OTO_RUNNER_RELEASE_BASE_URL")
if releaseBaseURL == "" {
if scheme == "https" {
releaseBaseURL = serverURL + "/releases"
} else {
writeResponse(w, http.StatusBadRequest, errorToJSON("release base URL must use HTTPS (set OTO_RUNNER_RELEASE_BASE_URL environment variable)"))
return
}
} else {
if !strings.HasPrefix(releaseBaseURL, "https://") {
writeResponse(w, http.StatusBadRequest, errorToJSON("OTO_RUNNER_RELEASE_BASE_URL must use https:// scheme"))
return
}
}
socketURL, err := runnerSocketPublicURL(serverURL)
defaults, err := bootstrapDefaultsForRequest(r, true)
if err != nil {
writeResponse(w, http.StatusBadRequest, errorToJSON(err.Error()))
return
}
serverURL := defaults.ServerURL
var bootstrapCmd string
if target == "windows" {
escapedScriptURL := powershellEscape(serverURL + "/bootstrap/oto-agent.ps1")
escapedServerURL := powershellEscape(serverURL)
escapedSocketURL := powershellEscape(socketURL)
escapedRunnerID := powershellEscape(runnerID)
escapedToken := powershellEscape(token)
escapedReleaseURL := powershellEscape(releaseBaseURL)
bootstrapCmd = fmt.Sprintf(
"powershell -ExecutionPolicy Bypass -Command \"& ([scriptblock]::Create((irm %s))) -- --server-url %s --socket-url %s --agent-id %s --enrollment-token %s --release-base-url %s\"",
escapedScriptURL, escapedServerURL, escapedSocketURL, escapedRunnerID, escapedToken, escapedReleaseURL,
"powershell -ExecutionPolicy Bypass -Command \"& ([scriptblock]::Create((irm %s))) -- --token %s\"",
escapedScriptURL, escapedToken,
)
} else {
escapedScriptURL := shellEscape(serverURL + "/bootstrap/oto-agent.sh")
escapedServerURL := shellEscape(serverURL)
escapedSocketURL := shellEscape(socketURL)
escapedRunnerID := shellEscape(runnerID)
escapedToken := shellEscape(token)
escapedReleaseURL := shellEscape(releaseBaseURL)
bootstrapCmd = fmt.Sprintf(
"curl -fsSL %s | bash -s -- --server-url %s --socket-url %s --agent-id %s --enrollment-token %s --release-base-url %s",
escapedScriptURL, escapedServerURL, escapedSocketURL, escapedRunnerID, escapedToken, escapedReleaseURL,
"curl -fsSL %s | bash -s -- --token %s",
escapedScriptURL, escapedToken,
)
}
@ -284,6 +304,61 @@ func handleRunnerBootstrapCommand(registry *runnerregistry.Registry) http.Handle
}
}
type bootstrapDefaults struct {
ServerURL string
SocketURL string
ReleaseBaseURL string
}
func bootstrapDefaultsForRequest(r *http.Request, requireRelease bool) (bootstrapDefaults, error) {
scheme := "http"
if r.TLS != nil {
scheme = "https"
}
serverURL := scheme + "://" + r.Host
u, err := url.Parse(serverURL)
if err != nil || u.Host == "" || u.Scheme == "" {
return bootstrapDefaults{}, fmt.Errorf("invalid server URL")
}
// Host validation to prevent command injection and host header spoofing.
for _, char := range u.Host {
if !((char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z') || (char >= '0' && char <= '9') ||
char == '.' || char == '-' || char == ':' || char == '[' || char == ']') {
return bootstrapDefaults{}, fmt.Errorf("invalid characters in server Host")
}
}
releaseBaseURL := strings.TrimSpace(os.Getenv("OTO_RUNNER_RELEASE_BASE_URL"))
if releaseBaseURL == "" {
if scheme == "https" {
releaseBaseURL = serverURL + "/releases"
} else if requireRelease {
return bootstrapDefaults{}, fmt.Errorf("release base URL must use HTTPS (set OTO_RUNNER_RELEASE_BASE_URL environment variable)")
}
} else if !strings.HasPrefix(releaseBaseURL, "https://") {
if requireRelease {
return bootstrapDefaults{}, fmt.Errorf("OTO_RUNNER_RELEASE_BASE_URL must use https:// scheme")
}
releaseBaseURL = ""
}
socketURL, err := runnerSocketPublicURL(serverURL)
if err != nil {
if requireRelease {
return bootstrapDefaults{}, err
}
socketURL = ""
}
return bootstrapDefaults{
ServerURL: serverURL,
SocketURL: socketURL,
ReleaseBaseURL: releaseBaseURL,
}, nil
}
func runnerSocketPublicURL(serverURL string) (string, error) {
if configured := strings.TrimSpace(os.Getenv("OTO_RUNNER_SOCKET_PUBLIC_URL")); configured != "" {
if !strings.Contains(configured, "://") {
@ -313,6 +388,8 @@ func handleServeBootstrapScript(provider bootstrapScriptProvider) http.HandlerFu
http.Error(w, "Bootstrap script not available: "+err.Error(), http.StatusInternalServerError)
return
}
defaults, _ := bootstrapDefaultsForRequest(r, false)
content = renderShellBootstrapDefaults(content, defaults)
w.Header().Set("Content-Type", "application/x-sh")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(content)
@ -330,12 +407,38 @@ func handleServeBootstrapPs1(provider bootstrapPs1Provider) http.HandlerFunc {
http.Error(w, "Bootstrap PS1 script not available: "+err.Error(), http.StatusInternalServerError)
return
}
defaults, _ := bootstrapDefaultsForRequest(r, false)
content = renderPowerShellBootstrapDefaults(content, defaults)
w.Header().Set("Content-Type", "application/x-powershell")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(content)
}
}
func renderShellBootstrapDefaults(content []byte, defaults bootstrapDefaults) []byte {
rendered := string(content)
rendered = strings.Replace(rendered, "__OTO_BOOTSTRAP_DEFAULT_SERVER_URL__", shellSingleQuoteContent(defaults.ServerURL), 1)
rendered = strings.Replace(rendered, "__OTO_BOOTSTRAP_DEFAULT_SOCKET_URL__", shellSingleQuoteContent(defaults.SocketURL), 1)
rendered = strings.Replace(rendered, "__OTO_BOOTSTRAP_DEFAULT_RELEASE_BASE_URL__", shellSingleQuoteContent(defaults.ReleaseBaseURL), 1)
return []byte(rendered)
}
func renderPowerShellBootstrapDefaults(content []byte, defaults bootstrapDefaults) []byte {
rendered := string(content)
rendered = strings.Replace(rendered, "__OTO_BOOTSTRAP_DEFAULT_SERVER_URL__", powershellSingleQuoteContent(defaults.ServerURL), 1)
rendered = strings.Replace(rendered, "__OTO_BOOTSTRAP_DEFAULT_SOCKET_URL__", powershellSingleQuoteContent(defaults.SocketURL), 1)
rendered = strings.Replace(rendered, "__OTO_BOOTSTRAP_DEFAULT_RELEASE_BASE_URL__", powershellSingleQuoteContent(defaults.ReleaseBaseURL), 1)
return []byte(rendered)
}
func shellSingleQuoteContent(s string) string {
return strings.ReplaceAll(s, "'", "'\\''")
}
func powershellSingleQuoteContent(s string) string {
return strings.ReplaceAll(s, "'", "''")
}
func writeRunnerRegisterResponse(w http.ResponseWriter, status int, response *otopb.RegisterRunnerResponse) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)

View file

@ -384,6 +384,48 @@ func TestHandleGetRunner(t *testing.T) {
}
}
func TestHandleUpdateRunnerAlias(t *testing.T) {
registry := runnerregistry.New()
registry.Register(&otopb.RegisterRunnerRequest{
EnrollmentToken: "token-123",
RunnerId: "runner-123",
Alias: "mac-mini",
ProtocolVersion: "oto.runner.v1",
Capability: &otopb.RunnerCapability{
Name: "oto-runner",
Version: "1.0.0",
},
})
body := bytes.NewBufferString(`{"alias":"Studio Mini"}`)
req := httptest.NewRequest(http.MethodPatch, "/api/v1/runners/runner-123", body)
rr := httptest.NewRecorder()
handleRunnerResource(registry)(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status = %v, want %v; body=%s", rr.Code, http.StatusOK, rr.Body.String())
}
var res map[string]interface{}
if err := json.Unmarshal(rr.Body.Bytes(), &res); err != nil {
t.Fatalf("decode response: %v", err)
}
if res["runner_id"] != "runner-123" {
t.Fatalf("runner_id = %v", res["runner_id"])
}
if res["alias"] != "Studio Mini" {
t.Fatalf("alias = %v, want Studio Mini", res["alias"])
}
record, ok := registry.Snapshot("runner-123")
if !ok {
t.Fatal("runner missing after update")
}
if record.Alias != "Studio Mini" {
t.Fatalf("registry alias = %q, want Studio Mini", record.Alias)
}
}
func TestHandleRunnerHeartbeatMismatch(t *testing.T) {
registry := runnerregistry.New()
@ -439,18 +481,18 @@ func TestHandleRunnerHeartbeatMismatch(t *testing.T) {
func TestHandleRunnerBootstrapCommand(t *testing.T) {
registry := runnerregistry.New()
// 1. Missing runner_id or enrollment_token should be rejected
bodyMissing := bytes.NewBufferString(`{"runner_id":"","enrollment_token":""}`)
// 1. Missing token should be rejected
bodyMissing := bytes.NewBufferString(`{}`)
req := httptest.NewRequest(http.MethodPost, "/api/v1/runners/bootstrap-command", bodyMissing)
rr := httptest.NewRecorder()
handleRunnerBootstrapCommand(registry)(rr, req)
if rr.Code != http.StatusBadRequest {
t.Fatalf("status = %v, want %v for missing runner_id/token", rr.Code, http.StatusBadRequest)
t.Fatalf("status = %v, want %v for missing token", rr.Code, http.StatusBadRequest)
}
// 2. Missing token only should be rejected
bodyNoToken := bytes.NewBufferString(`{"runner_id":"runner-123","enrollment_token":""}`)
// 2. Empty token should be rejected
bodyNoToken := bytes.NewBufferString(`{"token":""}`)
req = httptest.NewRequest(http.MethodPost, "/api/v1/runners/bootstrap-command", bodyNoToken)
rr = httptest.NewRecorder()
@ -461,7 +503,7 @@ func TestHandleRunnerBootstrapCommand(t *testing.T) {
// 3. Request should be rejected if server is HTTP and OTO_RUNNER_RELEASE_BASE_URL is not set
t.Setenv("OTO_RUNNER_RELEASE_BASE_URL", "")
bodyValid := bytes.NewBufferString(`{"runner_id":"runner-123","enrollment_token":"token-123"}`)
bodyValid := bytes.NewBufferString(`{"token":"token-123"}`)
req = httptest.NewRequest(http.MethodPost, "/api/v1/runners/bootstrap-command", bodyValid)
req.Host = "localhost:8080"
rr = httptest.NewRecorder()
@ -473,7 +515,7 @@ func TestHandleRunnerBootstrapCommand(t *testing.T) {
// 4. Request should be rejected if OTO_RUNNER_RELEASE_BASE_URL is not HTTPS
t.Setenv("OTO_RUNNER_RELEASE_BASE_URL", "http://example.com/releases")
bodyValid = bytes.NewBufferString(`{"runner_id":"runner-123","enrollment_token":"token-123"}`)
bodyValid = bytes.NewBufferString(`{"token":"token-123"}`)
req = httptest.NewRequest(http.MethodPost, "/api/v1/runners/bootstrap-command", bodyValid)
req.Host = "localhost:8080"
rr = httptest.NewRecorder()
@ -485,7 +527,7 @@ func TestHandleRunnerBootstrapCommand(t *testing.T) {
// 5. Valid request should return correct escaped command when OTO_RUNNER_RELEASE_BASE_URL is set to HTTPS
t.Setenv("OTO_RUNNER_RELEASE_BASE_URL", "https://example.com/releases")
bodyValid = bytes.NewBufferString(`{"runner_id":"runner-123","enrollment_token":"token-123"}`)
bodyValid = bytes.NewBufferString(`{"token":"token-123"}`)
req = httptest.NewRequest(http.MethodPost, "/api/v1/runners/bootstrap-command", bodyValid)
req.Host = "localhost:8080"
rr = httptest.NewRecorder()
@ -500,13 +542,13 @@ func TestHandleRunnerBootstrapCommand(t *testing.T) {
t.Fatalf("decode bootstrap command response: %v", err)
}
expectedCmd := "curl -fsSL 'http://localhost:8080/bootstrap/oto-agent.sh' | bash -s -- --server-url 'http://localhost:8080' --socket-url 'tcp://localhost:18080' --agent-id 'runner-123' --enrollment-token 'token-123' --release-base-url 'https://example.com/releases'"
expectedCmd := "curl -fsSL 'http://localhost:8080/bootstrap/oto-agent.sh' | bash -s -- --token 'token-123'"
if response.GetBootstrapCommand() != expectedCmd {
t.Fatalf("bootstrap_command = %q, want %q", response.GetBootstrapCommand(), expectedCmd)
}
// 6. Request with shell metacharacters in runner_id/token should be safely escaped
bodyMalicious := bytes.NewBufferString(`{"runner_id":"runner; rm -rf /","enrollment_token":"token'$(say hello)'"}`)
// 6. Request with shell metacharacters in token should be safely escaped
bodyMalicious := bytes.NewBufferString(`{"token":"token'$(say hello)'"}`)
req = httptest.NewRequest(http.MethodPost, "/api/v1/runners/bootstrap-command", bodyMalicious)
req.Host = "localhost:8080"
rr = httptest.NewRecorder()
@ -520,13 +562,13 @@ func TestHandleRunnerBootstrapCommand(t *testing.T) {
t.Fatalf("decode bootstrap command response: %v", err)
}
expectedEscapedCmd := "curl -fsSL 'http://localhost:8080/bootstrap/oto-agent.sh' | bash -s -- --server-url 'http://localhost:8080' --socket-url 'tcp://localhost:18080' --agent-id 'runner; rm -rf /' --enrollment-token 'token'\\''$(say hello)'\\''' --release-base-url 'https://example.com/releases'"
expectedEscapedCmd := "curl -fsSL 'http://localhost:8080/bootstrap/oto-agent.sh' | bash -s -- --token 'token'\\''$(say hello)'\\'''"
if response.GetBootstrapCommand() != expectedEscapedCmd {
t.Fatalf("bootstrap_command = %q, want %q", response.GetBootstrapCommand(), expectedEscapedCmd)
}
// 7. Malicious Host header with invalid characters should be rejected
bodyValid = bytes.NewBufferString(`{"runner_id":"runner-123","enrollment_token":"token-123"}`)
bodyValid = bytes.NewBufferString(`{"token":"token-123"}`)
req = httptest.NewRequest(http.MethodPost, "/api/v1/runners/bootstrap-command", bodyValid)
req.Host = "localhost; rm -rf /"
rr = httptest.NewRecorder()
@ -537,7 +579,7 @@ func TestHandleRunnerBootstrapCommand(t *testing.T) {
}
// 8. Host with shell metacharacter but no space (e.g. localhost;rm) should also be rejected
bodyValid = bytes.NewBufferString(`{"runner_id":"runner-123","enrollment_token":"token-123"}`)
bodyValid = bytes.NewBufferString(`{"token":"token-123"}`)
req = httptest.NewRequest(http.MethodPost, "/api/v1/runners/bootstrap-command", bodyValid)
req.Host = "localhost;rm"
rr = httptest.NewRecorder()
@ -548,7 +590,7 @@ func TestHandleRunnerBootstrapCommand(t *testing.T) {
}
// 9. target="linux" should work and return Unix shell command
bodyLinux := bytes.NewBufferString(`{"runner_id":"runner-123","enrollment_token":"token-123","target":"linux"}`)
bodyLinux := bytes.NewBufferString(`{"token":"token-123","target":"linux"}`)
req = httptest.NewRequest(http.MethodPost, "/api/v1/runners/bootstrap-command", bodyLinux)
req.Host = "localhost:8080"
rr = httptest.NewRecorder()
@ -560,13 +602,13 @@ func TestHandleRunnerBootstrapCommand(t *testing.T) {
if err := json.Unmarshal(rr.Body.Bytes(), &response); err != nil {
t.Fatalf("decode bootstrap command response: %v", err)
}
expectedLinuxCmd := "curl -fsSL 'http://localhost:8080/bootstrap/oto-agent.sh' | bash -s -- --server-url 'http://localhost:8080' --socket-url 'tcp://localhost:18080' --agent-id 'runner-123' --enrollment-token 'token-123' --release-base-url 'https://example.com/releases'"
expectedLinuxCmd := "curl -fsSL 'http://localhost:8080/bootstrap/oto-agent.sh' | bash -s -- --token 'token-123'"
if response.GetBootstrapCommand() != expectedLinuxCmd {
t.Fatalf("bootstrap_command for linux = %q, want %q", response.GetBootstrapCommand(), expectedLinuxCmd)
}
// 10. target="macos" should work and return Unix shell command
bodyMacos := bytes.NewBufferString(`{"runner_id":"runner-123","enrollment_token":"token-123","target":"macos"}`)
bodyMacos := bytes.NewBufferString(`{"token":"token-123","target":"macos"}`)
req = httptest.NewRequest(http.MethodPost, "/api/v1/runners/bootstrap-command", bodyMacos)
req.Host = "localhost:8080"
rr = httptest.NewRecorder()
@ -578,13 +620,13 @@ func TestHandleRunnerBootstrapCommand(t *testing.T) {
if err := json.Unmarshal(rr.Body.Bytes(), &response); err != nil {
t.Fatalf("decode bootstrap command response: %v", err)
}
expectedMacosCmd := "curl -fsSL 'http://localhost:8080/bootstrap/oto-agent.sh' | bash -s -- --server-url 'http://localhost:8080' --socket-url 'tcp://localhost:18080' --agent-id 'runner-123' --enrollment-token 'token-123' --release-base-url 'https://example.com/releases'"
expectedMacosCmd := "curl -fsSL 'http://localhost:8080/bootstrap/oto-agent.sh' | bash -s -- --token 'token-123'"
if response.GetBootstrapCommand() != expectedMacosCmd {
t.Fatalf("bootstrap_command for macos = %q, want %q", response.GetBootstrapCommand(), expectedMacosCmd)
}
// 11. target="windows" should work and return PowerShell command
bodyWindows := bytes.NewBufferString(`{"runner_id":"runner-123","enrollment_token":"token-123","target":"windows"}`)
bodyWindows := bytes.NewBufferString(`{"token":"token-123","target":"windows"}`)
req = httptest.NewRequest(http.MethodPost, "/api/v1/runners/bootstrap-command", bodyWindows)
req.Host = "localhost:8080"
rr = httptest.NewRecorder()
@ -596,13 +638,13 @@ func TestHandleRunnerBootstrapCommand(t *testing.T) {
if err := json.Unmarshal(rr.Body.Bytes(), &response); err != nil {
t.Fatalf("decode bootstrap command response: %v", err)
}
expectedWindowsCmd := "powershell -ExecutionPolicy Bypass -Command \"& ([scriptblock]::Create((irm 'http://localhost:8080/bootstrap/oto-agent.ps1'))) -- --server-url 'http://localhost:8080' --socket-url 'tcp://localhost:18080' --agent-id 'runner-123' --enrollment-token 'token-123' --release-base-url 'https://example.com/releases'\""
expectedWindowsCmd := "powershell -ExecutionPolicy Bypass -Command \"& ([scriptblock]::Create((irm 'http://localhost:8080/bootstrap/oto-agent.ps1'))) -- --token 'token-123'\""
if response.GetBootstrapCommand() != expectedWindowsCmd {
t.Fatalf("bootstrap_command for windows = %q, want %q", response.GetBootstrapCommand(), expectedWindowsCmd)
}
// 12. target="windows" with malicious runner_id/token should escape properly for PowerShell
bodyWindowsMalicious := bytes.NewBufferString(`{"runner_id":"runner; rm -rf /","enrollment_token":"token'$(say hello)'","target":"windows"}`)
// 12. target="windows" with malicious token should escape properly for PowerShell
bodyWindowsMalicious := bytes.NewBufferString(`{"token":"token'$(say hello)'","target":"windows"}`)
req = httptest.NewRequest(http.MethodPost, "/api/v1/runners/bootstrap-command", bodyWindowsMalicious)
req.Host = "localhost:8080"
rr = httptest.NewRecorder()
@ -614,13 +656,13 @@ func TestHandleRunnerBootstrapCommand(t *testing.T) {
if err := json.Unmarshal(rr.Body.Bytes(), &response); err != nil {
t.Fatalf("decode bootstrap command response: %v", err)
}
expectedWindowsEscapedCmd := "powershell -ExecutionPolicy Bypass -Command \"& ([scriptblock]::Create((irm 'http://localhost:8080/bootstrap/oto-agent.ps1'))) -- --server-url 'http://localhost:8080' --socket-url 'tcp://localhost:18080' --agent-id 'runner; rm -rf /' --enrollment-token 'token''$(say hello)''' --release-base-url 'https://example.com/releases'\""
expectedWindowsEscapedCmd := "powershell -ExecutionPolicy Bypass -Command \"& ([scriptblock]::Create((irm 'http://localhost:8080/bootstrap/oto-agent.ps1'))) -- --token 'token''$(say hello)'''\""
if response.GetBootstrapCommand() != expectedWindowsEscapedCmd {
t.Fatalf("bootstrap_command for windows malicious = %q, want %q", response.GetBootstrapCommand(), expectedWindowsEscapedCmd)
}
// 13. unsupported target should be rejected with 400 Bad Request
bodyUnsupported := bytes.NewBufferString(`{"runner_id":"runner-123","enrollment_token":"token-123","target":"freebsd"}`)
bodyUnsupported := bytes.NewBufferString(`{"token":"token-123","target":"freebsd"}`)
req = httptest.NewRequest(http.MethodPost, "/api/v1/runners/bootstrap-command", bodyUnsupported)
req.Host = "localhost:8080"
rr = httptest.NewRecorder()
@ -673,6 +715,34 @@ func TestHandleServeBootstrapScript(t *testing.T) {
}
}
func TestHandleServeBootstrapScriptInjectsConnectionDefaults(t *testing.T) {
t.Setenv("OTO_RUNNER_RELEASE_BASE_URL", "https://example.com/releases")
t.Setenv("OTO_RUNNER_SOCKET_PUBLIC_URL", "tcp://socket.example.com:18080")
content := []byte(strings.Join([]string{
"server_url='__OTO_BOOTSTRAP_DEFAULT_SERVER_URL__'",
"socket_url='__OTO_BOOTSTRAP_DEFAULT_SOCKET_URL__'",
"release_base_url='__OTO_BOOTSTRAP_DEFAULT_RELEASE_BASE_URL__'",
}, "\n"))
req := httptest.NewRequest(http.MethodGet, "http://localhost:8080/bootstrap/oto-agent.sh", nil)
rr := httptest.NewRecorder()
handleServeBootstrapScript(staticProvider{content: content})(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status = %v, want %v; body = %s", rr.Code, http.StatusOK, rr.Body.String())
}
body := rr.Body.String()
if !strings.Contains(body, "server_url='http://localhost:8080'") {
t.Fatalf("server default not injected: %s", body)
}
if !strings.Contains(body, "socket_url='tcp://socket.example.com:18080'") {
t.Fatalf("socket default not injected: %s", body)
}
if !strings.Contains(body, "release_base_url='https://example.com/releases'") {
t.Fatalf("release default not injected: %s", body)
}
}
func TestHandleServeBootstrapScript_MethodNotAllowed(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/bootstrap/oto-agent.sh", nil)
rr := httptest.NewRecorder()

View file

@ -92,6 +92,9 @@ func (r *Registry) Register(req *otopb.RegisterRunnerRequest) *otopb.RegisterRun
}
r.mu.Lock()
if existing, ok := r.runners[runnerID]; ok && strings.TrimSpace(existing.Alias) != "" {
record.Alias = existing.Alias
}
r.runners[runnerID] = record
r.mu.Unlock()
@ -145,6 +148,19 @@ func (r *Registry) Disconnect(runnerID string) bool {
return true
}
func (r *Registry) UpdateAlias(runnerID, alias string) (RunnerRecord, bool) {
r.mu.Lock()
defer r.mu.Unlock()
record, ok := r.runners[runnerID]
if !ok {
return RunnerRecord{}, false
}
record.Alias = strings.TrimSpace(alias)
r.runners[runnerID] = record
record.CommandTypes = append([]string(nil), record.CommandTypes...)
return record, true
}
func (r *Registry) CheckTimeouts(timeoutDuration time.Duration) {
r.mu.Lock()
defer r.mu.Unlock()

View file

@ -191,6 +191,51 @@ func TestRegistryDisconnect(t *testing.T) {
}
}
func TestRegistryUpdatesAliasAndPreservesItOnReregister(t *testing.T) {
registry := New()
registry.Register(&otopb.RegisterRunnerRequest{
EnrollmentToken: "token-123",
RunnerId: "runner-123",
Alias: "device-default",
ProtocolVersion: "oto.runner.v1",
Capability: &otopb.RunnerCapability{
Name: "oto-runner",
Version: "1.0.0",
},
})
record, ok := registry.UpdateAlias("runner-123", "friendly-name")
if !ok {
t.Fatal("expected alias update to succeed")
}
if record.Alias != "friendly-name" {
t.Fatalf("alias = %q, want friendly-name", record.Alias)
}
registry.Register(&otopb.RegisterRunnerRequest{
EnrollmentToken: "token-123",
RunnerId: "runner-123",
Alias: "device-default",
ProtocolVersion: "oto.runner.v1",
Capability: &otopb.RunnerCapability{
Name: "oto-runner",
Version: "1.0.0",
},
})
record, ok = registry.Snapshot("runner-123")
if !ok {
t.Fatal("expected runner to exist")
}
if record.Alias != "friendly-name" {
t.Fatalf("alias after reregister = %q, want friendly-name", record.Alias)
}
if _, ok := registry.UpdateAlias("unknown-runner", "name"); ok {
t.Fatal("expected alias update on unknown runner to fail")
}
}
func TestRegistryCheckTimeouts(t *testing.T) {
var mockTime time.Time
registry := NewWithClock(func() time.Time { return mockTime })