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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions src/lib/deploy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
isBrevInstanceFailed,
isBrevInstanceReady,
} from "../../dist/lib/deploy";
import { validateName } from "../../dist/lib/runner";

describe("inferDeployProvider", () => {
it("prefers an explicit provider override", () => {
Expand Down Expand Up @@ -223,6 +224,32 @@ describe("executeDeploy", () => {
expect(errorText).toContain("failed-id");
expect(fixture.calls.some((call) => call.file === "ssh-keyscan")).toBe(false);
});

it("rejects invalid NEMOCLAW_SANDBOX_NAME before Brev provisioning", async () => {
const fixture = makeDeployOptions({
env: {
NEMOCLAW_SANDBOX_NAME: "bad name",
NEMOCLAW_PROVIDER: "build",
NEMOCLAW_DEPLOY_NO_START_SERVICES: "1",
},
validateName,
});

await expect(executeDeploy(fixture.options)).rejects.toThrow("exit:1");

const errorText = fixture.errors.join("\n");
expect(errorText).toContain("Invalid sandbox name: 'bad name'");
expect(errorText).toContain("Sandbox names cannot contain spaces.");
expect(errorText).toContain(
"Allowed format: lowercase, starts with a letter, letters/numbers/internal hyphens only, ends with letter/number.",
);
expect(errorText).toContain(
"Brev deploy is non-interactive and cannot prompt for a corrected sandbox name.",
);
expect(errorText).toContain("Set NEMOCLAW_SANDBOX_NAME to a valid sandbox name and retry.");
expect(fixture.calls).toEqual([]);
expect(fixture.interactive).toEqual([]);
});
});

describe("Brev status helpers", () => {
Expand Down
33 changes: 32 additions & 1 deletion src/lib/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";

import { NAME_ALLOWED_FORMAT, getNameValidationGuidance } from "./name-validation";
import { sleepSeconds } from "./wait";

type ExecLikeValue =
Expand Down Expand Up @@ -226,6 +227,29 @@ function fail(
return exit(1);
}

function validateDeploySandboxName(
rawSandboxName: string,
opts: Pick<DeployExecutionOptions, "validateName" | "error" | "exit">,
): string {
try {
return opts.validateName(rawSandboxName, "sandbox name");
} catch (caught) {
const message = caught instanceof Error ? caught.message : String(caught);
return fail(
[
` ${message}`,
...getNameValidationGuidance("sandbox name", rawSandboxName, {
includeAllowedFormat: false,
}).map((line) => ` ${line}`),
" Brev deploy is non-interactive and cannot prompt for a corrected sandbox name.",
" Set NEMOCLAW_SANDBOX_NAME to a valid sandbox name and retry.",
],
opts.error,
opts.exit,
);
}
}

export async function executeDeploy(opts: DeployExecutionOptions): Promise<void> {
const {
instanceName,
Expand Down Expand Up @@ -259,6 +283,9 @@ export async function executeDeploy(opts: DeployExecutionOptions): Promise<void>
" nemoclaw deploy my-gpu-box",
" nemoclaw deploy nemoclaw-prod",
" nemoclaw deploy nemoclaw-test",
"",
" Sandbox name comes from NEMOCLAW_SANDBOX_NAME (default: my-assistant).",
` Allowed sandbox name format: ${NAME_ALLOWED_FORMAT}.`,
],
error,
exit,
Expand All @@ -276,7 +303,11 @@ export async function executeDeploy(opts: DeployExecutionOptions): Promise<void>
const skipStartServices = ["1", "true"].includes(
String(env.NEMOCLAW_DEPLOY_NO_START_SERVICES || "").toLowerCase(),
);
const sandboxName = validateName(env.NEMOCLAW_SANDBOX_NAME || "my-assistant", "sandbox name");
const sandboxName = validateDeploySandboxName(env.NEMOCLAW_SANDBOX_NAME || "my-assistant", {
validateName,
error,
exit,
});
const credentials: DeployCredentials = {
NVIDIA_API_KEY: getCredential("NVIDIA_API_KEY"),
OPENAI_API_KEY: getCredential("OPENAI_API_KEY"),
Expand Down
28 changes: 28 additions & 0 deletions src/lib/name-validation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

export const NAME_ALLOWED_FORMAT =
"lowercase, starts with a letter, letters/numbers/internal hyphens only, ends with letter/number";

function validationSubject(label: string): string {
const normalized = label.trim().toLowerCase();
if (normalized === "sandbox name") return "Sandbox names";
if (normalized === "instance name") return "Instance names";
if (normalized === "target sandbox name") return "Target sandbox names";
return "Names";
}

export function getNameValidationGuidance(
label: string,
value: string,
opts: { includeAllowedFormat?: boolean } = {},
): string[] {
const lines: string[] = [];
if (/\s/.test(value)) {
lines.push(`${validationSubject(label)} cannot contain spaces.`);
}
if (opts.includeAllowedFormat !== false) {
lines.push(`Allowed format: ${NAME_ALLOWED_FORMAT}.`);
}
return lines;
}
18 changes: 12 additions & 6 deletions src/lib/onboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ const ANSI_RE = /\x1B(?:\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1B\\)|[@-_])/g;
const runner: typeof import("./runner") = require("./runner");
const { ROOT, SCRIPTS, redact, run, runShell, runCapture, runFile, shellQuote, validateName } =
runner;
const nameValidation: typeof import("./name-validation") = require("./name-validation");
const { NAME_ALLOWED_FORMAT, getNameValidationGuidance } = nameValidation;
const docker: typeof import("./docker") = require("./docker");
const {
dockerContainerInspectFormat,
Expand Down Expand Up @@ -3846,7 +3848,7 @@ async function promptValidatedSandboxName(agent: AgentDefinition | null = null)
const defaultSandboxName = getSandboxPromptDefault(agent);
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
const nameAnswer = await promptOrDefault(
` Sandbox name (lowercase, starts with letter, hyphens ok) [${defaultSandboxName}]: `,
` Sandbox name (${NAME_ALLOWED_FORMAT}) [${defaultSandboxName}]: `,
"NEMOCLAW_SANDBOX_NAME",
defaultSandboxName,
);
Expand All @@ -3871,11 +3873,10 @@ async function promptValidatedSandboxName(agent: AgentDefinition | null = null)
console.error(` ${errorMessage}`);
}

if (/^[0-9]/.test(sandboxName)) {
console.error(" Names must start with a letter, not a digit.");
} else {
console.error(" Names must be lowercase, contain only letters, numbers, and hyphens,");
console.error(" must start with a letter, and end with a letter or number.");
for (const line of getNameValidationGuidance("sandbox name", sandboxName, {
includeAllowedFormat: false,
})) {
console.error(` ${line}`);
}

// Non-interactive runs cannot re-prompt — abort so the caller can fix the
Expand Down Expand Up @@ -8362,6 +8363,11 @@ async function onboard(opts: OnboardOptions = {}): Promise<void> {
requestedSandboxName = validated;
} catch (error) {
console.error(` ${error instanceof Error ? error.message : String(error)}`);
for (const line of getNameValidationGuidance("sandbox name", requestedSandboxName, {
includeAllowedFormat: false,
})) {
console.error(` ${line}`);
}
process.exit(1);
}
}
Expand Down
9 changes: 6 additions & 3 deletions src/lib/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type {
SpawnSyncOptionsWithStringEncoding,
SpawnSyncReturns,
} from "node:child_process";
import { NAME_ALLOWED_FORMAT } from "./name-validation";

const { spawnSync } = require("child_process");
const path = require("path");
Expand Down Expand Up @@ -261,14 +262,16 @@ function shellQuote(value: RunnerScalar): string {
*/
function validateName(name: string, label = "name"): string {
if (!name || typeof name !== "string") {
throw new Error(`${label} is required`);
throw new Error(`${label} is required. Allowed format: ${NAME_ALLOWED_FORMAT}.`);
}
if (name.length > 63) {
throw new Error(`${label} too long (max 63 chars): '${name.slice(0, 20)}...'`);
throw new Error(
`${label} too long (max 63 chars): '${name.slice(0, 20)}...'. Allowed format: ${NAME_ALLOWED_FORMAT}.`,
);
}
if (!/^[a-z]([a-z0-9-]*[a-z0-9])?$/.test(name)) {
throw new Error(
`Invalid ${label}: '${name}'. Must start with a letter and contain only lowercase alphanumerics with optional internal hyphens.`,
`Invalid ${label}: '${name}'. Allowed format: ${NAME_ALLOWED_FORMAT}.`,
);
}
return name;
Expand Down
33 changes: 32 additions & 1 deletion test/e2e/brev-e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@
*/

import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { execSync, execFileSync, type StdioOptions } from "node:child_process";
import { execSync, execFileSync, spawnSync, type StdioOptions } from "node:child_process";
import path from "node:path";

// Instance configuration
Expand Down Expand Up @@ -667,6 +667,37 @@ const hasAuthenticatedBrev = (() => {
}
})();

describe("Brev deploy input validation", () => {
it("rejects invalid sandbox names before provisioning or remote work", () => {
const result = spawnSync(process.execPath, [CLI_PATH, "deploy", "brev-target"], {
cwd: REPO_DIR,
encoding: "utf-8",
env: {
...process.env,
HOME: process.env.HOME,
NEMOCLAW_SANDBOX_NAME: "bad name",
NEMOCLAW_PROVIDER: "build",
NEMOCLAW_DEPLOY_NO_CONNECT: "1",
NEMOCLAW_DEPLOY_NO_START_SERVICES: "1",
},
timeout: 30_000,
});

const output = `${result.stdout}${result.stderr}`;
expect(result.status).toBe(1);
expect(output).toContain("Invalid sandbox name: 'bad name'");
expect(output).toContain("Sandbox names cannot contain spaces.");
expect(output).toContain(
"Allowed format: lowercase, starts with a letter, letters/numbers/internal hyphens only, ends with letter/number.",
);
expect(output).not.toContain("brev CLI not found");
expect(output).not.toContain("Creating Brev instance");
expect(output).not.toContain("Waiting for Brev instance readiness");
expect(output).not.toContain("Waiting for SSH");
expect(output).not.toContain("bash scripts/install.sh");
});
});

describe.runIf(hasRequiredVars && hasAuthenticatedBrev)("Brev E2E", () => {
beforeAll(() => {
const bootstrapStart = Date.now();
Expand Down
13 changes: 13 additions & 0 deletions test/onboard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { describe, expect, it } from "vitest";
import type { AgentDefinition } from "../dist/lib/agent-defs.js";
import { loadAgent } from "../dist/lib/agent-defs.js";
import { buildChain, buildControlUiUrls } from "../dist/lib/dashboard-contract.js";
import { NAME_ALLOWED_FORMAT } from "../dist/lib/name-validation.js";
import { stageOptimizedSandboxBuildContext } from "../dist/lib/sandbox-build-context.js";

type ShimScalar = string | number | boolean | null | undefined;
Expand Down Expand Up @@ -6676,6 +6677,18 @@ const { createSandbox } = require(${onboardPath});
// Non-interactive still exits within this function
assert.match(fnBody, /isNonInteractive\(\)/);
assert.match(fnBody, /process\.exit\(1\)/);
assert.match(fnBody, /getNameValidationGuidance\("sandbox name", sandboxName,/);
});

it("shows the full allowed sandbox name format before prompting", () => {
const source = fs.readFileSync(
path.join(import.meta.dirname, "..", "src", "lib", "onboard.ts"),
"utf-8",
);
expect(NAME_ALLOWED_FORMAT).toBe(
"lowercase, starts with a letter, letters/numbers/internal hyphens only, ends with letter/number",
);
assert.match(source, /Sandbox name \(\$\{NAME_ALLOWED_FORMAT\}\)/);
});

it("guards against reusing the same sandbox name for a different agent", () => {
Expand Down
Loading