Skip to content

Commit 600d47b

Browse files
cvgithub-advanced-security[bot]cjagwaniericksoa
authored
refactor(onboard): extract gateway bootstrap repair helpers (#3306)
## Summary Extract OpenShell gateway bootstrap secret repair helpers out of the large onboarding module. This continues the onboarding cleanup stack by isolating the repair plan, generated Kubernetes secret script, cluster command wrappers, and repair orchestration behind a focused helper module. ## Changes - Add `src/lib/onboard/gateway-bootstrap.ts` for bootstrap secret repair planning, script generation, missing-secret detection, healthcheck execution, and repair orchestration. - Update `src/lib/onboard.ts` to use the extracted gateway bootstrap helpers while preserving existing exports and call sites. - Add unit tests covering repair-plan normalization, no-op scripts, targeted repair scripts, missing-secret parsing, and successful repair behavior. ## Type of Change - [x] Code change (feature, bug fix, or refactor) - [ ] Code change with doc updates - [ ] Doc only (prose changes, no code sample modifications) - [ ] Doc only (includes code sample changes) ## Verification - [x] `npx prek run --all-files` passes - [x] `npm test` passes - [x] Tests added or updated for new or changed behavior - [x] No secrets, API keys, or credentials committed - [ ] Docs updated for user-facing behavior changes - [ ] `make docs` builds without warnings (doc changes only) - [ ] Doc pages follow the [style guide](https://github.com/NVIDIA/NemoClaw/blob/main/docs/CONTRIBUTING.md) (doc changes only) - [ ] New doc pages include SPDX header and frontmatter (new pages only) --- Signed-off-by: Carlos Villela <cvillela@nvidia.com> --------- Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> Co-authored-by: cjagwani <cjagwani@nvidia.com> Co-authored-by: Aaron Erickson <aerickson@nvidia.com>
1 parent 6ec0dbe commit 600d47b

5 files changed

Lines changed: 524 additions & 247 deletions

File tree

src/lib/onboard.ts

Lines changed: 52 additions & 247 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,15 @@ const {
4444
const {
4545
agentSupportsWebSearch,
4646
}: typeof import("./onboard/web-search-support") = require("./onboard/web-search-support");
47+
const dashboardAccess: typeof import("./onboard/dashboard-access") = require("./onboard/dashboard-access");
48+
const {
49+
buildGatewayBootstrapSecretsScript,
50+
createGatewayBootstrapRepairHelpers,
51+
getGatewayBootstrapRepairPlan,
52+
}: typeof import("./onboard/gateway-bootstrap") = require("./onboard/gateway-bootstrap");
53+
const {
54+
verifyWebSearchInsideSandbox: verifyWebSearchInsideSandboxWithDeps,
55+
}: typeof import("./onboard/web-search-verify") = require("./onboard/web-search-verify");
4756
const {
4857
verifyWebSearchInsideSandbox: verifyWebSearchInsideSandboxWithDeps,
4958
}: typeof import("./onboard/web-search-verify") = require("./onboard/web-search-verify");
@@ -346,12 +355,6 @@ const DIM = USE_COLOR ? "\x1b[2m" : "";
346355
const RESET = USE_COLOR ? "\x1b[0m" : "";
347356
let OPENSHELL_BIN: string | null = null;
348357
const GATEWAY_NAME = "nemoclaw";
349-
const GATEWAY_BOOTSTRAP_SECRET_NAMES = [
350-
"openshell-server-tls",
351-
"openshell-server-client-ca",
352-
"openshell-client-tls",
353-
"openshell-ssh-handshake",
354-
];
355358
const BACK_TO_SELECTION = "__NEMOCLAW_BACK_TO_SELECTION__";
356359
type HermesAuthMethod = "oauth" | "api_key";
357360
const HERMES_AUTH_METHOD_OAUTH: HermesAuthMethod = "oauth";
@@ -3183,6 +3186,15 @@ function getGatewayLocalEndpoint(): string {
31833186
return dockerDriverGatewayEnv.getGatewayHttpsEndpoint();
31843187
}
31853188

3189+
const {
3190+
gatewayClusterHealthcheckPassed,
3191+
repairGatewayBootstrapSecrets,
3192+
} = createGatewayBootstrapRepairHelpers({
3193+
buildGatewayClusterExecArgv,
3194+
run,
3195+
runCapture,
3196+
});
3197+
31863198
function isLinuxDockerDriverGatewayEnabled(
31873199
platform: NodeJS.Platform = process.platform,
31883200
arch: NodeJS.Architecture = process.arch,
@@ -3550,117 +3562,7 @@ function registerDockerDriverGatewayEndpoint(): boolean {
35503562
return ok;
35513563
}
35523564

3553-
function getGatewayBootstrapRepairPlan(missingSecrets: string[] = []) {
3554-
const allowed = new Set(GATEWAY_BOOTSTRAP_SECRET_NAMES);
3555-
const normalized = [
3556-
...new Set((missingSecrets || []).map((name) => String(name).trim()).filter(Boolean)),
3557-
].filter((name) => allowed.has(name));
3558-
const missing = new Set(normalized);
3559-
const needsClientBundle =
3560-
missing.has("openshell-server-client-ca") || missing.has("openshell-client-tls");
35613565

3562-
return {
3563-
missingSecrets: normalized,
3564-
needsRepair: normalized.length > 0,
3565-
needsServerTls: missing.has("openshell-server-tls"),
3566-
needsClientBundle,
3567-
needsHandshake: missing.has("openshell-ssh-handshake"),
3568-
};
3569-
}
3570-
3571-
function buildGatewayBootstrapSecretsScript(missingSecrets: string[] = []): string {
3572-
const plan = getGatewayBootstrapRepairPlan(missingSecrets);
3573-
if (!plan.needsRepair) return "exit 0";
3574-
3575-
return `
3576-
set -eu
3577-
export KUBECONFIG=/etc/rancher/k3s/k3s.yaml
3578-
kubectl get namespace openshell >/dev/null 2>&1
3579-
kubectl -n openshell get statefulset/openshell >/dev/null 2>&1
3580-
TMPDIR="$(mktemp -d)"
3581-
cleanup() {
3582-
rm -rf "$TMPDIR"
3583-
}
3584-
trap cleanup EXIT
3585-
if ${plan.needsServerTls ? "true" : "false"}; then
3586-
cat >"$TMPDIR/server-ext.cnf" <<'EOF'
3587-
subjectAltName=DNS:openshell,DNS:openshell.openshell,DNS:openshell.openshell.svc,DNS:openshell.openshell.svc.cluster.local,DNS:localhost,IP:127.0.0.1
3588-
extendedKeyUsage=serverAuth
3589-
EOF
3590-
openssl req -nodes -newkey rsa:2048 -keyout "$TMPDIR/server.key" -out "$TMPDIR/server.csr" -subj "/CN=openshell.openshell.svc.cluster.local" >/dev/null 2>&1
3591-
openssl x509 -req -in "$TMPDIR/server.csr" -signkey "$TMPDIR/server.key" -out "$TMPDIR/server.crt" -days 3650 -sha256 -extfile "$TMPDIR/server-ext.cnf" >/dev/null 2>&1
3592-
kubectl create secret tls -n openshell openshell-server-tls --cert="$TMPDIR/server.crt" --key="$TMPDIR/server.key" --dry-run=client -o yaml | kubectl apply -f -
3593-
fi
3594-
if ${plan.needsClientBundle ? "true" : "false"}; then
3595-
cat >"$TMPDIR/client-ext.cnf" <<'EOF'
3596-
extendedKeyUsage=clientAuth
3597-
EOF
3598-
openssl req -x509 -nodes -newkey rsa:2048 -keyout "$TMPDIR/client-ca.key" -out "$TMPDIR/client-ca.crt" -subj "/CN=openshell-client-ca" -days 3650 >/dev/null 2>&1
3599-
openssl req -nodes -newkey rsa:2048 -keyout "$TMPDIR/client.key" -out "$TMPDIR/client.csr" -subj "/CN=openshell-client" >/dev/null 2>&1
3600-
openssl x509 -req -in "$TMPDIR/client.csr" -CA "$TMPDIR/client-ca.crt" -CAkey "$TMPDIR/client-ca.key" -CAcreateserial -out "$TMPDIR/client.crt" -days 3650 -sha256 -extfile "$TMPDIR/client-ext.cnf" >/dev/null 2>&1
3601-
kubectl create secret generic -n openshell openshell-server-client-ca --from-file=ca.crt="$TMPDIR/client-ca.crt" --dry-run=client -o yaml | kubectl apply -f -
3602-
kubectl create secret generic -n openshell openshell-client-tls --from-file=tls.crt="$TMPDIR/client.crt" --from-file=tls.key="$TMPDIR/client.key" --from-file=ca.crt="$TMPDIR/client-ca.crt" --dry-run=client -o yaml | kubectl apply -f -
3603-
fi
3604-
if ${plan.needsHandshake ? "true" : "false"}; then
3605-
kubectl create secret generic -n openshell openshell-ssh-handshake --from-literal=secret="$(openssl rand -hex 32)" --dry-run=client -o yaml | kubectl apply -f -
3606-
fi
3607-
`;
3608-
}
3609-
3610-
function runGatewayClusterCapture(script: string, opts: RunnerOptions = {}) {
3611-
return runCapture(buildGatewayClusterExecArgv(script), opts);
3612-
}
3613-
3614-
function runGatewayCluster(script: string, opts: RunnerOptions = {}) {
3615-
return run(buildGatewayClusterExecArgv(script), opts);
3616-
}
3617-
3618-
function listMissingGatewayBootstrapSecrets() {
3619-
const output = runGatewayClusterCapture(
3620-
`
3621-
set -eu
3622-
export KUBECONFIG=/etc/rancher/k3s/k3s.yaml
3623-
kubectl get namespace openshell >/dev/null 2>&1 || exit 0
3624-
kubectl -n openshell get statefulset/openshell >/dev/null 2>&1 || exit 0
3625-
for name in ${GATEWAY_BOOTSTRAP_SECRET_NAMES.map((name) => shellQuote(name)).join(" ")}; do
3626-
kubectl -n openshell get secret "$name" >/dev/null 2>&1 || printf '%s\\n' "$name"
3627-
done
3628-
`,
3629-
{ ignoreError: true },
3630-
);
3631-
return output
3632-
.split("\n")
3633-
.map((line) => line.trim())
3634-
.filter(Boolean);
3635-
}
3636-
3637-
function gatewayClusterHealthcheckPassed(): boolean {
3638-
const result = runGatewayCluster("/usr/local/bin/cluster-healthcheck.sh", {
3639-
ignoreError: true,
3640-
suppressOutput: true,
3641-
});
3642-
return result.status === 0;
3643-
}
3644-
3645-
function repairGatewayBootstrapSecrets(): { repaired: boolean; missingSecrets: string[] } {
3646-
const missingSecrets = listMissingGatewayBootstrapSecrets();
3647-
const plan = getGatewayBootstrapRepairPlan(missingSecrets);
3648-
if (!plan.needsRepair) return { repaired: false, missingSecrets };
3649-
3650-
console.log(
3651-
` OpenShell bootstrap secrets missing: ${plan.missingSecrets.join(", ")}. Repairing...`,
3652-
);
3653-
const repairResult = runGatewayCluster(buildGatewayBootstrapSecretsScript(plan.missingSecrets), {
3654-
ignoreError: true,
3655-
suppressOutput: true,
3656-
});
3657-
const remainingSecrets = listMissingGatewayBootstrapSecrets();
3658-
if (repairResult.status === 0 && remainingSecrets.length === 0) {
3659-
console.log(" ✓ OpenShell bootstrap secrets created");
3660-
return { repaired: true, missingSecrets: remainingSecrets };
3661-
}
3662-
return { repaired: false, missingSecrets: remainingSecrets };
3663-
}
36643566

36653567
function attachGatewayMetadataIfNeeded({
36663568
forceRefresh = false,
@@ -9770,172 +9672,75 @@ function fetchGatewayAuthTokenFromSandbox(sandboxName: string): string | null {
97709672

97719673
function buildDashboardChain(
97729674
chatUiUrl = process.env.CHAT_UI_URL || `http://127.0.0.1:${CONTROL_UI_PORT}`,
9773-
options: {
9774-
wslHostAddress?: string | null;
9775-
runCapture?: typeof runCapture;
9776-
env?: NodeJS.ProcessEnv;
9777-
platform?: NodeJS.Platform;
9778-
release?: string;
9779-
isWsl?: boolean;
9780-
} = {},
9675+
options: Parameters<typeof dashboardAccess.buildDashboardChain>[1] = {},
97819676
) {
9782-
return buildChain({
9783-
chatUiUrl,
9784-
isWsl: isWsl(options),
9785-
wslHostAddress: getWslHostAddress(options),
9786-
});
9677+
return dashboardAccess.buildDashboardChain(chatUiUrl, { ...options, runCapture: options.runCapture || runCapture });
97879678
}
97889679

97899680
function getDashboardForwardPort(
97909681
chatUiUrl = process.env.CHAT_UI_URL || `http://127.0.0.1:${CONTROL_UI_PORT}`,
9791-
options: {
9792-
wslHostAddress?: string | null;
9793-
runCapture?: typeof runCapture;
9794-
env?: NodeJS.ProcessEnv;
9795-
platform?: NodeJS.Platform;
9796-
release?: string;
9797-
isWsl?: boolean;
9798-
} = {},
9682+
options: Parameters<typeof dashboardAccess.getDashboardForwardPort>[1] = {},
97999683
): string {
9800-
return String(buildDashboardChain(chatUiUrl, options).port);
9684+
return dashboardAccess.getDashboardForwardPort(chatUiUrl, {
9685+
...options,
9686+
runCapture: options.runCapture || runCapture,
9687+
});
98019688
}
98029689

98039690
function getDashboardForwardTarget(
98049691
chatUiUrl = process.env.CHAT_UI_URL || `http://127.0.0.1:${CONTROL_UI_PORT}`,
9805-
options: {
9806-
wslHostAddress?: string | null;
9807-
runCapture?: typeof runCapture;
9808-
env?: NodeJS.ProcessEnv;
9809-
platform?: NodeJS.Platform;
9810-
release?: string;
9811-
isWsl?: boolean;
9812-
chatUiUrl?: string;
9813-
token?: string | null;
9814-
} = {},
9692+
options: Parameters<typeof dashboardAccess.getDashboardForwardTarget>[1] = {},
98159693
): string {
9816-
return buildDashboardChain(chatUiUrl, options).forwardTarget;
9694+
return dashboardAccess.getDashboardForwardTarget(chatUiUrl, {
9695+
...options,
9696+
runCapture: options.runCapture || runCapture,
9697+
});
98179698
}
98189699

98199700
function getDashboardForwardStartCommand(
98209701
sandboxName: string,
9821-
options: {
9822-
chatUiUrl?: string;
9823-
openshellBinary?: string;
9824-
wslHostAddress?: string | null;
9825-
runCapture?: typeof runCapture;
9826-
env?: NodeJS.ProcessEnv;
9827-
platform?: NodeJS.Platform;
9828-
release?: string;
9829-
isWsl?: boolean;
9830-
token?: string | null;
9831-
} = {},
9702+
options: Parameters<typeof dashboardAccess.getDashboardForwardStartCommand>[1] = {},
98329703
): string {
9833-
const chatUiUrl =
9834-
options.chatUiUrl || process.env.CHAT_UI_URL || `http://127.0.0.1:${CONTROL_UI_PORT}`;
9835-
const forwardTarget = getDashboardForwardTarget(chatUiUrl, options);
9836-
return `${openshellShellCommand(
9837-
["forward", "start", "--background", forwardTarget, sandboxName],
9838-
options,
9839-
)}`;
9704+
return dashboardAccess.getDashboardForwardStartCommand(sandboxName, {
9705+
...options,
9706+
runCapture: options.runCapture || runCapture,
9707+
openshellShellCommand,
9708+
});
98409709
}
98419710

98429711
function buildAuthenticatedDashboardUrl(baseUrl: string, token: string | null = null): string {
9843-
if (!token) return baseUrl;
9844-
return `${baseUrl}#token=${encodeURIComponent(token)}`;
9712+
return dashboardAccess.buildAuthenticatedDashboardUrl(baseUrl, token);
98459713
}
98469714

98479715
function dashboardUrlForDisplay(url: string): string {
9848-
return redact(url.replace(/#token=[^\s'"]*$/i, ""));
9716+
return dashboardAccess.dashboardUrlForDisplay(url, redact);
98499717
}
98509718

98519719
function getWslHostAddress(
9852-
options: {
9853-
wslHostAddress?: string | null;
9854-
runCapture?: typeof runCapture;
9855-
env?: NodeJS.ProcessEnv;
9856-
platform?: NodeJS.Platform;
9857-
release?: string;
9858-
isWsl?: boolean;
9859-
} = {},
9720+
options: Parameters<typeof dashboardAccess.getWslHostAddress>[0] = {},
98609721
): string | null {
9861-
if (options.wslHostAddress) {
9862-
return options.wslHostAddress;
9863-
}
9864-
if (!isWsl(options)) {
9865-
return null;
9866-
}
9867-
const runCaptureFn = options.runCapture || runCapture;
9868-
const output = runCaptureFn(["hostname", "-I"], { ignoreError: true });
9869-
return (
9870-
String(output || "")
9871-
.trim()
9872-
.split(/\s+/)
9873-
.filter(Boolean)[0] || null
9874-
);
9722+
return dashboardAccess.getWslHostAddress({ ...options, runCapture: options.runCapture || runCapture });
98759723
}
98769724

98779725
function getDashboardAccessInfo(
98789726
sandboxName: string,
9879-
options: {
9880-
token?: string | null;
9881-
chatUiUrl?: string;
9882-
wslHostAddress?: string | null;
9883-
runCapture?: typeof runCapture;
9884-
env?: NodeJS.ProcessEnv;
9885-
platform?: NodeJS.Platform;
9886-
release?: string;
9887-
isWsl?: boolean;
9888-
} = {},
9727+
options: Parameters<typeof dashboardAccess.getDashboardAccessInfo>[1] = {},
98899728
) {
9890-
const token = Object.prototype.hasOwnProperty.call(options, "token")
9891-
? options.token
9892-
: fetchGatewayAuthTokenFromSandbox(sandboxName);
9893-
const chatUiUrl =
9894-
options.chatUiUrl || process.env.CHAT_UI_URL || `http://127.0.0.1:${CONTROL_UI_PORT}`;
9895-
const chain = buildDashboardChain(chatUiUrl, options);
9896-
const dashboardAccess = buildControlUiUrls(token, chain.port, chain.accessUrl).map(
9897-
(url, index) => ({
9898-
label: index === 0 ? "Dashboard" : `Alt ${index}`,
9899-
url: buildAuthenticatedDashboardUrl(url, null),
9900-
}),
9901-
);
9902-
9903-
const wslHostAddress = getWslHostAddress(options);
9904-
if (wslHostAddress) {
9905-
const wslUrl = buildAuthenticatedDashboardUrl(`http://${wslHostAddress}:${chain.port}/`, token);
9906-
if (!dashboardAccess.some((access) => access.url === wslUrl)) {
9907-
dashboardAccess.push({ label: "VS Code/WSL", url: wslUrl });
9908-
}
9909-
}
9910-
9911-
return dashboardAccess;
9729+
return dashboardAccess.getDashboardAccessInfo(sandboxName, {
9730+
...options,
9731+
runCapture: options.runCapture || runCapture,
9732+
fetchGatewayAuthToken: fetchGatewayAuthTokenFromSandbox,
9733+
});
99129734
}
99139735

99149736
function getDashboardGuidanceLines(
9915-
dashboardAccess: Array<{ label: string; url: string }> = [],
9916-
options: {
9917-
chatUiUrl?: string;
9918-
wslHostAddress?: string | null;
9919-
runCapture?: typeof runCapture;
9920-
env?: NodeJS.ProcessEnv;
9921-
platform?: NodeJS.Platform;
9922-
release?: string;
9923-
isWsl?: boolean;
9924-
} = {},
9737+
access: Parameters<typeof dashboardAccess.getDashboardGuidanceLines>[0] = [],
9738+
options: Parameters<typeof dashboardAccess.getDashboardGuidanceLines>[1] = {},
99259739
): string[] {
9926-
const chatUiUrl =
9927-
options.chatUiUrl || process.env.CHAT_UI_URL || `http://127.0.0.1:${CONTROL_UI_PORT}`;
9928-
const chain = buildDashboardChain(chatUiUrl, options);
9929-
const guidance = [`Port ${String(chain.port)} must be forwarded before opening these URLs.`];
9930-
if (isWsl(options)) {
9931-
guidance.push(
9932-
"WSL detected: if localhost fails in Windows, use the WSL host IP shown by `hostname -I`.",
9933-
);
9934-
}
9935-
if (dashboardAccess.length === 0) {
9936-
guidance.push("No dashboard URLs were generated.");
9937-
}
9938-
return guidance;
9740+
return dashboardAccess.getDashboardGuidanceLines(access, {
9741+
...options,
9742+
runCapture: options.runCapture || runCapture,
9743+
});
99399744
}
99409745
/** Print the post-onboard dashboard with sandbox status and reconfiguration hints. */
99419746
function printDashboard(

0 commit comments

Comments
 (0)