The bravo agent authenticates to the backend using a per-job one_time_key issued by the /monitor endpoint and stored in DynamoDB keyed by correlation_id. Without it the presigned-URL request (and all telemetry endpoints via sendApiRequest) get rejected, so detection events never upload and insights never appear. For third-party runners, override correlation_id to RUNNER_NAME before the monitor call so the key stored in DDB matches the one the bravo agent will use when requesting presigned URLs. Drop the random api_key and customer field — when OneTimeKey is present the agent uses x-one-time-key header, not vm-api-key.
599 lines
20 KiB
TypeScript
599 lines
20 KiB
TypeScript
import * as core from "@actions/core";
|
|
import * as cp from "child_process";
|
|
import * as fs from "fs";
|
|
import * as httpm from "@actions/http-client";
|
|
import * as path from "path";
|
|
import { v4 as uuidv4 } from "uuid";
|
|
import * as common from "./common";
|
|
import isDocker from "is-docker";
|
|
import { context } from "@actions/github";
|
|
import { EOL } from "os";
|
|
import {
|
|
ArtifactCacheEntry,
|
|
cacheKey,
|
|
cacheFile,
|
|
CompressionMethod,
|
|
isValidEvent,
|
|
} from "./cache";
|
|
import { Configuration, PolicyResponse } from "./interfaces";
|
|
import { fetchPolicy, fetchPolicyFromStore, mergeConfigs } from "./policy-utils";
|
|
import * as cache from "@actions/cache";
|
|
import { getCacheEntry } from "@actions/cache/lib/internal/cacheHttpClient";
|
|
import * as cacheTwirpClient from "@actions/cache/lib/internal/shared/cacheTwirpClient";
|
|
import { GetCacheEntryDownloadURLRequest } from "@actions/cache/lib/generated/results/api/v1/cache";
|
|
import { getCacheServiceVersion } from "@actions/cache/lib/internal/config";
|
|
|
|
import * as utils from "@actions/cache/lib/internal/cacheUtils";
|
|
import { isARCRunner, sendAllowedEndpoints } from "./arc-runner";
|
|
import {
|
|
STEPSECURITY_API_URL,
|
|
STEPSECURITY_TELEMETRY_URL,
|
|
STEPSECURITY_WEB_URL,
|
|
} from "./configs";
|
|
import { isGithubHosted, isTLSEnabled } from "./tls-inspect";
|
|
import {
|
|
installAgent,
|
|
installAgentBravo,
|
|
installMacosAgent,
|
|
installWindowsAgent,
|
|
} from "./install-agent";
|
|
|
|
import { chownForFolder, detectThirdPartyRunnerProvider, isAgentInstalled, isPlatformSupported, shouldDeployAgentOnSelfHosted } from "./utils";
|
|
|
|
interface MonitorResponse {
|
|
runner_ip_address?: string;
|
|
one_time_key?: string;
|
|
monitoring_started?: boolean;
|
|
}
|
|
|
|
(async () => {
|
|
try {
|
|
console.log("[harden-runner] pre-step");
|
|
|
|
const customProperties = context?.payload?.repository?.custom_properties || {};
|
|
if (customProperties["skip-harden-runner"] === "true") {
|
|
console.log("Skipping harden-runner: custom property 'skip-harden-runner' is set to 'true'");
|
|
return;
|
|
}
|
|
|
|
if (!isPlatformSupported(process.platform)) {
|
|
console.log(common.UNSUPPORTED_RUNNER_MESSAGE);
|
|
return;
|
|
}
|
|
if (isGithubHosted() && isDocker()) {
|
|
console.log(common.CONTAINER_MESSAGE);
|
|
return;
|
|
}
|
|
|
|
var correlation_id = uuidv4();
|
|
var api_url = STEPSECURITY_API_URL;
|
|
var web_url = STEPSECURITY_WEB_URL;
|
|
|
|
let confg: Configuration = {
|
|
repo: process.env["GITHUB_REPOSITORY"],
|
|
run_id: process.env["GITHUB_RUN_ID"],
|
|
correlation_id: correlation_id,
|
|
working_directory: process.env["GITHUB_WORKSPACE"],
|
|
api_url: api_url,
|
|
telemetry_url: STEPSECURITY_TELEMETRY_URL,
|
|
allowed_endpoints: core.getInput("allowed-endpoints"),
|
|
egress_policy: core.getInput("egress-policy"),
|
|
disable_telemetry: core.getBooleanInput("disable-telemetry"),
|
|
disable_sudo: core.getBooleanInput("disable-sudo"),
|
|
disable_sudo_and_containers: core.getBooleanInput(
|
|
"disable-sudo-and-containers"
|
|
),
|
|
disable_file_monitoring: core.getBooleanInput("disable-file-monitoring"),
|
|
private: context?.payload?.repository?.private || false,
|
|
is_github_hosted: isGithubHosted(),
|
|
is_debug: core.isDebug(),
|
|
one_time_key: "",
|
|
api_key: core.getInput("api-key"),
|
|
use_policy_store: core.getBooleanInput("use-policy-store"),
|
|
deploy_on_self_hosted_vm: core.getBooleanInput("deploy-on-self-hosted-vm"),
|
|
};
|
|
|
|
if (confg.api_key !== "") {
|
|
core.setSecret(confg.api_key);
|
|
}
|
|
|
|
let policyName = core.getInput("policy");
|
|
if (confg.use_policy_store) {
|
|
console.log(`Fetching policy from policy store`);
|
|
if (confg.api_key === "") {
|
|
core.setFailed("api-key is required when use-policy-store is set to true");
|
|
} else {
|
|
try {
|
|
const repoName = (process.env["GITHUB_REPOSITORY"] || "").split("/")[1] || "";
|
|
const workflowRef = process.env["GITHUB_WORKFLOW_REF"] || "";
|
|
const workflow = workflowRef.replace(/.*\.github\/workflows\//, "").replace(/@.*/, "");
|
|
let result: PolicyResponse | null = await fetchPolicyFromStore(
|
|
context.repo.owner,
|
|
repoName,
|
|
confg.api_key,
|
|
workflow,
|
|
confg.run_id,
|
|
confg.correlation_id
|
|
);
|
|
if (result !== null) {
|
|
core.info(`Policy found: ${result.policy_name || "unnamed"}`);
|
|
confg = mergeConfigs(confg, result);
|
|
} else {
|
|
core.info("No policy found in policy store. Defaulting to audit mode.");
|
|
confg.egress_policy = "audit";
|
|
}
|
|
} catch (err) {
|
|
core.info(`[!] ${err}`);
|
|
if (err.statusCode >= 400 && err.statusCode < 500) {
|
|
core.info("Policy not found in policy store. Defaulting to audit mode.");
|
|
confg.egress_policy = "audit";
|
|
} else {
|
|
core.error(`Unexpected error fetching from policy store: ${err}. Falling back to audit mode.`);
|
|
confg.egress_policy = "audit";
|
|
}
|
|
}
|
|
}
|
|
} else if (policyName !== "") {
|
|
console.log(`Fetching policy from API with name: ${policyName}`);
|
|
try {
|
|
let idToken: string = await core.getIDToken();
|
|
let result: PolicyResponse = await fetchPolicy(
|
|
context.repo.owner,
|
|
policyName,
|
|
idToken
|
|
);
|
|
confg = mergeConfigs(confg, result);
|
|
} catch (err) {
|
|
core.info(`[!] ${err}`);
|
|
// Only fail the job if ID token is not available
|
|
if (err.message && err.message.includes('Unable to get ACTIONS_ID_TOKEN_REQUEST')) {
|
|
core.setFailed('Policy store requires id-token write permission as it uses OIDC to fetch the policy from StepSecurity API. Please add "id-token: write" to your job permissions.');
|
|
} else {
|
|
// Handle different HTTP status codes
|
|
if (err.statusCode >= 400 && err.statusCode < 500) {
|
|
core.error('Policy not found');
|
|
} else {
|
|
core.error(`Unexpected error occurred: ${err}. Falling back to egress policy audit`);
|
|
confg.egress_policy = 'audit';
|
|
}
|
|
}
|
|
}
|
|
}
|
|
fs.appendFileSync(
|
|
process.env.GITHUB_STATE,
|
|
`disableSudo=${confg.disable_sudo}${EOL}`,
|
|
{
|
|
encoding: "utf8",
|
|
}
|
|
);
|
|
fs.appendFileSync(
|
|
process.env.GITHUB_STATE,
|
|
`disableSudoAndContainers=${confg.disable_sudo_and_containers}${EOL}`,
|
|
{
|
|
encoding: "utf8",
|
|
}
|
|
);
|
|
core.info(`[!] Current Configuration: \n${JSON.stringify(confg)}\n`);
|
|
|
|
if (confg.egress_policy !== "audit" && confg.egress_policy !== "block") {
|
|
core.setFailed("egress-policy must be either audit or block");
|
|
}
|
|
|
|
if (confg.egress_policy === "block" && confg.allowed_endpoints === "") {
|
|
core.warning(
|
|
"egress-policy is set to block (default) and allowed-endpoints is empty. No outbound traffic will be allowed for job steps."
|
|
);
|
|
}
|
|
|
|
if (confg.disable_telemetry !== true && confg.disable_telemetry !== false) {
|
|
core.setFailed("disable-telemetry must be a boolean value");
|
|
}
|
|
|
|
if (isValidEvent() && confg.egress_policy === "block") {
|
|
try {
|
|
const cacheResult = await cache.saveCache(
|
|
[path.join(__dirname, "cache.txt")],
|
|
cacheKey
|
|
);
|
|
console.log(cacheResult);
|
|
} catch (exception) {
|
|
console.log(exception);
|
|
}
|
|
|
|
const cacheServiceVersion: string = getCacheServiceVersion();
|
|
|
|
switch (cacheServiceVersion) {
|
|
case "v2":
|
|
core.info(`cache version: v2`);
|
|
try {
|
|
const cacheFilePath = path.join(__dirname, "cache.txt");
|
|
core.info(`cacheFilePath ${cacheFilePath}`);
|
|
|
|
const twirpClient = cacheTwirpClient.internalCacheTwirpClient();
|
|
const compressionMethod = await utils.getCompressionMethod();
|
|
|
|
const request: GetCacheEntryDownloadURLRequest = {
|
|
key: cacheKey,
|
|
restoreKeys: [],
|
|
version: utils.getCacheVersion(
|
|
[cacheFilePath],
|
|
compressionMethod,
|
|
false
|
|
),
|
|
};
|
|
|
|
const response = await twirpClient.GetCacheEntryDownloadURL(
|
|
request
|
|
);
|
|
|
|
const url = new URL(response.signedDownloadUrl);
|
|
core.info(
|
|
`Adding cacheHost: ${url.hostname}:443 to allowed-endpoints`
|
|
);
|
|
|
|
confg.allowed_endpoints += ` ${url.hostname}:443`;
|
|
} catch (e) {
|
|
core.info(`Unable to fetch cacheURL ${e}`);
|
|
if (confg.egress_policy === "block") {
|
|
core.info("Switching egress-policy to audit mode");
|
|
confg.egress_policy = "audit";
|
|
}
|
|
}
|
|
break;
|
|
|
|
case "v1":
|
|
core.info(`cache version: v1`);
|
|
|
|
try {
|
|
const compressionMethod: CompressionMethod =
|
|
await utils.getCompressionMethod();
|
|
const cacheFilePath = path.join(__dirname, "cache.txt");
|
|
core.info(`cacheFilePath ${cacheFilePath}`);
|
|
|
|
const cacheEntry: ArtifactCacheEntry = await getCacheEntry(
|
|
[cacheKey],
|
|
[cacheFilePath],
|
|
{
|
|
compressionMethod: compressionMethod,
|
|
}
|
|
);
|
|
const url = new URL(cacheEntry.archiveLocation);
|
|
core.info(
|
|
`Adding cacheHost: ${url.hostname}:443 to allowed-endpoints`
|
|
);
|
|
|
|
confg.allowed_endpoints += ` ${url.hostname}:443`;
|
|
} catch (exception) {
|
|
// some exception has occurred.
|
|
core.info(`Unable to fetch cacheURL ${exception}`);
|
|
if (confg.egress_policy === "block") {
|
|
core.info("Switching egress-policy to audit mode");
|
|
confg.egress_policy = "audit";
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!confg.disable_telemetry || confg.egress_policy === "audit") {
|
|
common.printInfo(web_url);
|
|
}
|
|
|
|
if (isARCRunner()) {
|
|
console.log(`[!] ${common.ARC_RUNNER_MESSAGE}`);
|
|
if (confg.egress_policy === "block") {
|
|
sendAllowedEndpoints(confg.allowed_endpoints);
|
|
await sleep(10000);
|
|
}
|
|
return;
|
|
}
|
|
|
|
const runnerName = process.env.RUNNER_NAME || "";
|
|
core.info(`RUNNER_NAME: ${runnerName}`);
|
|
if (!isGithubHosted()) {
|
|
const thirdPartyProvider = detectThirdPartyRunnerProvider();
|
|
if (thirdPartyProvider) {
|
|
core.info(`Detected ${thirdPartyProvider} runner environment. Installing agent-bravo.`);
|
|
confg.correlation_id = runnerName || confg.correlation_id;
|
|
await callMonitorEndpoint(api_url, confg);
|
|
await installAgentForBravo(context.repo.owner, confg);
|
|
return;
|
|
}
|
|
|
|
fs.appendFileSync(process.env.GITHUB_STATE, `selfHosted=true${EOL}`, {
|
|
encoding: "utf8",
|
|
});
|
|
|
|
core.info(common.SELF_HOSTED_RUNNER_MESSAGE);
|
|
|
|
const inContainer = isDocker();
|
|
const alreadyInstalled = isAgentInstalled(process.platform);
|
|
|
|
if (shouldDeployAgentOnSelfHosted(confg.deploy_on_self_hosted_vm, inContainer, alreadyInstalled)) {
|
|
if (process.platform !== "linux") {
|
|
core.info("deploy-on-self-hosted-vm is only supported on Linux. Skipping agent deployment.");
|
|
} else {
|
|
core.info("deploy-on-self-hosted-vm is enabled. Installing agent on self-hosted runner.");
|
|
await installAgentForSelfHosted(context.repo.owner, confg);
|
|
}
|
|
} else {
|
|
if (confg.deploy_on_self_hosted_vm && inContainer) {
|
|
core.info("Skipping agent deployment: running inside a container.");
|
|
}
|
|
if (confg.deploy_on_self_hosted_vm && alreadyInstalled) {
|
|
core.info("Agent already installed on self-hosted runner, skipping installation.");
|
|
}
|
|
}
|
|
|
|
if (confg.egress_policy === "block" && !confg.deploy_on_self_hosted_vm) {
|
|
sendAllowedEndpoints(confg.allowed_endpoints);
|
|
await sleep(5000);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (isGithubHosted() && process.env.STEP_SECURITY_HARDEN_RUNNER === "true") {
|
|
fs.appendFileSync(process.env.GITHUB_STATE, `customVMImage=true${EOL}`, {
|
|
encoding: "utf8",
|
|
});
|
|
|
|
core.info("This job is running on a custom VM image with Harden Runner installed.");
|
|
|
|
if (confg.egress_policy === "block") {
|
|
sendAllowedEndpoints(confg.allowed_endpoints);
|
|
await sleep(5000);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (isGithubHosted() && isAgentInstalled(process.platform)) {
|
|
console.log("Agent already installed, skipping installation");
|
|
return;
|
|
}
|
|
|
|
let _http = new httpm.HttpClient();
|
|
let statusCode: number | undefined;
|
|
_http.requestOptions = { socketTimeout: 3 * 1000 };
|
|
let addSummary = "false";
|
|
try {
|
|
const monitorRequestData = {
|
|
correlation_id: correlation_id,
|
|
job: process.env["GITHUB_JOB"],
|
|
};
|
|
const resp = await _http.postJson<MonitorResponse>(
|
|
`${api_url}/github/${process.env["GITHUB_REPOSITORY"]}/actions/runs/${process.env["GITHUB_RUN_ID"]}/monitor`,
|
|
monitorRequestData
|
|
);
|
|
|
|
const responseData = resp.result;
|
|
statusCode = resp.statusCode; // adding error code to check whether agent is getting installed or not.
|
|
fs.appendFileSync(
|
|
process.env.GITHUB_STATE,
|
|
`monitorStatusCode=${statusCode}${EOL}`,
|
|
{
|
|
encoding: "utf8",
|
|
}
|
|
);
|
|
|
|
if (statusCode === 200 && responseData) {
|
|
console.log(`Runner IP Address: ${responseData.runner_ip_address}`);
|
|
confg.one_time_key = responseData.one_time_key;
|
|
addSummary = responseData.monitoring_started ? "true" : "false";
|
|
}
|
|
} catch (e) {
|
|
console.log(`error in connecting to ${api_url}: ${e}`);
|
|
}
|
|
fs.appendFileSync(
|
|
process.env.GITHUB_STATE,
|
|
`addSummary=${addSummary}${EOL}`,
|
|
{
|
|
encoding: "utf8",
|
|
}
|
|
);
|
|
fs.appendFileSync(
|
|
process.env.GITHUB_STATE,
|
|
`correlation_id=${correlation_id}${EOL}`,
|
|
{
|
|
encoding: "utf8",
|
|
}
|
|
);
|
|
|
|
console.log(`Step Security Job Correlation ID: ${correlation_id}`);
|
|
if (String(statusCode) === common.STATUS_HARDEN_RUNNER_UNAVAILABLE) {
|
|
console.log(common.HARDEN_RUNNER_UNAVAILABLE_MESSAGE);
|
|
return;
|
|
}
|
|
|
|
const { api_key, use_policy_store, ...agentConfig } = confg;
|
|
const configStr = JSON.stringify(agentConfig);
|
|
|
|
// platform specific
|
|
let statusFile = "";
|
|
let logFile = "";
|
|
let agentInstalled = false;
|
|
|
|
switch (process.platform) {
|
|
case "linux":
|
|
statusFile = "/home/agent/agent.status";
|
|
logFile = "/home/agent/agent.log";
|
|
|
|
cp.execSync("sudo mkdir -p /home/agent");
|
|
chownForFolder(process.env.USER, "/home/agent");
|
|
|
|
let isTLS = await isTLSEnabled(context.repo.owner);
|
|
agentInstalled = await installAgent(isTLS, configStr);
|
|
|
|
break;
|
|
case "win32":
|
|
core.info("Installing Windows Agent...");
|
|
agentInstalled = await installWindowsAgent(configStr);
|
|
|
|
const agentDir = process.env.STATE_agentDir || "C:\\agent";
|
|
statusFile = path.join(agentDir, "agent.status");
|
|
logFile = path.join(agentDir, "agent.log");
|
|
|
|
break;
|
|
case "darwin":
|
|
const installed = await installMacosAgent(configStr);
|
|
if (!installed) {
|
|
core.warning("😭 macos agent installation failed");
|
|
}
|
|
return; // early return
|
|
default:
|
|
throw new Error(
|
|
`Setup failed because of unsupported platform: ${process.platform}`
|
|
);
|
|
}
|
|
|
|
if (agentInstalled) {
|
|
var counter = 0;
|
|
while (true) {
|
|
if (!fs.existsSync(statusFile)) {
|
|
counter++;
|
|
if (counter > 30) {
|
|
console.log("timed out");
|
|
if (fs.existsSync(logFile)) {
|
|
var content = fs.readFileSync(logFile, "utf-8");
|
|
console.log(content);
|
|
}
|
|
break;
|
|
}
|
|
await sleep(300);
|
|
} // The file *does* exist
|
|
else {
|
|
// Read the file
|
|
var content = fs.readFileSync(statusFile, "utf-8");
|
|
console.log(content);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
} catch (error) {
|
|
core.setFailed(error.message);
|
|
}
|
|
// see https://github.com/ruby/setup-ruby/issues/543
|
|
process.exit(0);
|
|
})();
|
|
|
|
export function sleep(ms: number) {
|
|
return new Promise((resolve) => {
|
|
setTimeout(resolve, ms);
|
|
});
|
|
}
|
|
|
|
async function callMonitorEndpoint(api_url: string, confg: Configuration) {
|
|
const _http = new httpm.HttpClient();
|
|
_http.requestOptions = { socketTimeout: 3 * 1000 };
|
|
try {
|
|
const monitorRequestData = {
|
|
correlation_id: confg.correlation_id,
|
|
job: process.env["GITHUB_JOB"],
|
|
};
|
|
const resp = await _http.postJson<MonitorResponse>(
|
|
`${api_url}/github/${process.env["GITHUB_REPOSITORY"]}/actions/runs/${process.env["GITHUB_RUN_ID"]}/monitor`,
|
|
monitorRequestData
|
|
);
|
|
if (resp.statusCode === 200 && resp.result) {
|
|
console.log(`Runner IP Address: ${resp.result.runner_ip_address}`);
|
|
confg.one_time_key = resp.result.one_time_key;
|
|
}
|
|
} catch (e) {
|
|
console.log(`error in connecting to ${api_url}: ${e}`);
|
|
}
|
|
}
|
|
|
|
export async function installAgentForSelfHosted(owner: string, confg: Configuration) {
|
|
try {
|
|
console.log("Installing Harden Runner agent for self-hosted runner");
|
|
|
|
let isTLS = await isTLSEnabled(owner);
|
|
|
|
if (!isTLS) {
|
|
console.log("TLS is not enabled for this organization. Agent installation skipped for self-hosted runner.");
|
|
return;
|
|
}
|
|
|
|
const selfHostedConfig = {
|
|
customer: owner,
|
|
working_directory: confg.working_directory,
|
|
api_url: confg.api_url,
|
|
api_key: uuidv4(),
|
|
allowed_endpoints: confg.allowed_endpoints,
|
|
egress_policy: confg.egress_policy,
|
|
disable_telemetry: confg.disable_telemetry,
|
|
disable_sudo: confg.disable_sudo,
|
|
disable_sudo_and_containers: confg.disable_sudo_and_containers,
|
|
disable_file_monitoring: confg.disable_file_monitoring,
|
|
is_github_hosted: false,
|
|
};
|
|
const selfHostedConfigStr = JSON.stringify(selfHostedConfig);
|
|
|
|
cp.execSync("sudo mkdir -p /home/agent");
|
|
chownForFolder(process.env.USER, "/home/agent");
|
|
|
|
const agentInstalled = await installAgent(isTLS, selfHostedConfigStr);
|
|
|
|
if (agentInstalled) {
|
|
const statusFile = "/home/agent/agent.status";
|
|
const logFile = "/home/agent/agent.log";
|
|
let counter = 0;
|
|
while (true) {
|
|
if (!fs.existsSync(statusFile)) {
|
|
counter++;
|
|
if (counter > 30) {
|
|
console.log("timed out");
|
|
if (fs.existsSync(logFile)) {
|
|
const content = fs.readFileSync(logFile, "utf-8");
|
|
console.log(content);
|
|
}
|
|
break;
|
|
}
|
|
await sleep(300);
|
|
} else {
|
|
const content = fs.readFileSync(statusFile, "utf-8");
|
|
console.log(content);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.log(`Failed to install agent for self-hosted runner: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
export async function installAgentForBravo(owner: string, confg: Configuration) {
|
|
try {
|
|
console.log("Installing Harden Runner bravo agent for third-party runner");
|
|
|
|
let isTLS = await isTLSEnabled(owner);
|
|
|
|
if (!isTLS) {
|
|
console.log("TLS is not enabled for this organization. Bravo agent installation skipped.");
|
|
return;
|
|
}
|
|
|
|
const bravoConfig = {
|
|
repo: confg.repo,
|
|
run_id: confg.run_id,
|
|
correlation_id: confg.correlation_id,
|
|
working_directory: confg.working_directory,
|
|
api_url: confg.api_url,
|
|
one_time_key: confg.one_time_key,
|
|
allowed_endpoints: confg.allowed_endpoints,
|
|
egress_policy: confg.egress_policy,
|
|
disable_telemetry: confg.disable_telemetry,
|
|
disable_sudo: confg.disable_sudo,
|
|
disable_sudo_and_containers: confg.disable_sudo_and_containers,
|
|
disable_file_monitoring: confg.disable_file_monitoring,
|
|
private: confg.private,
|
|
is_github_hosted: true,
|
|
};
|
|
const bravoConfigStr = JSON.stringify(bravoConfig);
|
|
|
|
cp.execSync("sudo mkdir -p /home/agent");
|
|
chownForFolder(process.env.USER, "/home/agent");
|
|
|
|
await installAgentBravo(bravoConfigStr);
|
|
} catch (error) {
|
|
console.log(`Failed to install bravo agent: ${error.message}`);
|
|
}
|
|
}
|