diff --git a/.agents/skills/nemoclaw-user-reference/references/commands.md b/.agents/skills/nemoclaw-user-reference/references/commands.md index 8599506743e..388edb134c7 100644 --- a/.agents/skills/nemoclaw-user-reference/references/commands.md +++ b/.agents/skills/nemoclaw-user-reference/references/commands.md @@ -535,7 +535,7 @@ $ nemoclaw my-assistant snapshot restore 2026-04-21T07-35-55-987Z $ nemoclaw my-assistant snapshot restore v3 --to my-assistant-clone ``` -### `openshell term` +## `openshell term` Open the OpenShell TUI to monitor sandbox activity and approve network egress requests. Run this on the host where the sandbox is running. diff --git a/docs/reference/commands.md b/docs/reference/commands.md index 9b648ecc7fc..17157e26bd4 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -563,7 +563,7 @@ $ nemoclaw my-assistant snapshot restore 2026-04-21T07-35-55-987Z $ nemoclaw my-assistant snapshot restore v3 --to my-assistant-clone ``` -### `openshell term` +## `openshell term` Open the OpenShell TUI to monitor sandbox activity and approve network egress requests. Run this on the host where the sandbox is running. diff --git a/nemoclaw-blueprint/scripts/ws-proxy-fix.js b/nemoclaw-blueprint/scripts/ws-proxy-fix.js index 80865ab1aa3..247d3012774 100644 --- a/nemoclaw-blueprint/scripts/ws-proxy-fix.js +++ b/nemoclaw-blueprint/scripts/ws-proxy-fix.js @@ -46,7 +46,8 @@ const _PATCHED = Symbol.for("nemoclaw.wsProxyFix"); const proxyUrl = process.env.HTTPS_PROXY || process.env.https_proxy; if (!proxyUrl) return; - if (globalThis[_PATCHED]) + const patchedFlag = Reflect.get(globalThis, _PATCHED) === true; + if (patchedFlag) return; let proxy; try { @@ -68,7 +69,7 @@ const _PATCHED = Symbol.for("nemoclaw.wsProxyFix"); // Override createConnection to route through the proxy's CONNECT tunnel. // The typing is intentionally loosened because the actual Node.js runtime // signature is broader than what @types/node declares. - agent.createConnection = function (options, callback) { + Reflect.set(agent, "createConnection", function (options, callback) { const connectReq = node_http_1.default.request({ host: proxyHost, port: proxyPort, @@ -88,7 +89,7 @@ const _PATCHED = Symbol.for("nemoclaw.wsProxyFix"); } const tlsSocket = node_tls_1.default.connect({ socket, - servername: options.servername || targetHost, + servername: typeof options.servername === "string" ? options.servername : targetHost, }); callback(null, tlsSocket); }); @@ -100,7 +101,7 @@ const _PATCHED = Symbol.for("nemoclaw.wsProxyFix"); // createConnection expects a synchronous return; the real socket arrives // via the callback. Return a placeholder that Node.js will discard. return new node_net_1.default.Socket(); - }; + }); return agent; } // ---------- Target check ------------------------------------------------- @@ -123,9 +124,23 @@ const _PATCHED = Symbol.for("nemoclaw.wsProxyFix"); return false; } // ---------- Patch https.request() --------------------------------------- - // Capture the original — typed as a loose callable so we can invoke it - // with the normalised (options, cb) form without fighting overload resolution. - const origRequest = node_https_1.default.request; + // Capture the original so we can call it after normalising arguments. + const requestRef = node_https_1.default.request; + function callOriginalRequest(input, options, callback) { + if (typeof input === "string" || input instanceof node_url_1.URL) { + if (typeof options === "function") { + return requestRef(input, options); + } + if (options) { + return callback ? requestRef(input, options, callback) : requestRef(input, options); + } + return callback ? requestRef(input, {}, callback) : requestRef(input); + } + if (typeof options === "function") { + return requestRef(input, options); + } + return requestRef(input, callback); + } function wsProxyFixedRequest(input, options, callback) { // --- Normalise arguments (Node.js accepts multiple call signatures) --- let opts; @@ -136,7 +151,7 @@ const _PATCHED = Symbol.for("nemoclaw.wsProxyFix"); opts = {}; } else { - opts = options || {}; + opts = options ?? {}; cb = callback; } const url = typeof input === "string" ? new node_url_1.URL(input) : input; @@ -159,19 +174,21 @@ const _PATCHED = Symbol.for("nemoclaw.wsProxyFix"); host = opts.host.replace(/:\d+$/, ""); } if (isDiscordWsUpgrade(host, opts.headers)) { + if (!host) { + return callOriginalRequest(input, options, callback); + } // Discord WebSocket upgrade — inject CONNECT tunnel agent unless the // caller already provides a custom (non-default) agent. if (!opts.agent || opts.agent === node_https_1.default.globalAgent) { const port = parseInt(String(opts.port), 10) || 443; opts = { ...opts, agent: createTunnelAgent(host, port) }; } - return origRequest.call(node_https_1.default, opts, cb); + return cb ? requestRef(opts, cb) : requestRef(opts); } - // Non-WebSocket — pass through original arguments unchanged. - // eslint-disable-next-line prefer-rest-params - return origRequest.apply(node_https_1.default, arguments); + // Non-WebSocket — pass through the original arguments unchanged. + return callOriginalRequest(input, options, callback); } // Replace https.request with our patched version. - node_https_1.default.request = wsProxyFixedRequest; - globalThis[_PATCHED] = true; + Reflect.set(node_https_1.default, "request", wsProxyFixedRequest); + Reflect.set(globalThis, _PATCHED, true); })(); diff --git a/nemoclaw-blueprint/scripts/ws-proxy-fix.ts b/nemoclaw-blueprint/scripts/ws-proxy-fix.ts index a82f3cdc952..730e52cf862 100644 --- a/nemoclaw-blueprint/scripts/ws-proxy-fix.ts +++ b/nemoclaw-blueprint/scripts/ws-proxy-fix.ts @@ -37,6 +37,7 @@ import { URL } from "node:url"; const _PATCHED = Symbol.for("nemoclaw.wsProxyFix"); type RequestCallback = (res: http.IncomingMessage) => void; +type TunnelConnectionOptions = { servername?: string }; /** * Merged options after normalising the multiple call signatures of @@ -54,7 +55,8 @@ interface ReqOpts extends https.RequestOptions { const proxyUrl = process.env.HTTPS_PROXY || process.env.https_proxy; if (!proxyUrl) return; - if ((globalThis as Record)[_PATCHED]) return; + const patchedFlag = Reflect.get(globalThis, _PATCHED) === true; + if (patchedFlag) return; let proxy: URL; try { @@ -82,10 +84,13 @@ interface ReqOpts extends https.RequestOptions { // Override createConnection to route through the proxy's CONNECT tunnel. // The typing is intentionally loosened because the actual Node.js runtime // signature is broader than what @types/node declares. - (agent as unknown as Record).createConnection = function ( - options: Record, - callback: (err: Error | null, socket?: tls.TLSSocket) => void, - ): net.Socket { + Reflect.set( + agent, + "createConnection", + function ( + options: TunnelConnectionOptions, + callback: (err: Error | null, socket?: tls.TLSSocket) => void, + ): net.Socket { const connectReq = http.request({ host: proxyHost, port: proxyPort, @@ -112,7 +117,7 @@ interface ReqOpts extends https.RequestOptions { } const tlsSocket = tls.connect({ socket, - servername: (options.servername as string) || targetHost, + servername: typeof options.servername === "string" ? options.servername : targetHost, }); callback(null, tlsSocket); }, @@ -127,7 +132,8 @@ interface ReqOpts extends https.RequestOptions { // createConnection expects a synchronous return; the real socket arrives // via the callback. Return a placeholder that Node.js will discard. return new net.Socket(); - }; + }, + ); return agent; } @@ -158,12 +164,28 @@ interface ReqOpts extends https.RequestOptions { // ---------- Patch https.request() --------------------------------------- - // Capture the original — typed as a loose callable so we can invoke it - // with the normalised (options, cb) form without fighting overload resolution. - const origRequest = https.request as ( - options: ReqOpts, + // Capture the original so we can call it after normalising arguments. + const requestRef = https.request; + + function callOriginalRequest( + input: string | URL | ReqOpts, + options?: RequestCallback | ReqOpts, callback?: RequestCallback, - ) => http.ClientRequest; + ): http.ClientRequest { + if (typeof input === "string" || input instanceof URL) { + if (typeof options === "function") { + return requestRef(input, options); + } + if (options) { + return callback ? requestRef(input, options, callback) : requestRef(input, options); + } + return callback ? requestRef(input, {}, callback) : requestRef(input); + } + if (typeof options === "function") { + return requestRef(input, options); + } + return requestRef(input, callback); + } function wsProxyFixedRequest( input: string | URL | ReqOpts, @@ -179,7 +201,7 @@ interface ReqOpts extends https.RequestOptions { cb = options; opts = {}; } else { - opts = (options as ReqOpts) || {}; + opts = options ?? {}; cb = callback; } const url = typeof input === "string" ? new URL(input) : input; @@ -202,24 +224,26 @@ interface ReqOpts extends https.RequestOptions { host = opts.host.replace(/:\d+$/, ""); } if (isDiscordWsUpgrade(host, opts.headers)) { + if (!host) { + return callOriginalRequest(input, options, callback); + } // Discord WebSocket upgrade — inject CONNECT tunnel agent unless the // caller already provides a custom (non-default) agent. if (!opts.agent || opts.agent === https.globalAgent) { const port = parseInt(String(opts.port), 10) || 443; - opts = { ...opts, agent: createTunnelAgent(host!, port) }; + opts = { ...opts, agent: createTunnelAgent(host, port) }; } - return origRequest.call(https, opts, cb); + return cb ? requestRef(opts, cb) : requestRef(opts); } - // Non-WebSocket — pass through original arguments unchanged. - // eslint-disable-next-line prefer-rest-params - return (origRequest as unknown as Function).apply(https, arguments); + // Non-WebSocket — pass through the original arguments unchanged. + return callOriginalRequest(input, options, callback); } // Replace https.request with our patched version. - (https as unknown as Record).request = wsProxyFixedRequest; + Reflect.set(https, "request", wsProxyFixedRequest); - (globalThis as Record)[_PATCHED] = true; + Reflect.set(globalThis, _PATCHED, true); })(); export {}; diff --git a/nemoclaw/src/blueprint/runner.test.ts b/nemoclaw/src/blueprint/runner.test.ts index 80753470334..02c8e811f9b 100644 --- a/nemoclaw/src/blueprint/runner.test.ts +++ b/nemoclaw/src/blueprint/runner.test.ts @@ -165,11 +165,132 @@ describe("runner", () => { expect(loadBlueprint()).toEqual({ version: "2.0" }); }); + it("parses nested policy additions with object and array values", () => { + addFile( + "blueprint.yaml", + YAML.stringify({ + version: "2.0", + components: { + policy: { + additions: { + extra: { + enabled: true, + paths: ["/tmp", "/var/tmp"], + }, + }, + }, + }, + }), + ); + expect(loadBlueprint()).toEqual({ + version: "2.0", + components: { + policy: { + additions: { + extra: { + enabled: true, + paths: ["/tmp", "/var/tmp"], + }, + }, + }, + }, + }); + }); + it("respects NEMOCLAW_BLUEPRINT_PATH env var", () => { process.env.NEMOCLAW_BLUEPRINT_PATH = "/custom/path"; addFile("/custom/path/blueprint.yaml", YAML.stringify({ version: "3.0" })); expect(loadBlueprint()).toEqual({ version: "3.0" }); }); + + it("rejects a YAML sequence at the root", () => { + addFile("blueprint.yaml", YAML.stringify(["not", "a", "mapping"])); + expect(() => loadBlueprint()).toThrow(/valid nested component shapes/); + }); + + it("rejects a non-string version", () => { + addFile("blueprint.yaml", YAML.stringify({ version: 2 })); + expect(() => loadBlueprint()).toThrow(/valid nested component shapes/); + }); + + it("rejects a non-object components block", () => { + addFile("blueprint.yaml", YAML.stringify({ components: [] })); + expect(() => loadBlueprint()).toThrow(/valid nested component shapes/); + }); + + it("rejects a non-object inference block", () => { + addFile( + "blueprint.yaml", + YAML.stringify({ + components: { + inference: [], + }, + }), + ); + expect(() => loadBlueprint()).toThrow(/valid nested component shapes/); + }); + + it("rejects nested component shapes that do not match the blueprint schema", () => { + addFile( + "blueprint.yaml", + YAML.stringify({ + version: "2.0", + components: { + inference: { profiles: 1 }, + }, + }), + ); + expect(() => loadBlueprint()).toThrow(/valid nested component shapes/); + }); + + it("rejects invalid inference profile field types", () => { + addFile( + "blueprint.yaml", + YAML.stringify({ + version: "2.0", + components: { + inference: { + profiles: { + default: { + timeout_secs: Number.POSITIVE_INFINITY, + }, + }, + }, + }, + }), + ); + expect(() => loadBlueprint()).toThrow(/valid nested component shapes/); + }); + + it("rejects invalid sandbox forward ports", () => { + addFile( + "blueprint.yaml", + YAML.stringify({ + version: "2.0", + components: { + sandbox: { + forward_ports: [70000], + }, + }, + }), + ); + expect(() => loadBlueprint()).toThrow(/valid nested component shapes/); + }); + + it("rejects non-plain policy additions values", () => { + addFile( + "blueprint.yaml", + [ + 'version: "2.0"', + "components:", + " policy:", + " additions:", + " extra: !!set", + " ? /tmp", + ].join("\n"), + ); + expect(() => loadBlueprint()).toThrow(/valid nested component shapes/); + }); }); describe("actionPlan", () => { @@ -705,16 +826,17 @@ describe("runner", () => { beforeEach(() => { captureStdout(); mockExeca.mockResolvedValue({ exitCode: 0, stdout: "", stderr: "" }); - // main() calls loadBlueprint() before dispatching the action seedBlueprintFile(); }); - it("throws on unknown action", async () => { - await expect(main(["bogus"])).rejects.toThrow(/Unknown action/); + it("throws on unknown action with the raw invalid token", async () => { + store.clear(); + await expect(main(["bogus"])).rejects.toThrow(/Unknown action 'bogus'/); }); - it("throws on missing action", async () => { - await expect(main([])).rejects.toThrow(/Unknown action/); + it("throws on missing action with a clear marker", async () => { + store.clear(); + await expect(main([])).rejects.toThrow(/Unknown action '\(missing\)'/); }); it("parses plan with --profile and --dry-run", async () => { diff --git a/nemoclaw/src/blueprint/runner.ts b/nemoclaw/src/blueprint/runner.ts index ad6b96c06fb..bd0a58d07d8 100644 --- a/nemoclaw/src/blueprint/runner.ts +++ b/nemoclaw/src/blueprint/runner.ts @@ -26,6 +26,141 @@ import { DASHBOARD_PORT } from "../lib/ports.js"; type Action = "plan" | "apply" | "status" | "rollback"; +type BlueprintDataScalar = string | number | boolean | null; +type BlueprintDataValue = BlueprintDataScalar | PolicyAdditions | BlueprintDataValue[]; +type RollbackPlanSource = { sandbox_name?: string }; +type UnknownRecord = { [key: string]: unknown }; + +function isAction(value: string | undefined): value is Action { + return value === "plan" || value === "apply" || value === "status" || value === "rollback"; +} + +function isObjectLike(value: unknown): value is UnknownRecord { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return false; + } + return Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null; +} + +function isOptionalString(value: unknown): value is string | undefined { + return value === undefined || typeof value === "string"; +} + +function isOptionalFiniteNumber(value: unknown): value is number | undefined { + return value === undefined || (typeof value === "number" && Number.isFinite(value)); +} + +function isValidPort(value: unknown): value is number { + return typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= 65535; +} + +function isOptionalPortList(value: unknown): value is number[] | undefined { + return ( + value === undefined || (Array.isArray(value) && value.every((entry) => isValidPort(entry))) + ); +} + +function isBlueprintDataValue(value: unknown): value is BlueprintDataValue { + if ( + value === null || + typeof value === "string" || + typeof value === "number" || + typeof value === "boolean" + ) { + return true; + } + if (Array.isArray(value)) { + return value.every((entry) => isBlueprintDataValue(entry)); + } + if (!isObjectLike(value)) { + return false; + } + return Object.values(value).every((entry) => isBlueprintDataValue(entry)); +} + +function isInferenceProfile(value: unknown): value is InferenceProfile { + if (!isObjectLike(value)) { + return false; + } + + return ( + isOptionalString(value.provider_type) && + isOptionalString(value.provider_name) && + isOptionalString(value.endpoint) && + isOptionalString(value.model) && + isOptionalString(value.credential_env) && + isOptionalString(value.credential_default) && + isOptionalFiniteNumber(value.timeout_secs) + ); +} + +function isBlueprint(value: unknown): value is Blueprint { + if (!isObjectLike(value)) { + return false; + } + + const version = value.version; + if (!isOptionalString(version)) { + return false; + } + + const components = value.components; + if (components === undefined) { + return true; + } + if (!isObjectLike(components)) { + return false; + } + + const inference = components.inference; + if (inference !== undefined) { + if (!isObjectLike(inference)) { + return false; + } + const profiles = inference.profiles; + if (profiles !== undefined) { + if ( + !isObjectLike(profiles) || + !Object.values(profiles).every((entry) => isInferenceProfile(entry)) + ) { + return false; + } + } + } + + const sandbox = components.sandbox; + if (sandbox !== undefined) { + if (!isObjectLike(sandbox)) { + return false; + } + if ( + !isOptionalString(sandbox.image) || + !isOptionalString(sandbox.name) || + !isOptionalPortList(sandbox.forward_ports) + ) { + return false; + } + } + + const policy = components.policy; + if (policy !== undefined) { + if (!isObjectLike(policy)) { + return false; + } + const additions = policy.additions; + if (additions !== undefined) { + if ( + !isObjectLike(additions) || + !Object.values(additions).every((entry) => isBlueprintDataValue(entry)) + ) { + return false; + } + } + } + + return true; +} + // ── Logging helpers ───────────────────────────────────────────── function log(msg: string): void { @@ -36,6 +171,10 @@ function progress(pct: number, label: string): void { process.stdout.write(`PROGRESS:${String(pct)}:${label}\n`); } +function readRollbackSandboxName(value: RollbackPlanSource | null): string { + return value && typeof value.sandbox_name === "string" ? value.sandbox_name : "openclaw"; +} + // ── Utilities ─────────────────────────────────────────────────── export function emitRunId(): string { @@ -50,15 +189,18 @@ export function emitRunId(): string { return rid; } +type InferenceProfileMap = { [profileName: string]: InferenceProfile }; +type PolicyAdditions = { [name: string]: BlueprintDataValue }; + interface Blueprint { version?: string; components?: { inference?: { - profiles?: Record; + profiles?: InferenceProfileMap; }; sandbox?: SandboxConfig; policy?: { - additions?: Record; + additions?: PolicyAdditions; }; }; } @@ -88,7 +230,13 @@ export function loadBlueprint(): Blueprint { } catch { throw new Error(`blueprint.yaml not found at ${bpFile}`); } - return YAML.parse(content) as Blueprint; + const parsed: unknown = YAML.parse(content); + if (!isBlueprint(parsed)) { + throw new Error( + `blueprint.yaml at ${bpFile} must contain a YAML mapping with valid nested component shapes`, + ); + } + return parsed; } async function runCmd( @@ -121,7 +269,7 @@ async function resolveRunConfig( blueprint: Blueprint, endpointUrl?: string, ): Promise<{ - inferenceProfiles: Record; + inferenceProfiles: InferenceProfileMap; inferenceCfg: InferenceProfile; sandboxCfg: SandboxConfig; }> { @@ -169,7 +317,7 @@ export interface RunPlan { model: string | undefined; credential_env: string | undefined; }; - policy_additions: Record; + policy_additions: PolicyAdditions; dry_run: boolean; } @@ -409,8 +557,12 @@ export async function actionRollback(rid: string): Promise { const planFile = join(stateDir, "plan.json"); try { const planData = readFileSync(planFile, "utf-8"); - const plan = JSON.parse(planData) as { sandbox_name?: string }; - const sandboxName = plan.sandbox_name ?? "openclaw"; + const parsedPlan: unknown = JSON.parse(planData); + const rollbackPlan: RollbackPlanSource | null = + typeof parsedPlan === "object" && parsedPlan !== null && !Array.isArray(parsedPlan) + ? parsedPlan + : null; + const sandboxName = readRollbackSandboxName(rollbackPlan); progress(30, `Stopping sandbox ${sandboxName}`); await runCmd(["openshell", "sandbox", "stop", sandboxName], { reject: false }); @@ -430,7 +582,8 @@ export async function actionRollback(rid: string): Promise { // ── CLI ───────────────────────────────────────────────────────── export async function main(argv: string[] = process.argv.slice(2)): Promise { - const action = argv[0] as Action | undefined; + const rawAction = argv.at(0); + const action = isAction(rawAction) ? rawAction : undefined; let profile = "default"; let planPath: string | undefined; let runId: string | undefined; @@ -442,6 +595,12 @@ export async function main(argv: string[] = process.argv.slice(2)): Promise; +}; + +function isSnapshotManifestJson(value: object | null): value is SnapshotManifestJson { + return value !== null && !Array.isArray(value); +} + +function readStringArray(value: SnapshotManifestJson["contents"]): string[] { + return Array.isArray(value) + ? value.filter((entry): entry is string => typeof entry === "string") + : []; +} + export function listSnapshots(): BlueprintSnapshotManifest[] { let entries: Dirent[]; try { @@ -212,15 +229,14 @@ export function listSnapshots(): BlueprintSnapshotManifest[] { if (!entry.isDirectory()) continue; const snapDir = join(SNAPSHOTS_DIR, entry.name); try { - const raw: unknown = JSON.parse(readFileSync(join(snapDir, "snapshot.json"), "utf-8")); - if (typeof raw !== "object" || raw === null) continue; - const obj = raw as Record; - if (typeof obj.timestamp !== "string") continue; + const parsed: unknown = JSON.parse(readFileSync(join(snapDir, "snapshot.json"), "utf-8")); + const raw = typeof parsed === "object" && parsed !== null ? parsed : null; + if (!isSnapshotManifestJson(raw) || typeof raw.timestamp !== "string") continue; snapshots.push({ - timestamp: obj.timestamp, - source: typeof obj.source === "string" ? obj.source : "", - file_count: typeof obj.file_count === "number" ? obj.file_count : 0, - contents: Array.isArray(obj.contents) ? (obj.contents as string[]) : [], + timestamp: raw.timestamp, + source: typeof raw.source === "string" ? raw.source : "", + file_count: typeof raw.file_count === "number" ? raw.file_count : 0, + contents: readStringArray(raw.contents), path: snapDir, }); } catch { diff --git a/nemoclaw/src/blueprint/state.test.ts b/nemoclaw/src/blueprint/state.test.ts index 209c70eeee9..af5b77b32b7 100644 --- a/nemoclaw/src/blueprint/state.test.ts +++ b/nemoclaw/src/blueprint/state.test.ts @@ -100,6 +100,34 @@ describe("blueprint/state", () => { expect(loaded.shieldsDownPolicy).toBeNull(); expect(loaded.shieldsPolicySnapshotPath).toBeNull(); }); + + it("falls back to blank defaults when the persisted JSON root is not an object", () => { + store.set(STATE_PATH, JSON.stringify(["not", "an", "object"])); + const loaded = loadState(); + expect(loaded.lastRunId).toBeNull(); + expect(loaded.shieldsDown).toBe(false); + }); + + it("ignores malformed persisted field types while preserving valid partial state", () => { + store.set( + STATE_PATH, + JSON.stringify({ + lastRunId: "run-1", + sandboxName: "sb", + updatedAt: {}, + shieldsDown: "false", + shieldsDownTimeout: "300", + shieldsDownReason: ["bad"], + }), + ); + const loaded = loadState(); + expect(loaded.lastRunId).toBe("run-1"); + expect(loaded.sandboxName).toBe("sb"); + expect(typeof loaded.updatedAt).toBe("string"); + expect(loaded.shieldsDown).toBe(false); + expect(loaded.shieldsDownTimeout).toBeNull(); + expect(loaded.shieldsDownReason).toBeNull(); + }); }); describe("saveState", () => { diff --git a/nemoclaw/src/blueprint/state.ts b/nemoclaw/src/blueprint/state.ts index aa927cc2945..b7e0f251094 100644 --- a/nemoclaw/src/blueprint/state.ts +++ b/nemoclaw/src/blueprint/state.ts @@ -28,6 +28,70 @@ export interface NemoClawState { shieldsPolicySnapshotPath: string | null; } +type UnknownRecord = { [key: string]: unknown }; + +function isRecord(value: unknown): value is UnknownRecord { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function readNullableString(value: unknown): string | null | undefined { + return value === undefined || value === null || typeof value === "string" ? value : undefined; +} + +function readString(value: unknown): string | undefined { + return typeof value === "string" ? value : undefined; +} + +function readBoolean(value: unknown): boolean | undefined { + return typeof value === "boolean" ? value : undefined; +} + +function readNullableNumber(value: unknown): number | null | undefined { + return value === undefined || value === null || typeof value === "number" ? value : undefined; +} + +function readStatePatch(value: unknown): Partial { + if (!isRecord(value)) { + return {}; + } + + const patch: Partial = {}; + + if (readNullableString(value.lastRunId) !== undefined) + patch.lastRunId = readNullableString(value.lastRunId); + if (readNullableString(value.lastAction) !== undefined) + patch.lastAction = readNullableString(value.lastAction); + if (readNullableString(value.blueprintVersion) !== undefined) + patch.blueprintVersion = readNullableString(value.blueprintVersion); + if (readNullableString(value.sandboxName) !== undefined) + patch.sandboxName = readNullableString(value.sandboxName); + if (readNullableString(value.migrationSnapshot) !== undefined) + patch.migrationSnapshot = readNullableString(value.migrationSnapshot); + if (readNullableString(value.hostBackupPath) !== undefined) + patch.hostBackupPath = readNullableString(value.hostBackupPath); + if (readNullableString(value.createdAt) !== undefined) + patch.createdAt = readNullableString(value.createdAt); + if (readString(value.updatedAt) !== undefined) patch.updatedAt = readString(value.updatedAt); + if (readNullableString(value.lastRebuildAt) !== undefined) + patch.lastRebuildAt = readNullableString(value.lastRebuildAt); + if (readNullableString(value.lastRebuildBackupPath) !== undefined) + patch.lastRebuildBackupPath = readNullableString(value.lastRebuildBackupPath); + if (readBoolean(value.shieldsDown) !== undefined) + patch.shieldsDown = readBoolean(value.shieldsDown); + if (readNullableString(value.shieldsDownAt) !== undefined) + patch.shieldsDownAt = readNullableString(value.shieldsDownAt); + if (readNullableNumber(value.shieldsDownTimeout) !== undefined) + patch.shieldsDownTimeout = readNullableNumber(value.shieldsDownTimeout); + if (readNullableString(value.shieldsDownReason) !== undefined) + patch.shieldsDownReason = readNullableString(value.shieldsDownReason); + if (readNullableString(value.shieldsDownPolicy) !== undefined) + patch.shieldsDownPolicy = readNullableString(value.shieldsDownPolicy); + if (readNullableString(value.shieldsPolicySnapshotPath) !== undefined) + patch.shieldsPolicySnapshotPath = readNullableString(value.shieldsPolicySnapshotPath); + + return patch; +} + let stateDirCreated = false; function ensureStateDir(): void { @@ -69,10 +133,15 @@ export function loadState(): NemoClawState { if (!existsSync(path)) { return blankState(); } - // Merge over blankState so that state files created before shields fields - // were added still return valid NemoClawState with sensible defaults. - const persisted = JSON.parse(readFileSync(path, "utf-8")) as Partial; - return { ...blankState(), ...persisted }; + + try { + // Merge over blankState so that state files created before shields fields + // were added still return valid NemoClawState with sensible defaults. + const persisted: unknown = JSON.parse(readFileSync(path, "utf-8")); + return { ...blankState(), ...readStatePatch(persisted) }; + } catch { + return blankState(); + } } export function saveState(state: NemoClawState): void { diff --git a/nemoclaw/src/commands/migration-state.test.ts b/nemoclaw/src/commands/migration-state.test.ts index 9704b76e754..75ece521136 100644 --- a/nemoclaw/src/commands/migration-state.test.ts +++ b/nemoclaw/src/commands/migration-state.test.ts @@ -905,13 +905,57 @@ describe("commands/migration-state", () => { stateDir: "/home/user/.openclaw", configPath: null, hasExternalConfig: false, - externalRoots: [], - warnings: [], + externalRoots: [ + { + id: "workspace-root", + kind: "workspace", + label: "Workspace", + sourcePath: "/host/workspace", + snapshotRelativePath: "external/workspace", + sandboxPath: "/sandbox/workspace", + symlinkPaths: ["/sandbox/.openclaw/workspace-link"], + bindings: [{ configPath: "workspace.path" }], + }, + ], + warnings: ["workspace root was remapped"], }; addFile("/snapshots/snap1/snapshot.json", JSON.stringify(manifest)); const loaded = loadSnapshotManifest("/snapshots/snap1"); expect(loaded).toEqual(manifest); }); + + it("rejects a snapshot manifest whose JSON root is not an object", () => { + addFile("/snapshots/snap1/snapshot.json", JSON.stringify(["not", "an", "object"])); + expect(() => loadSnapshotManifest("/snapshots/snap1")).toThrow(/Invalid snapshot manifest/); + }); + + it("rejects malformed externalRoots and warnings entries", () => { + addFile( + "/snapshots/snap1/snapshot.json", + JSON.stringify({ + version: 2, + createdAt: "2026-03-01T00:00:00.000Z", + homeDir: "/home/user", + stateDir: "/home/user/.openclaw", + configPath: null, + hasExternalConfig: false, + externalRoots: [ + { + id: "workspace-root", + kind: "workspace", + label: "Workspace", + sourcePath: "/host/workspace", + snapshotRelativePath: "external/workspace", + sandboxPath: "/sandbox/workspace", + symlinkPaths: ["/sandbox/.openclaw/workspace-link"], + bindings: [1], + }, + ], + warnings: [null], + }), + ); + expect(() => loadSnapshotManifest("/snapshots/snap1")).toThrow(/Invalid snapshot manifest/); + }); }); describe("restoreSnapshotToHost", () => { diff --git a/nemoclaw/src/commands/migration-state.ts b/nemoclaw/src/commands/migration-state.ts index a40e619f68f..e7c753d4492 100644 --- a/nemoclaw/src/commands/migration-state.ts +++ b/nemoclaw/src/commands/migration-state.ts @@ -593,10 +593,51 @@ function writeSnapshotManifest(snapshotDir: string, manifest: SnapshotManifest): writeFileSync(path.join(snapshotDir, "snapshot.json"), JSON.stringify(manifest, null, 2)); } +function isMigrationRootBinding(value: unknown): value is MigrationRootBinding { + return isRecord(value) && typeof value.configPath === "string"; +} + +function isMigrationExternalRoot(value: unknown): value is MigrationExternalRoot { + return ( + isRecord(value) && + typeof value.id === "string" && + (value.kind === "workspace" || value.kind === "agentDir" || value.kind === "skillsExtraDir") && + typeof value.label === "string" && + typeof value.sourcePath === "string" && + typeof value.snapshotRelativePath === "string" && + typeof value.sandboxPath === "string" && + Array.isArray(value.symlinkPaths) && + value.symlinkPaths.every((entry) => typeof entry === "string") && + Array.isArray(value.bindings) && + value.bindings.every((entry) => isMigrationRootBinding(entry)) + ); +} + +function isSnapshotManifest(value: unknown): value is SnapshotManifest { + return ( + isRecord(value) && + typeof value.version === "number" && + typeof value.createdAt === "string" && + typeof value.homeDir === "string" && + typeof value.stateDir === "string" && + (value.configPath === null || typeof value.configPath === "string") && + typeof value.hasExternalConfig === "boolean" && + Array.isArray(value.externalRoots) && + value.externalRoots.every((entry) => isMigrationExternalRoot(entry)) && + Array.isArray(value.warnings) && + value.warnings.every((entry) => typeof entry === "string") && + (value.blueprintDigest === undefined || + value.blueprintDigest === null || + typeof value.blueprintDigest === "string") + ); +} + function readSnapshotManifest(snapshotDir: string): SnapshotManifest { - return JSON.parse( - readFileSync(path.join(snapshotDir, "snapshot.json"), "utf-8"), - ) as SnapshotManifest; + const raw: unknown = JSON.parse(readFileSync(path.join(snapshotDir, "snapshot.json"), "utf-8")); + if (!isSnapshotManifest(raw)) { + throw new Error(`Invalid snapshot manifest at ${path.join(snapshotDir, "snapshot.json")}`); + } + return raw; } function resolveConfigSourcePath(manifest: SnapshotManifest, snapshotDir: string): string { @@ -608,12 +649,26 @@ function resolveConfigSourcePath(manifest: SnapshotManifest, snapshotDir: string const UNSAFE_PROPERTY_NAMES = new Set(["__proto__", "constructor", "prototype"]); +function isArrayIndexToken(token: string): boolean { + return /^\d+$/.test(token); +} + +function requireArray(value: unknown, configPath: string): unknown[] { + if (!Array.isArray(value)) { + throw new Error(`Invalid config path segment in ${configPath}`); + } + return value; +} + +function requireRecord(value: unknown, configPath: string): UnknownRecord { + if (!isRecord(value)) { + throw new Error(`Invalid config path segment in ${configPath}`); + } + return value; +} + /** @visibleForTesting */ -export function setConfigValue( - document: Record, - configPath: string, - value: string, -): void { +export function setConfigValue(document: UnknownRecord, configPath: string, value: string): void { const tokens = configPath.match(/[^.[\]]+/g); if (!tokens || tokens.length === 0) { throw new Error(`Invalid config path: ${configPath}`); @@ -632,21 +687,22 @@ export function setConfigValue( if (!token || !nextToken) { throw new Error(`Invalid config path segment in ${configPath}`); } - const isArrayIndex = /^\d+$/.test(token); + const isArrayIndex = isArrayIndexToken(token); if (isArrayIndex) { - const array = current as unknown[]; - const entry = array[Number.parseInt(token, 10)]; + const array = requireArray(current, configPath); + const arrayIndex = Number.parseInt(token, 10); + const entry = array[arrayIndex]; if (entry == null) { - array[Number.parseInt(token, 10)] = /^\d+$/.test(nextToken) ? [] : {}; + array[arrayIndex] = isArrayIndexToken(nextToken) ? [] : {}; } - current = array[Number.parseInt(token, 10)]; + current = array[arrayIndex]; continue; } - const record = current as Record; + const record = requireRecord(current, configPath); if (!record[token] || typeof record[token] !== "object") { - record[token] = /^\d+$/.test(nextToken) ? [] : {}; + record[token] = isArrayIndexToken(nextToken) ? [] : {}; } current = record[token]; } @@ -655,12 +711,13 @@ export function setConfigValue( if (!finalToken) { throw new Error(`Missing final config path segment in ${configPath}`); } - if (/^\d+$/.test(finalToken)) { - const array = current as unknown[]; + if (isArrayIndexToken(finalToken)) { + const array = requireArray(current, configPath); array[Number.parseInt(finalToken, 10)] = value; return; } - (current as Record)[finalToken] = value; + const record = requireRecord(current, configPath); + record[finalToken] = value; } function prepareSandboxState(snapshotDir: string, manifest: SnapshotManifest): string { @@ -679,7 +736,7 @@ function prepareSandboxState(snapshotDir: string, manifest: SnapshotManifest): s } // Strip gateway config (contains auth tokens) — sandbox entrypoint regenerates it - delete (config as Record)["gateway"]; + delete config["gateway"]; const configPath = path.join(preparedStateDir, "openclaw.json"); writeFileSync(configPath, JSON.stringify(config, null, 2)); diff --git a/nemoclaw/src/index.ts b/nemoclaw/src/index.ts index de4ddb771cf..4d62108b2c0 100644 --- a/nemoclaw/src/index.ts +++ b/nemoclaw/src/index.ts @@ -20,6 +20,40 @@ import { } from "./onboard/config.js"; import { scanForSecrets, isMemoryPath } from "./security/secret-scanner.js"; +type PluginScalar = string | number | boolean | null | undefined; +type PluginValue = PluginScalar | PluginRecord | PluginValue[]; +type PluginRecord = { [key: string]: PluginValue }; + +function isToolParams(value: PluginValue | object | null | undefined): value is ToolParams { + return ( + value !== null && value !== undefined && typeof value === "object" && !Array.isArray(value) + ); +} + +function readStringProperty( + value: PluginValue | object | null | undefined, + key: string, +): string | undefined { + if (!isToolParams(value)) { + return undefined; + } + const property = value[key]; + return typeof property === "string" ? property : undefined; +} + +function readBeforeToolCallEvent( + value: PluginValue | object | null | undefined, +): Partial | undefined { + if (!isToolParams(value)) { + return undefined; + } + const params = value["params"]; + return { + toolName: readStringProperty(value, "toolName"), + params: isToolParams(params) ? params : undefined, + }; +} + // Resolve live inference config from OpenShell as a fallback when the // onboard config file is not available (e.g. when running inside the // sandbox). Returns empty strings if the probe fails. @@ -30,15 +64,15 @@ function probeOpenShellInference(): { endpoint: string; provider: string; model: timeout: 3000, stdio: ["pipe", "pipe", "pipe"], }); - const parsed = JSON.parse(raw) as { - provider?: string; - model?: string; - endpoint?: string; - }; + const parsed: unknown = JSON.parse(raw); + const parsedObject = typeof parsed === "object" && parsed !== null ? parsed : null; + const endpoint = readStringProperty(parsedObject, "endpoint"); + const provider = readStringProperty(parsedObject, "provider"); + const model = readStringProperty(parsedObject, "model"); return { - endpoint: parsed.endpoint ?? parsed.provider ?? "", - provider: parsed.provider ?? "", - model: parsed.model ?? "", + endpoint: endpoint ?? "", + provider: provider ?? "", + model: model ?? "", }; } catch { return { endpoint: "", provider: "", model: "" }; @@ -51,7 +85,7 @@ function probeOpenShellInference(): { endpoint: string; provider: string; model: /** Subset of OpenClawConfig that we actually read. */ export interface OpenClawConfig { - [key: string]: unknown; + [key: string]: PluginValue; } /** Logger provided by the plugin host. */ @@ -62,6 +96,8 @@ export interface PluginLogger { debug(message: string): void; } +type ToolParams = { [key: string]: PluginValue }; + /** Context passed to slash-command handlers. */ export interface PluginCommandContext { senderId?: string; @@ -134,14 +170,14 @@ export interface PluginService { /** Event payload for before_tool_call hooks. */ export interface BeforeToolCallEvent { toolName: string; - params: Record; + params: ToolParams; runId?: string; toolCallId?: string; } /** Return value from a before_tool_call hook. */ export interface BeforeToolCallResult { - params?: Record; + params?: ToolParams; block?: boolean; blockReason?: string; } @@ -155,13 +191,16 @@ export interface OpenClawPluginApi { name: string; version?: string; config: OpenClawConfig; - pluginConfig?: Record; + pluginConfig?: OpenClawConfig; logger: PluginLogger; registerCommand: (command: PluginCommandDefinition) => void; registerProvider: (provider: ProviderPlugin) => void; registerService: (service: PluginService) => void; resolvePath: (input: string) => string; - on: (hookName: string, handler: (...args: unknown[]) => BeforeToolCallResult | undefined) => void; + on: ( + hookName: string, + handler: (...args: readonly PluginValue[]) => BeforeToolCallResult | undefined, + ) => void; } // --------------------------------------------------------------------------- @@ -325,38 +364,41 @@ export default function register(api: OpenClawPluginApi): void { // a no-op. Verify after OpenClaw upgrades that blocked writes still show // the expected error message. try { - api.on("before_tool_call", (...args: unknown[]): BeforeToolCallResult | undefined => { - const event = args[0] as Partial | undefined; - if (!event?.toolName || !event.params) return undefined; - - const toolName = event.toolName.toLowerCase(); - if (!WRITE_TOOL_NAMES.has(toolName)) return undefined; - - const rawPath = event.params["file_path"] ?? event.params["path"]; - if (typeof rawPath !== "string" || rawPath.length === 0) return undefined; - // Resolve symlinks and traversal before checking — prevents bypasses like - // /sandbox/project/../../.openclaw-data/memory/secrets.md - const filePath = api.resolvePath(rawPath); - if (!isMemoryPath(filePath)) return undefined; - - const content = - event.params["content"] ?? event.params["new_string"] ?? event.params["patch"]; - if (typeof content !== "string" || content.length === 0) return undefined; - - const matches = scanForSecrets(content); - if (matches.length === 0) return undefined; - - const summary = matches.map((m) => ` - ${m.pattern} (${m.redacted})`).join("\n"); - api.logger.warn(`[SECURITY] Blocked memory write to ${filePath} — secrets detected`); - - return { - block: true, - blockReason: - `Memory write blocked: detected ${String(matches.length)} likely secret(s):\n${summary}\n\n` + - "Remove secrets before saving to persistent memory. " + - "Use environment variables or credential stores instead.", - }; - }); + api.on( + "before_tool_call", + (...args: readonly PluginValue[]): BeforeToolCallResult | undefined => { + const event = readBeforeToolCallEvent(args[0]); + if (!event?.toolName || !event.params) return undefined; + + const toolName = event.toolName.toLowerCase(); + if (!WRITE_TOOL_NAMES.has(toolName)) return undefined; + + const rawPath = event.params["file_path"] ?? event.params["path"]; + if (typeof rawPath !== "string" || rawPath.length === 0) return undefined; + // Resolve symlinks and traversal before checking — prevents bypasses like + // /sandbox/project/../../.openclaw-data/memory/secrets.md + const filePath = api.resolvePath(rawPath); + if (!isMemoryPath(filePath)) return undefined; + + const content = + event.params["content"] ?? event.params["new_string"] ?? event.params["patch"]; + if (typeof content !== "string" || content.length === 0) return undefined; + + const matches = scanForSecrets(content); + if (matches.length === 0) return undefined; + + const summary = matches.map((m) => ` - ${m.pattern} (${m.redacted})`).join("\n"); + api.logger.warn(`[SECURITY] Blocked memory write to ${filePath} — secrets detected`); + + return { + block: true, + blockReason: + `Memory write blocked: detected ${String(matches.length)} likely secret(s):\n${summary}\n\n` + + "Remove secrets before saving to persistent memory. " + + "Use environment variables or credential stores instead.", + }; + }, + ); } catch (err) { api.logger.warn( `[SECURITY] Could not register secret scanner hook: ${err instanceof Error ? err.message : String(err)}`, diff --git a/nemoclaw/src/onboard/config.test.ts b/nemoclaw/src/onboard/config.test.ts index 4fcb77914da..bb0b8627209 100644 --- a/nemoclaw/src/onboard/config.test.ts +++ b/nemoclaw/src/onboard/config.test.ts @@ -149,6 +149,12 @@ describe("onboard/config", () => { store.set(configPath, JSON.stringify(config)); expect(loadOnboardConfig()).toEqual(config); }); + + it("returns null when the parsed JSON root is not a valid onboard config", () => { + const configPath = `${homedir()}/.nemoclaw/config.json`; + store.set(configPath, JSON.stringify({ endpointType: "bogus" })); + expect(loadOnboardConfig()).toBeNull(); + }); }); describe("saveOnboardConfig", () => { diff --git a/nemoclaw/src/onboard/config.ts b/nemoclaw/src/onboard/config.ts index f5ef52faee3..cb36e15295a 100644 --- a/nemoclaw/src/onboard/config.ts +++ b/nemoclaw/src/onboard/config.ts @@ -30,6 +30,55 @@ export interface NemoClawOnboardConfig { onboardedAt: string; } +type OnboardConfigSource = { + endpointType?: string | null; + endpointUrl?: string; + ncpPartner?: string | null; + model?: string; + profile?: string; + credentialEnv?: string; + provider?: string; + providerLabel?: string; + onboardedAt?: string; +}; + +function isRecord(value: object | null): value is OnboardConfigSource { + return value !== null && !Array.isArray(value); +} + +function isEndpointType(value: string | null | undefined): value is EndpointType { + return ( + value === "build" || + value === "openai" || + value === "anthropic" || + value === "gemini" || + value === "ncp" || + value === "nim-local" || + value === "vllm" || + value === "ollama" || + value === "custom" + ); +} + +function isOptionalString(value: string | null | undefined): boolean { + return value === undefined || typeof value === "string"; +} + +function isOnboardConfig(value: OnboardConfigSource | null): value is NemoClawOnboardConfig { + return ( + isRecord(value) && + isEndpointType(value.endpointType) && + typeof value.endpointUrl === "string" && + (value.ncpPartner === null || typeof value.ncpPartner === "string") && + typeof value.model === "string" && + typeof value.profile === "string" && + typeof value.credentialEnv === "string" && + isOptionalString(value.providerLabel) && + isOptionalString(value.provider) && + typeof value.onboardedAt === "string" + ); +} + export function describeOnboardEndpoint(config: NemoClawOnboardConfig): string { if (config.endpointUrl === "https://inference.local/v1") { return "Managed Inference Route (inference.local)"; @@ -108,7 +157,9 @@ export function loadOnboardConfig(): NemoClawOnboardConfig | null { if (!existsSync(path)) { return null; } - return JSON.parse(readFileSync(path, "utf-8")) as NemoClawOnboardConfig; + const parsed: unknown = JSON.parse(readFileSync(path, "utf-8")); + const parsedObject = typeof parsed === "object" && parsed !== null ? parsed : null; + return isOnboardConfig(parsedObject) ? parsedObject : null; } export function saveOnboardConfig(config: NemoClawOnboardConfig): void { diff --git a/nemoclaw/src/register.test.ts b/nemoclaw/src/register.test.ts index 0a1df184f07..b0c5e864a41 100644 --- a/nemoclaw/src/register.test.ts +++ b/nemoclaw/src/register.test.ts @@ -112,6 +112,22 @@ describe("plugin registration", () => { expect(logLines.some((line) => line.includes("Provider: Ollama"))).toBe(true); expect(logLines.some((line) => line.includes("Model: llama3.2:latest"))).toBe(true); }); + + it("does not treat the provider name as a fallback endpoint", () => { + mockedExecFileSync.mockReturnValue( + JSON.stringify({ + provider: "Ollama", + model: "llama3.2:latest", + }), + ); + + const api = createMockApi(); + register(api); + + const logLines = vi.mocked(api.logger.info).mock.calls.map(([message]) => message); + expect(logLines.some((line) => line.includes("Endpoint: build.nvidia.com"))).toBe(true); + expect(logLines.some((line) => line.includes("Endpoint: Ollama"))).toBe(false); + }); }); describe("before_tool_call secret scanner hook (#1233)", () => { diff --git a/scripts/bump-version.ts b/scripts/bump-version.ts index 8b13ccdfa00..8bfc694bf94 100644 --- a/scripts/bump-version.ts +++ b/scripts/bump-version.ts @@ -22,18 +22,15 @@ type Options = { type PackageJson = { version: string; scripts?: Record; - [key: string]: unknown; }; type BlueprintManifest = { version?: string; - [key: string]: unknown; }; type DocsProjectJson = { name?: string; version?: string; - [key: string]: unknown; }; type DocsVersionEntry = { @@ -42,6 +39,34 @@ type DocsVersionEntry = { url: string; }; +function parseJson(text: string): T { + return JSON.parse(text); +} + +function parseYaml(text: string): T { + return YAML.parse(text); +} + +function isErrnoException(error: unknown): error is NodeJS.ErrnoException { + return typeof error === "object" && error !== null && "code" in error; +} + +function readStringProperty(value: object | null, key: string): string | undefined { + if (!value || Array.isArray(value)) { + return undefined; + } + const property = Reflect.get(value, key); + return typeof property === "string" ? property : undefined; +} + +function readNumberProperty(value: object | null, key: string): number | undefined { + if (!value || Array.isArray(value)) { + return undefined; + } + const property = Reflect.get(value, key); + return typeof property === "number" ? property : undefined; +} + const REPO_ROOT = process.cwd(); const ROOT_PACKAGE_JSON = path.join(REPO_ROOT, "package.json"); const PLUGIN_PACKAGE_JSON = path.join(REPO_ROOT, "nemoclaw", "package.json"); @@ -209,7 +234,9 @@ function parseArgs(args: string[]): Options { } if (createPr && push) { - throw new Error("--push cannot be combined with --create-pr; PR mode pushes a release branch instead"); + throw new Error( + "--push cannot be combined with --create-pr; PR mode pushes a release branch instead", + ); } if (!branchName) { @@ -251,7 +278,9 @@ function ensureCleanGit(): void { function ensureOnMainBranch(): void { const branch = run("git", ["branch", "--show-current"]).trim(); if (branch !== "main") { - throw new Error(`Release bumps must run from main. Current branch: ${branch || "(detached HEAD)"}`); + throw new Error( + `Release bumps must run from main. Current branch: ${branch || "(detached HEAD)"}`, + ); } } @@ -279,12 +308,16 @@ function ensureUpToDateWithOriginMain(): void { if (localHead !== originHead) { if (mergeBase === originHead) { - throw new Error("Local main is ahead of origin/main. Push or reconcile before cutting a release."); + throw new Error( + "Local main is ahead of origin/main. Push or reconcile before cutting a release.", + ); } if (mergeBase === localHead) { throw new Error("Local main is behind origin/main. Pull/rebase before cutting a release."); } - throw new Error("Local main has diverged from origin/main. Reconcile before cutting a release."); + throw new Error( + "Local main has diverged from origin/main. Reconcile before cutting a release.", + ); } } @@ -301,7 +334,7 @@ function updatePackageJson(filePath: string, version: string): void { } function updateBlueprintVersion(version: string): void { - const manifest = YAML.parse(readText(BLUEPRINT_YAML)) as BlueprintManifest; + const manifest = parseYaml(readText(BLUEPRINT_YAML)); manifest.version = version; writeFileSync(BLUEPRINT_YAML, YAML.stringify(manifest), "utf8"); } @@ -354,30 +387,24 @@ function updateDocsVersionsJson(version: string): void { function readDocsVersionsJson(): DocsVersionEntry[] { try { - const parsed = JSON.parse(readText(DOCS_VERSIONS_JSON)) as unknown; + const parsed = parseJson>>(readText(DOCS_VERSIONS_JSON)); if (!Array.isArray(parsed)) { throw new Error("docs/versions1.json must contain an array"); } return parsed.map((entry) => { - if (!entry || typeof entry !== "object") { - throw new Error("Invalid docs/versions1.json entry"); - } - const candidate = entry as Partial; - if (typeof candidate.version !== "string") { + const version = typeof entry.version === "string" ? entry.version : undefined; + if (!version) { throw new Error("Each docs/versions1.json entry must include a string version"); } + const url = typeof entry.url === "string" ? entry.url : undefined; return { - preferred: candidate.preferred === true ? true : undefined, - version: candidate.version, - url: - typeof candidate.url === "string" && candidate.url.length > 0 - ? candidate.url - : buildDocsVersionUrl(candidate.version), + preferred: entry.preferred === true ? true : undefined, + version, + url: url && url.length > 0 ? url : buildDocsVersionUrl(version), }; }); } catch (error) { - const err = error as NodeJS.ErrnoException; - if (err.code === "ENOENT") { + if (isErrnoException(error) && error.code === "ENOENT") { return []; } throw error; @@ -416,10 +443,26 @@ function updateInstallAndUninstallDocs(nextDocsVersion: string): void { const installReplacement = `curl -fsSL https://www.nvidia.com/nemoclaw.sh | bash # ${nextDocsVersion}`; const uninstallReplacement = `curl -fsSL https://raw.githubusercontent.com/NVIDIA/NemoClaw/refs/heads/main/uninstall.sh | bash # ${nextDocsVersion}`; - replaceCodeBlockLine(README_MD, /^curl -fsSL https:\/\/www\.nvidia\.com\/nemoclaw\.sh \| bash(?: # v[0-9]+\.[0-9]+\.[0-9]+(?:[-+][0-9A-Za-z.-]+)?)?$/m, installReplacement); - replaceCodeBlockLine(QUICKSTART_MD, /^curl -fsSL https:\/\/www\.nvidia\.com\/nemoclaw\.sh \| bash(?: # v[0-9]+\.[0-9]+\.[0-9]+(?:[-+][0-9A-Za-z.-]+)?)?$/m, installReplacement); - replaceCodeBlockLine(README_MD, /^curl -fsSL https:\/\/raw\.githubusercontent\.com\/NVIDIA\/NemoClaw\/refs\/heads\/main\/uninstall\.sh \| bash(?: # v[0-9]+\.[0-9]+\.[0-9]+(?:[-+][0-9A-Za-z.-]+)?)?$/m, uninstallReplacement); - replaceCodeBlockLine(QUICKSTART_MD, /^curl -fsSL https:\/\/raw\.githubusercontent\.com\/NVIDIA\/NemoClaw\/refs\/heads\/main\/uninstall\.sh \| bash(?: # v[0-9]+\.[0-9]+\.[0-9]+(?:[-+][0-9A-Za-z.-]+)?)?$/m, uninstallReplacement); + replaceCodeBlockLine( + README_MD, + /^curl -fsSL https:\/\/www\.nvidia\.com\/nemoclaw\.sh \| bash(?: # v[0-9]+\.[0-9]+\.[0-9]+(?:[-+][0-9A-Za-z.-]+)?)?$/m, + installReplacement, + ); + replaceCodeBlockLine( + QUICKSTART_MD, + /^curl -fsSL https:\/\/www\.nvidia\.com\/nemoclaw\.sh \| bash(?: # v[0-9]+\.[0-9]+\.[0-9]+(?:[-+][0-9A-Za-z.-]+)?)?$/m, + installReplacement, + ); + replaceCodeBlockLine( + README_MD, + /^curl -fsSL https:\/\/raw\.githubusercontent\.com\/NVIDIA\/NemoClaw\/refs\/heads\/main\/uninstall\.sh \| bash(?: # v[0-9]+\.[0-9]+\.[0-9]+(?:[-+][0-9A-Za-z.-]+)?)?$/m, + uninstallReplacement, + ); + replaceCodeBlockLine( + QUICKSTART_MD, + /^curl -fsSL https:\/\/raw\.githubusercontent\.com\/NVIDIA\/NemoClaw\/refs\/heads\/main\/uninstall\.sh \| bash(?: # v[0-9]+\.[0-9]+\.[0-9]+(?:[-+][0-9A-Za-z.-]+)?)?$/m, + uninstallReplacement, + ); } function replaceCodeBlockLine(filePath: string, pattern: RegExp, replacement: string): void { @@ -431,11 +474,23 @@ function replaceCodeBlockLine(filePath: string, pattern: RegExp, replacement: st writeFileSync(filePath, updated, "utf8"); } -function verifyVersionState(version: string, docsPublicUrl: string, docsDisplayVersion: string): void { - assertEqual(readJson(ROOT_PACKAGE_JSON).version, version, "root package.json version mismatch"); - assertEqual(readJson(PLUGIN_PACKAGE_JSON).version, version, "plugin package.json version mismatch"); +function verifyVersionState( + version: string, + docsPublicUrl: string, + docsDisplayVersion: string, +): void { + assertEqual( + readJson(ROOT_PACKAGE_JSON).version, + version, + "root package.json version mismatch", + ); + assertEqual( + readJson(PLUGIN_PACKAGE_JSON).version, + version, + "plugin package.json version mismatch", + ); - const blueprint = YAML.parse(readText(BLUEPRINT_YAML)) as BlueprintManifest; + const blueprint = parseYaml(readText(BLUEPRINT_YAML)); assertEqual(blueprint.version, version, "blueprint version mismatch"); requireContains(INSTALL_SH, `DEFAULT_NEMOCLAW_VERSION="${version}"`); @@ -490,21 +545,18 @@ function createReleasePr(options: Options, previousVersion: string, tagName: str ranTests: !options.skipTests, ranFormat: false, }); - const prUrl = run( - "gh", - [ - "pr", - "create", - "--base", - "main", - "--head", - options.branchName, - "--title", - `chore(release): bump version to ${tagName}`, - "--body", - prBody, - ], - ).trim(); + const prUrl = run("gh", [ + "pr", + "create", + "--base", + "main", + "--head", + options.branchName, + "--title", + `chore(release): bump version to ${tagName}`, + "--body", + prBody, + ]).trim(); log(`Release PR created: ${prUrl}`); log(`Review and merge the PR before creating release tags on main.`); @@ -521,7 +573,11 @@ function ensureBranchDoesNotExist(branchName: string): void { } function gitRemoteBranchExists(branchName: string): boolean { - return run("git", ["ls-remote", "--exit-code", "--heads", "origin", branchName], { allowFailure: true }).exitCode === 0; + return ( + run("git", ["ls-remote", "--exit-code", "--heads", "origin", branchName], { + allowFailure: true, + }).exitCode === 0 + ); } type PrBodyOptions = { @@ -575,7 +631,7 @@ function buildPrBody(previousVersion: string, nextVersion: string, options: PrBo "- [x] Doc pages updated for any user-facing behavior changes (new commands, changed defaults, new features, bug fixes that contradict existing docs).", "", "### Doc Changes", - "- [ ] Follows the [style guide](https://github.com/NVIDIA/NemoClaw/blob/main/docs/CONTRIBUTING.md). Try running the `update-docs` agent skill to draft changes while complying with the style guide. For example, prompt your agent with \"`/update-docs` catch up the docs for the new changes I made in this PR.\"", + '- [ ] Follows the [style guide](https://github.com/NVIDIA/NemoClaw/blob/main/docs/CONTRIBUTING.md). Try running the `update-docs` agent skill to draft changes while complying with the style guide. For example, prompt your agent with "`/update-docs` catch up the docs for the new changes I made in this PR."', "- [ ] New pages include SPDX license header and frontmatter, if creating a new page.", "- [x] Cross-references and links verified.", "", @@ -594,11 +650,13 @@ function updateLatestTag(tagName: string): void { } function gitRefExists(ref: string): boolean { - return run("git", ["show-ref", "--verify", "--quiet", ref], { allowFailure: true }).exitCode === 0; + return ( + run("git", ["show-ref", "--verify", "--quiet", ref], { allowFailure: true }).exitCode === 0 + ); } function readJson(filePath: string): T { - return JSON.parse(readText(filePath)) as T; + return parseJson(readText(filePath)); } function readText(filePath: string): string { @@ -677,13 +735,17 @@ function verifyDocsLinks(filePath: string, expectedDocsPublicUrl: string): void } } -function assertEqual(actual: unknown, expected: unknown, message: string): void { +function assertEqual(actual: T, expected: T, message: string): void { if (actual !== expected) { throw new Error(`${message}. Expected '${expected}', got '${String(actual)}'`); } } -function run(command: string, args: string[], options?: { allowFailure?: boolean }): string & { exitCode?: number } { +function run( + command: string, + args: string[], + options?: { allowFailure?: boolean }, +): string & { exitCode?: number } { try { const output = execFileSync(command, args, { cwd: REPO_ROOT, @@ -692,16 +754,13 @@ function run(command: string, args: string[], options?: { allowFailure?: boolean }); return Object.assign(output, { exitCode: 0 }); } catch (error) { - const err = error as NodeJS.ErrnoException & { - stdout?: string; - stderr?: string; - status?: number; - }; + const errorObject = typeof error === "object" && error !== null ? error : null; + const stdout = readStringProperty(errorObject, "stdout")?.trim(); + const stderr = readStringProperty(errorObject, "stderr")?.trim(); + const status = readNumberProperty(errorObject, "status") ?? 1; if (options?.allowFailure) { - return Object.assign(err.stdout ?? "", { exitCode: err.status ?? 1 }); + return Object.assign(stdout ?? "", { exitCode: status }); } - const stderr = err.stderr?.trim(); - const stdout = err.stdout?.trim(); throw new Error( [`Command failed: ${command} ${args.join(" ")}`, stdout, stderr].filter(Boolean).join("\n"), ); @@ -722,8 +781,12 @@ function printDryRunPlan( log(`Docs mode: ${docsMode}`); log(`Docs URL target: ${docsPublicUrl}/`); log(`Files to update: ${FILES_TO_STAGE.map((filePath) => relative(filePath)).join(", ")}`); - log("Pre-checks: clean git tree, main branch, canonical origin, origin/main sync, tag availability"); - log(`Mode: ${docsMode === "versioned" ? "versioned docs" : "latest docs"}, ${skipTests ? "tests skipped" : "tests enabled"}`); + log( + "Pre-checks: clean git tree, main branch, canonical origin, origin/main sync, tag availability", + ); + log( + `Mode: ${docsMode === "versioned" ? "versioned docs" : "latest docs"}, ${skipTests ? "tests skipped" : "tests enabled"}`, + ); if (skipTests) { log("Checks: installer version and build:cli only (typecheck and tests skipped)"); } else { diff --git a/scripts/check-coverage-ratchet.ts b/scripts/check-coverage-ratchet.ts index 2d4a5a85f6a..bcaf17f6981 100755 --- a/scripts/check-coverage-ratchet.ts +++ b/scripts/check-coverage-ratchet.ts @@ -9,11 +9,12 @@ import { readFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; -const METRICS = ["lines", "functions", "branches", "statements"] as const; +type MetricName = "lines" | "functions" | "branches" | "statements"; -type MetricName = (typeof METRICS)[number]; +const METRICS: readonly MetricName[] = ["lines", "functions", "branches", "statements"]; type Thresholds = Record; +type CoverageSummary = { total: Record }; const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), ".."); const TOLERANCE = 1; @@ -22,26 +23,55 @@ const TOLERANCE = 1; function loadJSON(repoRelative: string): T { const abs = join(REPO_ROOT, repoRelative); try { - return JSON.parse(readFileSync(abs, "utf-8")) as T; + return JSON.parse(readFileSync(abs, "utf-8")); } catch (cause) { throw new Error(`Failed to load ${abs}`, { cause }); } } +function isMetricSummary(value: { pct?: number } | null | undefined): value is { pct: number } { + return typeof value?.pct === "number"; +} + +function isCoverageSummary( + value: { total?: Record } | null | undefined, +): value is CoverageSummary { + const total = value?.total; + if (!total) { + return false; + } + return METRICS.every((metric) => isMetricSummary(total[metric])); +} + +function isThresholds(value: Partial | null | undefined): value is Thresholds { + if (!value) { + return false; + } + return METRICS.every((metric) => typeof value[metric] === "number"); +} + function main(): void { const [summaryPath, thresholdPath, label = "coverage"] = process.argv.slice(2); if (!summaryPath || !thresholdPath) { throw new Error( - "Usage: check-coverage-ratchet.ts [label]" + "Usage: check-coverage-ratchet.ts [label]", ); } - const summary = loadJSON<{ total: Record }>(summaryPath); - const thresholds = loadJSON(thresholdPath); + + const summaryValue = loadJSON<{ total?: Record }>(summaryPath); + if (!isCoverageSummary(summaryValue)) { + throw new Error(`Invalid coverage summary: ${summaryPath}`); + } + + const thresholdValue = loadJSON>(thresholdPath); + if (!isThresholds(thresholdValue)) { + throw new Error(`Invalid coverage threshold: ${thresholdPath}`); + } const failures = METRICS.map((metric) => ({ metric, - actual: summary.total[metric].pct, - threshold: thresholds[metric], + actual: summaryValue.total[metric].pct, + threshold: thresholdValue[metric], })).filter((r) => r.actual < r.threshold - TOLERANCE); if (failures.length === 0) return; diff --git a/scripts/dev-tier-selector.js b/scripts/dev-tier-selector.js index e08c06f2bd1..153ac435913 100644 --- a/scripts/dev-tier-selector.js +++ b/scripts/dev-tier-selector.js @@ -1,4 +1,3 @@ -// @ts-nocheck // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 // @@ -34,7 +33,16 @@ creds.prompt = (msg) => }); }); -runner.run = () => {}; +const successfulRunResult = { + pid: 0, + output: [null, "", ""], + stdout: "", + stderr: "", + status: 0, + signal: null, +}; + +runner.run = () => successfulRunResult; runner.runCapture = () => ""; registry.getSandbox = () => ({ name: "test-sb", model: null, provider: null }); @@ -42,7 +50,11 @@ registry.registerSandbox = () => true; registry.updateSandbox = () => true; // ── Run ──────────────────────────────────────────────────────────────────── -const { selectPolicyTier, selectTierPresetsAndAccess } = require("../dist/lib/onboard.js"); +const onboard = /** @type {{ + * selectPolicyTier: () => Promise; + * selectTierPresetsAndAccess: (tierName: string, allPresets: unknown[]) => Promise; + * }} */ (require("../dist/lib/onboard.js")); +const { selectPolicyTier, selectTierPresetsAndAccess } = onboard; const policies = require("../dist/lib/policies.js"); (async () => { diff --git a/scripts/migrate-js-to-ts.ts b/scripts/migrate-js-to-ts.ts index ba34e942df1..fb83a4ce725 100644 --- a/scripts/migrate-js-to-ts.ts +++ b/scripts/migrate-js-to-ts.ts @@ -19,6 +19,16 @@ type Options = { apply: boolean; }; +type ManifestSource = Partial; + +function parseJson(text: string): T { + return JSON.parse(text); +} + +function isManifest(value: object | null): value is Manifest { + return value !== null && !Array.isArray(value); +} + const REPO_ROOT = process.cwd(); const DIST_ROOT = path.join(REPO_ROOT, "dist"); const SRC_ROOT = path.join(REPO_ROOT, "src"); @@ -79,14 +89,18 @@ function loadManifest(manifestPath: string): Manifest { if (!fs.existsSync(manifestPath)) { fail(`manifest not found: ${path.relative(REPO_ROOT, manifestPath)}`); } - const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")) as Manifest; + const manifest = parseJson(fs.readFileSync(manifestPath, "utf8")); + const manifestObject = typeof manifest === "object" && manifest !== null ? manifest : null; + if (!isManifest(manifestObject)) { + fail(`manifest must be a JSON object: ${path.relative(REPO_ROOT, manifestPath)}`); + } const allowedKeys = new Set(["renameTests", "moveRuntime", "rewriteSourcePaths", "shimStrategy"]); - for (const key of Object.keys(manifest)) { + for (const key of Object.keys(manifestObject)) { if (!allowedKeys.has(key)) { fail(`unsupported manifest key '${key}' in ${path.relative(REPO_ROOT, manifestPath)}`); } } - return manifest; + return manifestObject; } function normalizeRel(filePath: string): string { diff --git a/scripts/ts-migration-assist.ts b/scripts/ts-migration-assist.ts index 6533daedcbb..385d41961d5 100644 --- a/scripts/ts-migration-assist.ts +++ b/scripts/ts-migration-assist.ts @@ -1,4 +1,3 @@ -// @ts-nocheck // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 @@ -17,7 +16,7 @@ const REPO_ROOT = process.cwd(); const DIST_ROOT = path.join(REPO_ROOT, "dist"); const SRC_ROOT = path.join(REPO_ROOT, "src"); const WRAPPER_HEADER = "// @ts-nocheck\n"; -const RUNTIME_MOVES = moveMap.runtimeMoves as Record; +const RUNTIME_MOVES: Record = moveMap.runtimeMoves; const SPECIAL_REWRITES: Record> = { "bin/lib/onboard.js": [ diff --git a/scripts/type-safety-hotspots.ts b/scripts/type-safety-hotspots.ts index fc16b134775..9c6b2850a76 100644 --- a/scripts/type-safety-hotspots.ts +++ b/scripts/type-safety-hotspots.ts @@ -7,7 +7,7 @@ import { fileURLToPath } from "node:url"; import ts from "typescript"; -const DEFAULT_PROJECTS = ["tsconfig.cli.json", "nemoclaw/tsconfig.json"] as const; +const DEFAULT_PROJECTS = Object.freeze(["tsconfig.cli.json", "nemoclaw/tsconfig.json"]); const DEFAULT_TOP_FILES = 15; const DEFAULT_TOP_FUNCTIONS = 15; const DEFAULT_MIN_SCORE = 1; @@ -156,6 +156,13 @@ function createPatternCounts(): PatternCounts { }; } +function requireDefined(value: T | undefined, message: string): T { + if (value === undefined) { + throw new Error(message); + } + return value; +} + function addPattern(target: PatternCounts, key: keyof PatternCounts, amount = 1): void { target[key] += amount; } @@ -287,7 +294,8 @@ function shouldIncludeFile(filePath: string, includeTests: boolean): boolean { } function hasExportModifier(node: ts.Node): boolean { - return (ts.getCombinedModifierFlags(node as ts.Declaration) & ts.ModifierFlags.Export) !== 0; + const modifiers = ts.canHaveModifiers(node) ? ts.getModifiers(node) : undefined; + return Boolean(modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword)); } function countExports(sourceFile: ts.SourceFile): number { @@ -1082,7 +1090,13 @@ export function analyzeTypeSafetyHotspots(options: AnalyzeOptions = {}): Hotspot const rawFiles = [...analyzedFiles] .sort((left, right) => left.localeCompare(right)) - .map((absPath) => analyzeFile(absPath, rootDir, projectByFile.get(absPath)!)); + .map((absPath) => + analyzeFile( + absPath, + rootDir, + requireDefined(projectByFile.get(absPath), `Missing project for ${absPath}`), + ), + ); const importersByFile = new Map>(); const importsFromFile = new Map>(); @@ -1092,8 +1106,14 @@ export function analyzeTypeSafetyHotspots(options: AnalyzeOptions = {}): Hotspot } for (const file of rawFiles) { - const project = projectByFile.get(file.absPath)!; - const resolvedImports = importsFromFile.get(file.absPath)!; + const project = requireDefined( + projectByFile.get(file.absPath), + `Missing project for analyzed file ${file.absPath}`, + ); + const resolvedImports = requireDefined( + importsFromFile.get(file.absPath), + `Missing import set for analyzed file ${file.absPath}`, + ); for (const specifier of file.importSpecifiers) { const resolved = resolveLocalImport(project, file.absPath, specifier, analyzedFiles); @@ -1306,6 +1326,13 @@ export function renderTextReport( return lines.join("\n"); } +function requireCliValue(flag: string, value: string | undefined): string { + if (!value || value.startsWith("-")) { + throw new Error(`Missing value for ${flag}`); + } + return value; +} + function parseInteger(flag: string, value: string | undefined): number { if (!value) { throw new Error(`Missing value for ${flag}`); @@ -1333,17 +1360,11 @@ export function parseArgs(argv: string[]): CliOptions { const arg = argv[index]; switch (arg) { case "--root": - if (!argv[index + 1]) { - throw new Error("Missing value for --root"); - } - options.rootDir = path.resolve(argv[index + 1]!); + options.rootDir = path.resolve(requireCliValue("--root", argv[index + 1])); index += 1; break; case "--project": - if (!argv[index + 1]) { - throw new Error("Missing value for --project"); - } - projectPaths.push(argv[index + 1]!); + projectPaths.push(requireCliValue("--project", argv[index + 1])); index += 1; break; case "--top-files": diff --git a/scripts/validate-configs.ts b/scripts/validate-configs.ts index 889752bddf6..9e249a5206f 100755 --- a/scripts/validate-configs.ts +++ b/scripts/validate-configs.ts @@ -22,6 +22,10 @@ interface ConfigTarget { files: string[]; } +type ConfigScalar = string | number | boolean | null; +type ConfigValue = ConfigScalar | ConfigObject | ConfigValue[]; +type ConfigObject = { [key: string]: ConfigValue }; + /** * Build the list of config files and their corresponding JSON Schemas. * Preset YAML files are discovered dynamically from the presets directory. @@ -55,10 +59,12 @@ function discoverTargets(): ConfigTarget[] { files: presetFiles, }); } else { - console.warn("WARN: presets directory exists but contains no .yaml/.yml files — no preset validation performed"); + console.warn( + "WARN: presets directory exists but contains no .yaml/.yml files — no preset validation performed", + ); } } catch (err) { - const code = (err as { code?: string }).code; + const code = typeof err === "object" && err !== null && "code" in err ? err.code : undefined; if (code !== "ENOENT" && code !== "ENOTDIR") throw err; // presets directory may not exist — not an error } @@ -70,7 +76,7 @@ function discoverTargets(): ConfigTarget[] { * Read and parse a config file relative to the repository root. * YAML files are parsed with the `yaml` library; everything else is parsed as JSON. */ -function loadFile(repoRelative: string): unknown { +function loadFile(repoRelative: string): ConfigValue { const abs = join(REPO_ROOT, repoRelative); const raw = readFileSync(abs, "utf-8"); if (repoRelative.endsWith(".yaml") || repoRelative.endsWith(".yml")) { @@ -85,21 +91,30 @@ function loadFile(repoRelative: string): unknown { */ function loadSchema(repoRelative: string): object { const abs = join(REPO_ROOT, repoRelative); - return JSON.parse(readFileSync(abs, "utf-8")) as object; + const schema: object = JSON.parse(readFileSync(abs, "utf-8")); + return schema; } +type ValidationParams = { additionalProperty?: string; unevaluatedProperty?: string }; + /** * Format a single AJV validation error into a human-readable string. * Includes the JSON Pointer path and a detail message, expanding * `additionalProperty` and `unevaluatedProperty` params for clarity. */ -function formatError(err: { instancePath: string; keyword?: string; message?: string; params?: Record }): string { +function formatError(err: { + instancePath: string; + keyword?: string; + message?: string; + params?: ValidationParams; +}): string { const path = err.instancePath || "/"; + const message = err.message ?? "unknown error"; const detail = err.params?.additionalProperty - ? `${err.message} '${err.params.additionalProperty}'` + ? `${message} '${err.params.additionalProperty}'` : err.params?.unevaluatedProperty - ? `${err.message} '${err.params.unevaluatedProperty}'` - : err.message ?? "unknown error"; + ? `${message} '${err.params.unevaluatedProperty}'` + : message; return ` ${path}: ${detail}`; } @@ -115,13 +130,7 @@ function formatError(err: { instancePath: string; keyword?: string; message?: st // legitimate pattern for real deployments. // ──────────────────────────────────────────────────────────────────── -const DANGEROUS_HOSTS: ReadonlySet = new Set([ - "*", - "0.0.0.0", - "0.0.0.0/0", - "::", - "::/0", -]); +const DANGEROUS_HOSTS: ReadonlySet = new Set(["*", "0.0.0.0", "0.0.0.0/0", "::", "::/0"]); /** * Return true if `host` is a catch-all value that grants access to any destination. @@ -224,7 +233,7 @@ function main(): void { for (const file of target.files) { totalFiles++; - let data: unknown; + let data: ConfigValue; try { data = loadFile(file); } catch (err) { @@ -273,6 +282,9 @@ function main(): void { export { DANGEROUS_HOSTS, isDangerousHost, findDangerousHosts }; // Only run main() when invoked directly (skip on test `import`). -if (import.meta.url === `file://${process.argv[1]}` || process.argv[1]?.endsWith("validate-configs.ts")) { +if ( + import.meta.url === `file://${process.argv[1]}` || + process.argv[1]?.endsWith("validate-configs.ts") +) { main(); } diff --git a/src/lib/agent-defs.ts b/src/lib/agent-defs.ts index 4809166be0e..3a94c69f8a3 100644 --- a/src/lib/agent-defs.ts +++ b/src/lib/agent-defs.ts @@ -6,8 +6,6 @@ import fs from "node:fs"; import path from "node:path"; -// eslint-disable-next-line @typescript-eslint/no-require-imports -const yaml: { load(input: string): unknown } = require("js-yaml"); import { ROOT } from "./runner"; import { DASHBOARD_PORT } from "./ports"; @@ -19,6 +17,9 @@ type ManifestValue = ManifestScalar | ManifestRecord | ManifestValue[]; type ManifestRecord = { [key: string]: ManifestValue }; type StringMap = { [key: string]: string }; +// eslint-disable-next-line @typescript-eslint/no-require-imports +const yaml: { load(input: string): unknown } = require("js-yaml"); + export interface AgentHealthProbe { url: string; port: number; @@ -85,7 +86,6 @@ export interface AgentDefinition { readonly policyPermissivePath: string | null; readonly pluginDir: string | null; readonly legacyPaths: AgentLegacyPaths | null; - [key: string]: unknown; } export interface AgentChoice { @@ -305,7 +305,7 @@ export function loadAgent(name: string): AgentDefinition { }, get dashboard(): AgentDashboard { - const d = (raw.dashboard as Partial) || {}; + const d = readObject(raw, "dashboard") ?? {}; const kind: AgentDashboardKind = d.kind === "api" ? "api" : "ui"; const defaultLabel = kind === "api" ? "API" : "UI"; const normalizedLabel = typeof d.label === "string" ? d.label.trim() : ""; diff --git a/src/lib/agent-onboard.test.ts b/src/lib/agent-onboard.test.ts index 0017e4171e7..9d3b431c006 100644 --- a/src/lib/agent-onboard.test.ts +++ b/src/lib/agent-onboard.test.ts @@ -6,21 +6,52 @@ import { describe, it, expect, beforeEach, afterEach, afterAll, vi } from "vites import { printDashboardUi } from "../../dist/lib/agent-onboard"; import type { AgentDefinition } from "./agent-defs"; -// Test fixtures — only the fields printDashboardUi reads are populated. -// Cast via unknown to avoid requiring the full AgentDefinition shape. -const apiAgent = { +function makeAgent(overrides: Partial = {}): AgentDefinition { + return { + name: "agent", + displayName: "Agent", + healthProbe: { url: "http://127.0.0.1:19000/", port: 19000, timeout_seconds: 5 }, + forwardPort: 19000, + dashboard: { kind: "ui", label: "UI", path: "/" }, + configPaths: { + immutableDir: "/tmp/agent/immutable", + writableDir: "/tmp/agent/writable", + configFile: "/tmp/agent/config.yaml", + envFile: null, + format: "yaml", + }, + stateDirs: [], + versionCommand: "agent --version", + expectedVersion: null, + hasDevicePairing: false, + phoneHomeHosts: [], + messagingPlatforms: [], + dockerfileBasePath: null, + dockerfilePath: null, + startScriptPath: null, + policyAdditionsPath: null, + policyPermissivePath: null, + pluginDir: null, + legacyPaths: null, + agentDir: "/tmp/agent", + manifestPath: "/tmp/agent/manifest.yaml", + ...overrides, + }; +} + +const apiAgent = makeAgent({ name: "hermes", displayName: "Hermes Agent", forwardPort: 8642, dashboard: { kind: "api", label: "OpenAI-compatible API", path: "/v1" }, -} as unknown as AgentDefinition; +}); -const uiAgent = { +const uiAgent = makeAgent({ name: "ficticious-ui", displayName: "Ficticious", forwardPort: 19000, dashboard: { kind: "ui", label: "UI", path: "/" }, -} as unknown as AgentDefinition; +}); // Regression fixture for issue #2078 — matches the text a user sees when // no token is available and prevents the wording from regressing to diff --git a/src/lib/agent-onboard.ts b/src/lib/agent-onboard.ts index a8fc6ed7769..e465540ec54 100644 --- a/src/lib/agent-onboard.ts +++ b/src/lib/agent-onboard.ts @@ -15,15 +15,19 @@ import { getProviderSelectionConfig } from "./inference-config"; import * as onboardSession from "./onboard-session"; import { sleepSeconds } from "./wait"; +type LooseScalar = string | number | boolean | null | undefined; +type LooseValue = LooseScalar | LooseObject | LooseValue[]; +type LooseObject = { [key: string]: LooseValue }; + export interface OnboardContext { step: (current: number, total: number, message: string) => void; runCaptureOpenshell: (args: string[], opts?: { ignoreError?: boolean }) => string | null; openshellShellCommand: (args: string[], options?: { openshellBinary?: string }) => string; openshellBinary: string; - buildSandboxConfigSyncScript: (config: Record) => string; + buildSandboxConfigSyncScript: (config: LooseObject) => string; writeSandboxConfigSyncFile: (script: string) => string; cleanupTempDir: (file: string, prefix: string) => void; - startRecordedStep: (stepName: string, updates: Record) => void; + startRecordedStep: (stepName: string, updates: LooseObject) => void; skippedStepMessage: (stepName: string, sandboxName: string) => void; } @@ -54,18 +58,21 @@ export function createAgentSandbox(agent: AgentDefinition): { const agentDockerfile = agent.dockerfilePath; const baseDockerfile = agent.dockerfileBasePath; + if (!agentDockerfile) { + throw new Error(`${agent.displayName} is missing a sandbox Dockerfile`); + } + if (baseDockerfile) { const baseImageTag = `ghcr.io/nvidia/nemoclaw/${agent.name}-sandbox-base:latest`; - const inspectResult = run( - ["docker", "image", "inspect", baseImageTag], - { ignoreError: true, suppressOutput: true }, - ); + const inspectResult = run(["docker", "image", "inspect", baseImageTag], { + ignoreError: true, + suppressOutput: true, + }); if (inspectResult.status !== 0) { console.log(` Building ${agent.displayName} base image (first time only)...`); - run( - ["docker", "build", "-f", baseDockerfile, "-t", baseImageTag, ROOT], - { stdio: ["ignore", "inherit", "inherit"] }, - ); + run(["docker", "build", "-f", baseDockerfile, "-t", baseImageTag, ROOT], { + stdio: ["ignore", "inherit", "inherit"], + }); console.log(` \u2713 Base image built: ${baseImageTag}`); } else { console.log(` Base image exists: ${baseImageTag}`); @@ -81,7 +88,7 @@ export function createAgentSandbox(agent: AgentDefinition): { }, }); const stagedDockerfile = path.join(buildCtx, "Dockerfile"); - fs.copyFileSync(agentDockerfile!, stagedDockerfile); + fs.copyFileSync(agentDockerfile, stagedDockerfile); console.log(` Using ${agent.displayName} Dockerfile: ${agentDockerfile}`); return { buildCtx, stagedDockerfile }; @@ -116,7 +123,7 @@ export async function handleAgentSetup( provider: string, agent: AgentDefinition, resume: boolean, - _session: unknown, + _session: object | null, ctx: OnboardContext, ): Promise { const { @@ -160,10 +167,10 @@ export async function handleAgentSetup( const scriptFile = writeSandboxConfigSyncFile(script); try { const scriptContent = fs.readFileSync(scriptFile, "utf-8"); - run( - [openshellBin, "sandbox", "connect", sandboxName], - { stdio: ["pipe", "ignore", "inherit"], input: scriptContent }, - ); + run([openshellBin, "sandbox", "connect", sandboxName], { + stdio: ["pipe", "ignore", "inherit"], + input: scriptContent, + }); } finally { cleanupTempDir(scriptFile, "nemoclaw-sync"); } @@ -190,12 +197,8 @@ export async function handleAgentSetup( if (healthy) { console.log(` \u2713 ${agent.displayName} gateway is healthy`); } else { - console.log( - ` \u26a0 ${agent.displayName} gateway did not respond within ${timeoutSecs}s.`, - ); - console.log( - ` The gateway may still be starting. Check: nemoclaw ${sandboxName} logs`, - ); + console.log(` \u26a0 ${agent.displayName} gateway did not respond within ${timeoutSecs}s.`); + console.log(` The gateway may still be starting. Check: nemoclaw ${sandboxName} logs`); } } else { console.log(` \u2713 ${agent.displayName} configured inside sandbox`); diff --git a/src/lib/agent-runtime.test.ts b/src/lib/agent-runtime.test.ts index 69cbdbdf201..dc933d7d4f9 100644 --- a/src/lib/agent-runtime.test.ts +++ b/src/lib/agent-runtime.test.ts @@ -6,15 +6,42 @@ import { describe, it, expect } from "vitest"; import { buildRecoveryScript } from "../../dist/lib/agent-runtime"; import type { AgentDefinition } from "./agent-defs"; -// Test fixture — only fields read by buildRecoveryScript are needed. -// Cast via unknown to avoid requiring the full AgentDefinition shape. -const minimalAgent = { - name: "test-agent", - displayName: "Test Agent", - binary_path: "/usr/local/bin/test-agent", - gateway_command: "test-agent gateway run", - healthProbe: { url: "http://127.0.0.1:19000/" }, -} as unknown as AgentDefinition; +function makeAgent(overrides: Partial = {}): AgentDefinition { + return { + name: "test-agent", + displayName: "Test Agent", + binary_path: "/usr/local/bin/test-agent", + gateway_command: "test-agent gateway run", + healthProbe: { url: "http://127.0.0.1:19000/", port: 19000, timeout_seconds: 5 }, + forwardPort: 19000, + dashboard: { kind: "ui", label: "UI", path: "/" }, + configPaths: { + immutableDir: "/tmp/agent/immutable", + writableDir: "/tmp/agent/writable", + configFile: "/tmp/agent/config.yaml", + envFile: null, + format: "yaml", + }, + stateDirs: [], + versionCommand: "test-agent --version", + expectedVersion: null, + hasDevicePairing: false, + phoneHomeHosts: [], + messagingPlatforms: [], + dockerfileBasePath: null, + dockerfilePath: null, + startScriptPath: null, + policyAdditionsPath: null, + policyPermissivePath: null, + pluginDir: null, + legacyPaths: null, + agentDir: "/tmp/agent", + manifestPath: "/tmp/agent/manifest.yaml", + ...overrides, + }; +} + +const minimalAgent = makeAgent(); describe("buildRecoveryScript", () => { it("returns null for null agent (OpenClaw inline script handles it)", () => { @@ -31,14 +58,23 @@ describe("buildRecoveryScript", () => { expect(script).toContain("--port 18789"); }); - it("uses the agent gateway_command, not a hardcoded openclaw", () => { + it("launches the default gateway command through the validated agent binary", () => { const script = buildRecoveryScript(minimalAgent, 19000); - expect(script).toContain("test-agent gateway run --port 19000"); + expect(script).toContain("command -v 'test-agent'"); + expect(script).toContain('nohup "$AGENT_BIN" gateway run --port 19000'); }); it("falls back to openclaw gateway run when gateway_command is absent", () => { - const agent = { ...minimalAgent, gateway_command: undefined } as unknown as AgentDefinition; + const agent = makeAgent({ gateway_command: undefined }); + const script = buildRecoveryScript(agent, 19000); + expect(script).toContain('nohup "$AGENT_BIN" gateway run --port 19000'); + }); + + it("validates and launches custom gateway commands explicitly", () => { + const agent = makeAgent({ gateway_command: "custom-launch --mode recovery" }); const script = buildRecoveryScript(agent, 19000); - expect(script).toContain("openclaw gateway run --port 19000"); + expect(script).toContain("GATEWAY_CMD_BIN='custom-launch'"); + expect(script).toContain('command -v "$GATEWAY_CMD_BIN" >/dev/null 2>&1'); + expect(script).toContain("nohup custom-launch --mode recovery --port 19000"); }); }); diff --git a/src/lib/agent-runtime.ts b/src/lib/agent-runtime.ts index 9e1180c6569..5a1670c8db0 100644 --- a/src/lib/agent-runtime.ts +++ b/src/lib/agent-runtime.ts @@ -58,7 +58,23 @@ export function buildRecoveryScript(agent: AgentDefinition | null, port: number) const probeUrl = getHealthProbeUrl(agent); const binaryPath = agent.binary_path || "/usr/local/bin/openclaw"; - const gatewayCmd = agent.gateway_command || "openclaw gateway run"; + const binaryName = binaryPath.split("/").pop() ?? "openclaw"; + const defaultGatewayCommand = `${binaryName} gateway run`; + const configuredGatewayCommand = agent.gateway_command?.trim() || defaultGatewayCommand; + const usesValidatedBinary = configuredGatewayCommand === defaultGatewayCommand; + const customGatewayExecutable = configuredGatewayCommand.split(/\s+/)[0] ?? binaryName; + const validationSteps = usesValidatedBinary + ? [ + `AGENT_BIN=${shellQuote(binaryPath)}; if [ ! -x "$AGENT_BIN" ]; then AGENT_BIN="$(command -v ${shellQuote(binaryName)})"; fi;`, + 'if [ -z "$AGENT_BIN" ]; then echo AGENT_MISSING; exit 1; fi;', + ] + : [ + `GATEWAY_CMD_BIN=${shellQuote(customGatewayExecutable)};`, + 'case "$GATEWAY_CMD_BIN" in */*) [ -x "$GATEWAY_CMD_BIN" ] || { echo AGENT_MISSING; exit 1; } ;; *) command -v "$GATEWAY_CMD_BIN" >/dev/null 2>&1 || { echo AGENT_MISSING; exit 1; } ;; esac;', + ]; + const launchCommand = usesValidatedBinary + ? `nohup "$AGENT_BIN" gateway run --port ${port} > /tmp/gateway.log 2>&1 &` + : `nohup ${configuredGatewayCommand} --port ${port} > /tmp/gateway.log 2>&1 &`; const isHermes = agent.name === "hermes"; const hermesHome = isHermes ? "export HERMES_HOME=/sandbox/.hermes-data; " : ""; @@ -68,9 +84,8 @@ export function buildRecoveryScript(agent: AgentDefinition | null, port: number) `if curl -sf --max-time 3 ${shellQuote(probeUrl)} > /dev/null 2>&1; then echo ALREADY_RUNNING; exit 0; fi;`, "rm -f /tmp/gateway.log;", "touch /tmp/gateway.log; chmod 600 /tmp/gateway.log;", - `AGENT_BIN=${shellQuote(binaryPath as string)}; if [ ! -x "$AGENT_BIN" ]; then AGENT_BIN="$(command -v ${shellQuote((binaryPath as string).split("/").pop()!)})"; fi;`, - 'if [ -z "$AGENT_BIN" ]; then echo AGENT_MISSING; exit 1; fi;', - `nohup ${gatewayCmd} --port ${port} > /tmp/gateway.log 2>&1 &`, + ...validationSteps, + launchCommand, "GPID=$!; sleep 2;", 'if kill -0 "$GPID" 2>/dev/null; then echo "GATEWAY_PID=$GPID"; else echo GATEWAY_FAILED; cat /tmp/gateway.log 2>/dev/null | tail -5; fi', ].join(" "); @@ -87,7 +102,5 @@ export function getAgentDisplayName(agent: AgentDefinition | null): string { * Get the gateway command for the current agent. */ export function getGatewayCommand(agent: AgentDefinition | null): string { - return agent - ? (agent.gateway_command as string) || "openclaw gateway run" - : "openclaw gateway run"; + return agent?.gateway_command || "openclaw gateway run"; } diff --git a/src/lib/config-io.ts b/src/lib/config-io.ts index 467ea4c62d5..5e28967ca78 100644 --- a/src/lib/config-io.ts +++ b/src/lib/config-io.ts @@ -9,6 +9,36 @@ import path from "node:path"; import { shellQuote } from "./shell-quote"; +type ErrnoLike = Error | { code?: string | number } | null; +type JsonScalar = string | number | boolean | null; +type JsonValue = JsonScalar | JsonObject | JsonValue[]; +type JsonObject = { [key: string]: JsonValue }; +type SerializableConfig = JsonScalar | JsonValue[] | object; + +function toError(error: Error | string | number | boolean | null | undefined): Error { + return error instanceof Error ? error : new Error(String(error)); +} + +function isErrnoException(error: ErrnoLike): error is NodeJS.ErrnoException { + return error instanceof Error && "code" in error; +} + +function isPermissionError(error: ErrnoLike): error is NodeJS.ErrnoException { + return isErrnoException(error) && (error.code === "EACCES" || error.code === "EPERM"); +} + +function parseJson(text: string): T { + return JSON.parse(text); +} + +function cleanupTempFile(filePath: string): void { + try { + fs.unlinkSync(filePath); + } catch { + // Best effort — cleanup only. + } +} + function buildRemediation(): string { const home = process.env.HOME ?? os.homedir(); const nemoclawDir = path.join(home, ".nemoclaw"); @@ -39,22 +69,6 @@ function buildRemediation(): string { ].join("\n"); } -function isErrnoException(error: unknown): error is NodeJS.ErrnoException { - return error instanceof Error && "code" in error; -} - -function isPermissionError(error: unknown): error is NodeJS.ErrnoException { - return isErrnoException(error) && (error.code === "EACCES" || error.code === "EPERM"); -} - -function cleanupTempFile(filePath: string): void { - try { - fs.unlinkSync(filePath); - } catch { - // Best effort — cleanup only. - } -} - export class ConfigPermissionError extends Error { code = "EACCES"; configPath: string; @@ -125,9 +139,12 @@ function rejectSymlinksOnPath(dirPath: string): void { ); } } catch (error) { + const errnoError = error instanceof Error ? error : null; // ENOENT is fine — the directory doesn't exist yet; keep walking up // to check ancestors that DO exist (an ancestor might be a symlink). - if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + if (!(isErrnoException(errnoError) && errnoError.code === "ENOENT")) { + throw error; + } } current = path.dirname(current); } @@ -144,21 +161,27 @@ export function ensureConfigDir(dirPath: string): void { if ((stat.mode & 0o077) !== 0) { fs.chmodSync(dirPath, 0o700); } - } catch (error: unknown) { - if (isPermissionError(error)) { - throw new ConfigPermissionError(`Cannot create config directory: ${dirPath}`, dirPath, error); + } catch (error) { + const errnoError = error instanceof Error ? error : null; + if (isPermissionError(errnoError)) { + throw new ConfigPermissionError( + `Cannot create config directory: ${dirPath}`, + dirPath, + toError(errnoError), + ); } throw error; } try { fs.accessSync(dirPath, fs.constants.W_OK); - } catch (error: unknown) { - if (isPermissionError(error)) { + } catch (error) { + const errnoError = error instanceof Error ? error : null; + if (isPermissionError(errnoError)) { throw new ConfigPermissionError( `Config directory exists but is not writable: ${dirPath}`, dirPath, - error, + toError(errnoError), ); } throw error; @@ -167,20 +190,24 @@ export function ensureConfigDir(dirPath: string): void { export function readConfigFile(filePath: string, fallback: T): T { try { - const parsed: unknown = JSON.parse(fs.readFileSync(filePath, "utf-8")); - return parsed as T; - } catch (error: unknown) { - if (isPermissionError(error)) { - throw new ConfigPermissionError(`Cannot read config file: ${filePath}`, filePath, error); + return parseJson(fs.readFileSync(filePath, "utf-8")); + } catch (error) { + const errnoError = error instanceof Error ? error : null; + if (isPermissionError(errnoError)) { + throw new ConfigPermissionError( + `Cannot read config file: ${filePath}`, + filePath, + toError(errnoError), + ); } - if (isErrnoException(error) && error.code === "ENOENT") { + if (isErrnoException(errnoError) && errnoError.code === "ENOENT") { return fallback; } return fallback; } } -export function writeConfigFile(filePath: string, data: unknown): void { +export function writeConfigFile(filePath: string, data: SerializableConfig): void { const dirPath = path.dirname(filePath); ensureConfigDir(dirPath); @@ -188,10 +215,15 @@ export function writeConfigFile(filePath: string, data: unknown): void { try { fs.writeFileSync(tmpFile, JSON.stringify(data, null, 2), { mode: 0o600 }); fs.renameSync(tmpFile, filePath); - } catch (error: unknown) { + } catch (error) { cleanupTempFile(tmpFile); - if (isPermissionError(error)) { - throw new ConfigPermissionError(`Cannot write config file: ${filePath}`, filePath, error); + const errnoError = error instanceof Error ? error : null; + if (isPermissionError(errnoError)) { + throw new ConfigPermissionError( + `Cannot write config file: ${filePath}`, + filePath, + toError(errnoError), + ); } throw error; } diff --git a/src/lib/credential-filter.ts b/src/lib/credential-filter.ts index 23d59144fae..7a594c2f21c 100644 --- a/src/lib/credential-filter.ts +++ b/src/lib/credential-filter.ts @@ -12,6 +12,10 @@ import { chmodSync, existsSync, readFileSync, writeFileSync } from "node:fs"; +function parseJson(text: string): T { + return JSON.parse(text); +} + /** * JSON-like configuration value supported by credential stripping. */ @@ -67,14 +71,14 @@ export function isCredentialField(key: string): boolean { /** * Narrow an unknown value to a JSON-like configuration object. */ -export function isConfigObject(value: unknown): value is ConfigObject { +export function isConfigObject(value: ConfigValue | object): value is ConfigObject { return typeof value === "object" && value !== null && !Array.isArray(value); } /** * Narrow an unknown value to a JSON-like configuration value. */ -export function isConfigValue(value: unknown): value is ConfigValue { +export function isConfigValue(value: ConfigValue | object): value is ConfigValue { if (value === null || value === undefined) return true; if (typeof value === "boolean" || typeof value === "number" || typeof value === "string") { return true; @@ -98,11 +102,19 @@ export function isConfigValue(value: unknown): value is ConfigValue { * Recursively strip credential fields from a JSON-like object. * Returns a new object with sensitive values replaced by a placeholder. */ -export function stripCredentials(obj: T): T { +export function stripCredentials(obj: null): null; +export function stripCredentials(obj: undefined): undefined; +export function stripCredentials(obj: boolean): boolean; +export function stripCredentials(obj: number): number; +export function stripCredentials(obj: string): string; +export function stripCredentials(obj: T): T; +export function stripCredentials(obj: T): T; +export function stripCredentials(obj: ConfigValue): ConfigValue; +export function stripCredentials(obj: ConfigValue): ConfigValue { if (obj === null || obj === undefined) return obj; if (typeof obj !== "object") return obj; if (Array.isArray(obj)) { - return obj.map((value) => stripCredentials(value)) as T; + return obj.map((value) => stripCredentials(value)); } if (!isConfigObject(obj)) return obj; @@ -110,7 +122,7 @@ export function stripCredentials(obj: T): T { for (const [key, value] of Object.entries(obj)) { result[key] = isCredentialField(key) ? CREDENTIAL_PLACEHOLDER : stripCredentials(value); } - return result as T; + return result; } /** @@ -119,9 +131,9 @@ export function stripCredentials(obj: T): T { */ export function sanitizeConfigFile(configPath: string): void { if (!existsSync(configPath)) return; - let parsed: unknown; + let parsed: ConfigValue; try { - parsed = JSON.parse(readFileSync(configPath, "utf-8")); + parsed = parseJson(readFileSync(configPath, "utf-8")); } catch { return; // Not valid JSON — skip (may be YAML for Hermes) } diff --git a/src/lib/credentials.ts b/src/lib/credentials.ts index a23d3646b89..40250745029 100644 --- a/src/lib/credentials.ts +++ b/src/lib/credentials.ts @@ -11,6 +11,14 @@ import { readConfigFile, writeConfigFile } from "./config-io"; const UNSAFE_HOME_PATHS = new Set(["/tmp", "/var/tmp", "/dev/shm", "/"]); +type ErrnoLike = Error | { code?: string | number } | null; + +function isErrnoException(error: ErrnoLike): error is NodeJS.ErrnoException { + return error !== null && typeof error === "object" && "code" in error; +} + +type CredentialInput = string | null | undefined; + export function resolveHomeDir(): string { const raw = process.env.HOME || os.homedir(); if (!raw) { @@ -31,7 +39,12 @@ export function resolveHomeDir(): string { ); } } catch (error) { - if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + if ( + !(typeof error === "object" && error !== null && isErrnoException(error)) || + error.code !== "ENOENT" + ) { + throw error; + } } if (UNSAFE_HOME_PATHS.has(home)) { throw new Error( @@ -68,12 +81,12 @@ export function loadCredentials(): Record { return readConfigFile>(getCredsFile(), {}); } -export function normalizeCredentialValue(value: unknown): string { +export function normalizeCredentialValue(value: CredentialInput): string { if (typeof value !== "string") return ""; return value.replace(/\r/g, "").trim(); } -export function saveCredential(key: string, value: unknown): void { +export function saveCredential(key: string, value: CredentialInput): void { const creds = loadCredentials(); creds[key] = normalizeCredentialValue(value); writeConfigFile(getCredsFile(), creds); @@ -118,12 +131,20 @@ export function promptSecret(question: string): Promise { } } - function finish(fn: (value: string | Error) => void, value: string | Error) { + function resolvePrompt(value: string) { if (finished) return; finished = true; cleanup(); output.write("\n"); - fn(value); + resolve(value); + } + + function rejectPrompt(error: Error) { + if (finished) return; + finished = true; + cleanup(); + output.write("\n"); + reject(error); } function onData(chunk: Buffer | string) { @@ -132,15 +153,12 @@ export function promptSecret(question: string): Promise { const ch = text[i]; if (ch === "\u0003") { - finish( - reject as (value: string | Error) => void, - Object.assign(new Error("Prompt interrupted"), { code: "SIGINT" }), - ); + rejectPrompt(Object.assign(new Error("Prompt interrupted"), { code: "SIGINT" })); return; } if (ch === "\r" || ch === "\n") { - finish(resolve as (value: string | Error) => void, answer.trim()); + resolvePrompt(answer.trim()); return; } @@ -200,9 +218,8 @@ export function prompt(question: string, opts: { secret?: boolean } = {}): Promi } const rl = readline.createInterface({ input: process.stdin, output: process.stderr }); let finished = false; - function finish(fn: (value: string | Error) => void, value: string | Error) { - if (finished) return; - finished = true; + + function cleanup() { rl.close(); if (!process.stdin.isTTY) { if (typeof process.stdin.pause === "function") { @@ -212,15 +229,29 @@ export function prompt(question: string, opts: { secret?: boolean } = {}): Promi process.stdin.unref(); } } - fn(value); } + + function resolvePrompt(value: string) { + if (finished) return; + finished = true; + cleanup(); + resolve(value); + } + + function rejectPrompt(error: Error) { + if (finished) return; + finished = true; + cleanup(); + reject(error); + } + rl.on("SIGINT", () => { const error = Object.assign(new Error("Prompt interrupted"), { code: "SIGINT" }); - finish(reject as (value: string | Error) => void, error); + rejectPrompt(error); process.kill(process.pid, "SIGINT"); }); rl.question(question, (answer) => { - finish(resolve as (value: string | Error) => void, answer.trim()); + resolvePrompt(answer.trim()); }); }); } diff --git a/src/lib/dashboard-contract.test.ts b/src/lib/dashboard-contract.test.ts index 260e5e7931c..997797f2eb8 100644 --- a/src/lib/dashboard-contract.test.ts +++ b/src/lib/dashboard-contract.test.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { describe, it, expect } from "vitest"; -import { buildChain, buildControlUiUrls } from "../../dist/lib/dashboard-contract"; +import { buildChain, buildControlUiUrls } from "../../dist/lib/dashboard-contract.js"; describe("buildChain", () => { it("returns default loopback chain with no arguments", () => { diff --git a/src/lib/dashboard-health.test.ts b/src/lib/dashboard-health.test.ts index 21eb16576af..5e9041f0a1d 100644 --- a/src/lib/dashboard-health.test.ts +++ b/src/lib/dashboard-health.test.ts @@ -2,8 +2,8 @@ // SPDX-License-Identifier: Apache-2.0 import { describe, it, expect } from "vitest"; -import { verifyDashboardChain } from "../../dist/lib/dashboard-health"; -import { buildChain } from "../../dist/lib/dashboard-contract"; +import { verifyDashboardChain } from "../../dist/lib/dashboard-health.js"; +import { buildChain } from "../../dist/lib/dashboard-contract.js"; const chain = buildChain(); diff --git a/src/lib/dashboard-recover.test.ts b/src/lib/dashboard-recover.test.ts index ac94a80a80f..b7a6432cdec 100644 --- a/src/lib/dashboard-recover.test.ts +++ b/src/lib/dashboard-recover.test.ts @@ -2,8 +2,8 @@ // SPDX-License-Identifier: Apache-2.0 import { describe, it, expect, vi } from "vitest"; -import { recoverDashboardChain } from "../../dist/lib/dashboard-recover"; -import { buildChain } from "../../dist/lib/dashboard-contract"; +import { recoverDashboardChain } from "../../dist/lib/dashboard-recover.js"; +import { buildChain } from "../../dist/lib/dashboard-contract.js"; const chain = buildChain(); diff --git a/src/lib/debug-command.test.ts b/src/lib/debug-command.test.ts index e223675008f..fce3e07876c 100644 --- a/src/lib/debug-command.test.ts +++ b/src/lib/debug-command.test.ts @@ -3,11 +3,11 @@ import { describe, expect, it, vi } from "vitest"; -import { - parseDebugArgs, - printDebugHelp, - runDebugCommand, -} from "../../dist/lib/debug-command"; +import { parseDebugArgs, printDebugHelp, runDebugCommand } from "../../dist/lib/debug-command"; + +function exitWithCode(code: number): never { + throw new Error(`exit:${code}`); +} describe("debug command", () => { it("prints help text", () => { @@ -23,9 +23,7 @@ describe("debug command", () => { getDefaultSandbox: () => "alpha", log: () => {}, error: () => {}, - exit: ((code: number) => { - throw new Error(`exit:${code}`); - }) as never, + exit: exitWithCode, }); expect(opts).toEqual({ quick: true, output: "/tmp/out.tgz", sandboxName: "alpha" }); }); @@ -37,9 +35,7 @@ describe("debug command", () => { runDebug, log: () => {}, error: () => {}, - exit: ((code: number) => { - throw new Error(`exit:${code}`); - }) as never, + exit: exitWithCode, }); expect(runDebug).toHaveBeenCalledWith({ sandboxName: "beta" }); }); @@ -51,9 +47,7 @@ describe("debug command", () => { runDebug, log: () => {}, error: () => {}, - exit: ((code: number) => { - throw new Error(`exit:${code}`); - }) as never, + exit: exitWithCode, }); expect(runDebug).toHaveBeenCalledWith({ sandboxName: "mybox" }); }); @@ -65,9 +59,7 @@ describe("debug command", () => { runDebug, log: () => {}, error: () => {}, - exit: ((code: number) => { - throw new Error(`exit:${code}`); - }) as never, + exit: exitWithCode, }); expect(runDebug).toHaveBeenCalledWith({ quick: true, sandboxName: undefined }); }); @@ -78,9 +70,7 @@ describe("debug command", () => { getDefaultSandbox: () => undefined, log: () => {}, error: () => {}, - exit: ((code: number) => { - throw new Error(`exit:${code}`); - }) as never, + exit: exitWithCode, }), ).toThrow("exit:1"); }); diff --git a/src/lib/deploy.ts b/src/lib/deploy.ts index 9efe2271765..2cddce9a5e7 100644 --- a/src/lib/deploy.ts +++ b/src/lib/deploy.ts @@ -7,6 +7,25 @@ import path from "node:path"; import { sleepSeconds } from "./wait"; +type ExecLikeValue = + | string + | number + | boolean + | null + | undefined + | string[] + | NodeJS.ProcessEnv + | object; +type ExecLikeOptions = { [key: string]: ExecLikeValue }; + +function readCommandOutput(error: object | null, key: "stdout" | "stderr"): string { + if (error === null) { + return ""; + } + const value = Reflect.get(error, key); + return typeof value === "string" ? value : String(value || ""); +} + export interface DeployCredentials { NVIDIA_API_KEY?: string | null; OPENAI_API_KEY?: string | null; @@ -42,8 +61,8 @@ export interface DeployExecutionOptions { shellQuote: (value: string) => string; run: (command: string, opts?: { ignoreError?: boolean }) => void; runInteractive: (command: string) => void; - execFileSync: (file: string, args: string[], opts?: Record) => string; - spawnSync: (file: string, args: string[], opts?: Record) => void; + execFileSync: (file: string, args: string[], opts?: ExecLikeOptions) => string; + spawnSync: (file: string, args: string[], opts?: ExecLikeOptions) => void; log: (message?: string) => void; error: (message?: string) => void; stdoutWrite: (message: string) => void; @@ -128,7 +147,7 @@ export function buildDeployEnvLines(opts: { "NEMOCLAW_POLICY_MODE", "NEMOCLAW_POLICY_PRESETS", "CHAT_UI_URL", - ] as const; + ]; for (const key of passthroughVars) { const value = env[key]; if (value) envLines.push(`${key}=${shellQuote(value)}`); @@ -159,7 +178,11 @@ export function findBrevInstanceStatus( try { const items = JSON.parse(rawJson); if (!Array.isArray(items)) return null; - return (items.find((item) => item && item.name === instanceName) as BrevInstanceStatus) || null; + const match = items.find( + (item): item is BrevInstanceStatus => + typeof item === "object" && item !== null && item.name === instanceName, + ); + return match ?? null; } catch { return null; } @@ -243,7 +266,9 @@ export async function executeDeploy(opts: DeployExecutionOptions): Promise const name = validateName(instanceName, "instance name"); const qname = shellQuote(name); const gpu = env.NEMOCLAW_GPU || "a2-highgpu-1g:nvidia-tesla-a100:1"; - const brevProvider = String(env.NEMOCLAW_BREV_PROVIDER || "gcp").trim().toLowerCase(); + const brevProvider = String(env.NEMOCLAW_BREV_PROVIDER || "gcp") + .trim() + .toLowerCase(); const skipConnect = ["1", "true"].includes( String(env.NEMOCLAW_DEPLOY_NO_CONNECT || "").toLowerCase(), ); @@ -292,9 +317,9 @@ export async function executeDeploy(opts: DeployExecutionOptions): Promise const out = execFileSync("brev", ["ls"], { encoding: "utf-8" }); exists = outputHasExactLine(out, name); } catch (caught) { - const err = caught as { stdout?: string; stderr?: string }; - if (outputHasExactLine(err.stdout, name)) exists = true; - if (outputHasExactLine(err.stderr, name)) exists = true; + const caughtObject = typeof caught === "object" && caught !== null ? caught : null; + if (outputHasExactLine(readCommandOutput(caughtObject, "stdout"), name)) exists = true; + if (outputHasExactLine(readCommandOutput(caughtObject, "stderr"), name)) exists = true; } if (!exists) { @@ -403,9 +428,7 @@ export async function executeDeploy(opts: DeployExecutionOptions): Promise fs.writeFileSync(envTmp, envLines.join("\n") + "\n", { mode: 0o600 }); try { run(`scp -q ${sshOpts} ${shellQuote(envTmp)} ${qname}:${shellQuote(`${remoteDir}/.env`)}`); - run( - `ssh -q ${sshOpts} ${qname} 'chmod 600 ${shellQuote(`${remoteDir}/.env`)}'`, - ); + run(`ssh -q ${sshOpts} ${qname} 'chmod 600 ${shellQuote(`${remoteDir}/.env`)}'`); } finally { try { fs.unlinkSync(envTmp); diff --git a/src/lib/http-probe.test.ts b/src/lib/http-probe.test.ts index d13ed8a2be1..da34b6690a0 100644 --- a/src/lib/http-probe.test.ts +++ b/src/lib/http-probe.test.ts @@ -29,9 +29,10 @@ describe("http-probe helpers", () => { }); it("summarizes JSON and text HTTP probe failures", () => { - expect(summarizeProbeError('{"error":{"message":"bad key"}}', 401)).toBe( - "HTTP 401: bad key", - ); + expect(summarizeProbeError('{"error":{"message":"bad key"}}', 401)).toBe("HTTP 401: bad key"); + expect( + summarizeProbeError('{"error":{"details":{"reason":"bad key","retry":false}}}', 401), + ).toBe('HTTP 401: {"reason":"bad key","retry":false}'); expect(summarizeProbeError(" plain text body ", 500)).toBe("HTTP 500: plain text body"); expect(summarizeProbeFailure("", 0, 28, "timeout")).toBe("curl failed (exit 28): timeout"); }); @@ -93,8 +94,10 @@ describe("runStreamingEventProbe", () => { return (_command: string, args: readonly string[]) => { const oIdx = args.indexOf("-o"); if (oIdx !== -1) { - const outputPath = args[oIdx + 1] as string; - fs.writeFileSync(outputPath, sseBody); + const outputPath = args[oIdx + 1]; + if (typeof outputPath === "string") { + fs.writeFileSync(outputPath, sseBody); + } } return { pid: 1, @@ -187,23 +190,20 @@ describe("runStreamingEventProbe", () => { }); it("fails on spawn error", () => { - const result = runStreamingEventProbe( - ["-sS", "https://example.test/v1/responses"], - { - spawnSyncImpl: () => { - const error = Object.assign(new Error("spawn ENOENT"), { code: "ENOENT" }); - return { - pid: 1, - output: [], - stdout: "", - stderr: "", - status: null, - signal: null, - error, - }; - }, + const result = runStreamingEventProbe(["-sS", "https://example.test/v1/responses"], { + spawnSyncImpl: () => { + const error = Object.assign(new Error("spawn ENOENT"), { code: "ENOENT" }); + return { + pid: 1, + output: [], + stdout: "", + stderr: "", + status: null, + signal: null, + error, + }; }, - ); + }); expect(result.ok).toBe(false); expect(result.message).toContain("Streaming probe failed"); @@ -211,26 +211,26 @@ describe("runStreamingEventProbe", () => { it("cleans up temp files after probe", () => { let outputPath = ""; - runStreamingEventProbe( - ["-sS", "--max-time", "15", "https://example.test/v1/responses"], - { - spawnSyncImpl: (_command, args) => { - const oIdx = args.indexOf("-o"); - if (oIdx !== -1) { - outputPath = args[oIdx + 1] as string; + runStreamingEventProbe(["-sS", "--max-time", "15", "https://example.test/v1/responses"], { + spawnSyncImpl: (_command, args) => { + const oIdx = args.indexOf("-o"); + if (oIdx !== -1) { + const nextArg = args[oIdx + 1]; + if (typeof nextArg === "string") { + outputPath = nextArg; fs.writeFileSync(outputPath, "event: response.output_text.delta\ndata: {}\n"); } - return { - pid: 1, - output: [], - stdout: "", - stderr: "", - status: 0, - signal: null, - }; - }, + } + return { + pid: 1, + output: [], + stdout: "", + stderr: "", + status: 0, + signal: null, + }; }, - ); + }); expect(outputPath).not.toBe(""); expect(fs.existsSync(outputPath)).toBe(false); diff --git a/src/lib/http-probe.ts b/src/lib/http-probe.ts index 983ccd9fe34..b93520db1fd 100644 --- a/src/lib/http-probe.ts +++ b/src/lib/http-probe.ts @@ -16,6 +16,12 @@ import { compactText } from "./url-utils"; export type CurlProbeResult = ProbeResult; +type ErrnoLike = Error | { code?: string | number; errno?: string | number } | null; + +function isErrnoException(error: ErrnoLike): error is NodeJS.ErrnoException { + return error !== null && typeof error === "object" && ("code" in error || "errno" in error); +} + export interface CurlProbeOptions { cwd?: string; env?: NodeJS.ProcessEnv; @@ -55,22 +61,46 @@ export function summarizeCurlFailure(curlStatus = 0, stderr = "", body = ""): st : `curl failed (exit ${curlStatus})`; } +type ProbeErrorDetail = + | string + | number + | boolean + | null + | { [key: string]: string | number | boolean | null } + | Array; + +type ProbeErrorBody = { + error?: { message?: ProbeErrorDetail; details?: ProbeErrorDetail }; + message?: ProbeErrorDetail; + detail?: ProbeErrorDetail; + details?: ProbeErrorDetail; +}; + +function formatProbeErrorDetail(detail: ProbeErrorDetail): string { + if (typeof detail === "string") { + return detail; + } + if (typeof detail === "number" || typeof detail === "boolean" || detail === null) { + return String(detail); + } + try { + return JSON.stringify(detail); + } catch { + return "[unserializable detail]"; + } +} + export function summarizeProbeError(body = "", status = 0): string { if (!body) return `HTTP ${status} with no response body`; try { - const parsed = JSON.parse(body) as { - error?: { message?: unknown; details?: unknown }; - message?: unknown; - detail?: unknown; - details?: unknown; - }; + const parsed: ProbeErrorBody = JSON.parse(body); const message = parsed?.error?.message || parsed?.error?.details || parsed?.message || parsed?.detail || parsed?.details; - if (message) return `HTTP ${status}: ${String(message)}`; + if (message !== undefined) return `HTTP ${status}: ${formatProbeErrorDetail(message)}`; } catch { /* non-JSON body — fall through to raw text */ } @@ -78,12 +108,7 @@ export function summarizeProbeError(body = "", status = 0): string { return `HTTP ${status}: ${compact.slice(0, 200)}`; } -export function summarizeProbeFailure( - body = "", - status = 0, - curlStatus = 0, - stderr = "", -): string { +export function summarizeProbeFailure(body = "", status = 0, curlStatus = 0, stderr = ""): string { if (curlStatus) { return summarizeCurlFailure(curlStatus, stderr, body); } @@ -112,11 +137,12 @@ export function runCurlProbe(argv: string[], opts: CurlProbeOptions = {}): CurlP ); const body = fs.existsSync(bodyFile) ? fs.readFileSync(bodyFile, "utf8") : ""; if (result.error) { - const spawnError = result.error as NodeJS.ErrnoException; - const rawErrorCode = spawnError.errno ?? spawnError.code; + const rawErrorCode = isErrnoException(result.error) + ? (result.error.errno ?? result.error.code) + : undefined; const errorCode = typeof rawErrorCode === "number" ? rawErrorCode : 1; const errorMessage = compactText( - `${spawnError.message || String(spawnError)} ${String(result.stderr || "")}`, + `${result.error.message || String(result.error)} ${String(result.stderr || "")}`, ); return { ok: false, @@ -134,14 +160,20 @@ export function runCurlProbe(argv: string[], opts: CurlProbeOptions = {}): CurlP curlStatus: result.status || 0, body, stderr: String(result.stderr || ""), - message: summarizeProbeFailure(body, status || 0, result.status || 0, String(result.stderr || "")), + message: summarizeProbeFailure( + body, + status || 0, + result.status || 0, + String(result.stderr || ""), + ), }; } catch (error) { const detail = error instanceof Error ? error.message : String(error); return { ok: false, httpStatus: 0, - curlStatus: typeof error === "object" && error && "status" in error ? Number(error.status) || 1 : 1, + curlStatus: + typeof error === "object" && error && "status" in error ? Number(error.status) || 1 : 1, body: "", stderr: detail, message: summarizeCurlFailure( @@ -180,19 +212,15 @@ export function runStreamingEventProbe( const args = [...argv]; const url = args.pop(); const spawnSyncImpl = opts.spawnSyncImpl ?? spawnSync; - const result = spawnSyncImpl( - "curl", - [...args, "-N", "-o", bodyFile, String(url || "")], - { - cwd: opts.cwd ?? ROOT, - encoding: "utf8", - timeout: 30_000, - env: { - ...process.env, - ...opts.env, - }, + const result = spawnSyncImpl("curl", [...args, "-N", "-o", bodyFile, String(url || "")], { + cwd: opts.cwd ?? ROOT, + encoding: "utf8", + timeout: 30_000, + env: { + ...process.env, + ...opts.env, }, - ); + }); const body = fs.existsSync(bodyFile) ? fs.readFileSync(bodyFile, "utf8") : ""; @@ -200,7 +228,7 @@ export function runStreamingEventProbe( // curl exit 28 = timeout, which is expected — we cap with --max-time // and may still have collected enough events before the timeout. const detail = result.error - ? String((result.error as Error).message || result.error) + ? String(result.error.message || result.error) : String(result.stderr || ""); return { ok: false, diff --git a/src/lib/inference-config.ts b/src/lib/inference-config.ts index c300f1982c4..402bd01cccc 100644 --- a/src/lib/inference-config.ts +++ b/src/lib/inference-config.ts @@ -42,8 +42,8 @@ export function getProviderSelectionConfig( provider: string, model?: string, ): ProviderSelectionConfig | null { - const base = { - endpointType: "custom" as const, + const base: Omit = { + endpointType: "custom", endpointUrl: INFERENCE_ROUTE_URL, ncpPartner: null, profile: DEFAULT_ROUTE_PROFILE, diff --git a/src/lib/local-inference.ts b/src/lib/local-inference.ts index 7dcfe1a6a57..e0465a94969 100644 --- a/src/lib/local-inference.ts +++ b/src/lib/local-inference.ts @@ -262,7 +262,7 @@ export function validateLocalProvider( } } -export function parseOllamaList(output: unknown): string[] { +export function parseOllamaList(output: string | null | undefined): string[] { return String(output || "") .split(/\r?\n/) .map((line) => line.trim()) @@ -272,7 +272,7 @@ export function parseOllamaList(output: unknown): string[] { .filter(Boolean); } -export function parseOllamaTags(output: unknown): string[] { +export function parseOllamaTags(output: string | null | undefined): string[] { try { const parsed = JSON.parse(String(output || "")); return Array.isArray(parsed?.models) diff --git a/src/lib/messaging-conflict.test.ts b/src/lib/messaging-conflict.test.ts index 65dbce100bf..1af20b1f911 100644 --- a/src/lib/messaging-conflict.test.ts +++ b/src/lib/messaging-conflict.test.ts @@ -10,6 +10,9 @@ import { findChannelConflicts, } from "./messaging-conflict"; +type ConflictProbe = Parameters[1]; +type ProviderExists = ConflictProbe["providerExists"]; + function makeRegistry(sandboxes: SandboxEntry[]) { const store = new Map(sandboxes.map((s) => [s.name, { ...s }])); return { @@ -90,10 +93,10 @@ describe("findAllOverlaps", () => { describe("backfillMessagingChannels", () => { it("fills in missing messagingChannels by probing OpenShell", () => { const registry = makeRegistry([{ name: "alice" }]); - const probe = { - providerExists: vi.fn((name: string) => + const probe: ConflictProbe = { + providerExists: vi.fn((name) => name === "alice-telegram-bridge" ? "present" : "absent", - ) as (name: string) => "present" | "absent" | "error", + ), }; backfillMessagingChannels(registry, probe); expect(registry.updateSandbox).toHaveBeenCalledWith("alice", { @@ -105,11 +108,9 @@ describe("backfillMessagingChannels", () => { }); it("leaves entries with existing messagingChannels alone", () => { - const registry = makeRegistry([ - { name: "alice", messagingChannels: ["telegram"] }, - ]); - const probe = { - providerExists: vi.fn(() => "present") as (name: string) => "present" | "absent" | "error", + const registry = makeRegistry([{ name: "alice", messagingChannels: ["telegram"] }]); + const probe: ConflictProbe = { + providerExists: vi.fn(() => "present"), }; backfillMessagingChannels(registry, probe); expect(registry.updateSandbox).not.toHaveBeenCalled(); @@ -118,8 +119,8 @@ describe("backfillMessagingChannels", () => { it("writes an empty array when all probes return absent", () => { const registry = makeRegistry([{ name: "alice" }]); - const probe = { - providerExists: vi.fn(() => "absent") as (name: string) => "present" | "absent" | "error", + const probe: ConflictProbe = { + providerExists: vi.fn(() => "absent"), }; backfillMessagingChannels(registry, probe); expect(registry.updateSandbox).toHaveBeenCalledWith("alice", { messagingChannels: [] }); @@ -130,11 +131,11 @@ describe("backfillMessagingChannels", () => { // be collapsed into "provider not attached" and persisted, because that // would prevent all future backfill retries and hide real overlaps. const registry = makeRegistry([{ name: "alice" }]); - const probe = { - providerExists: vi.fn((name: string) => { + const probe: ConflictProbe = { + providerExists: vi.fn((name) => { if (name.endsWith("-telegram-bridge")) return "error"; return name.endsWith("-discord-bridge") ? "present" : "absent"; - }) as (name: string) => "present" | "absent" | "error", + }), }; backfillMessagingChannels(registry, probe); expect(registry.updateSandbox).not.toHaveBeenCalled(); @@ -142,10 +143,10 @@ describe("backfillMessagingChannels", () => { it("also treats a thrown probe as error (defensive; callers should return 'error' instead)", () => { const registry = makeRegistry([{ name: "alice" }]); - const probe = { - providerExists: vi.fn(() => { + const probe: ConflictProbe = { + providerExists: vi.fn(() => { throw new Error("unexpected"); - }) as (name: string) => "present" | "absent" | "error", + }), }; backfillMessagingChannels(registry, probe); expect(registry.updateSandbox).not.toHaveBeenCalled(); @@ -154,14 +155,14 @@ describe("backfillMessagingChannels", () => { it("re-attempts backfill on a subsequent call after a prior error", () => { const registry = makeRegistry([{ name: "alice" }]); let firstPass = true; - const probe = { - providerExists: vi.fn((name: string) => { + const probe: ConflictProbe = { + providerExists: vi.fn((name) => { if (name.endsWith("-telegram-bridge") && firstPass) { firstPass = false; return "error"; } return name === "alice-telegram-bridge" ? "present" : "absent"; - }) as (name: string) => "present" | "absent" | "error", + }), }; backfillMessagingChannels(registry, probe); expect(registry.updateSandbox).not.toHaveBeenCalled(); diff --git a/src/lib/onboard-command.test.ts b/src/lib/onboard-command.test.ts index c6889d322b2..a16b948b362 100644 --- a/src/lib/onboard-command.test.ts +++ b/src/lib/onboard-command.test.ts @@ -9,6 +9,14 @@ import { runOnboardCommand, } from "./onboard-command"; +function exitWithCode(code: number): never { + throw new Error(String(code)); +} + +function exitWithPrefixedCode(code: number): never { + throw new Error(`exit:${code}`); +} + describe("onboard command", () => { it("parses onboard flags", () => { expect( @@ -19,9 +27,7 @@ describe("onboard command", () => { { env: {}, error: () => {}, - exit: ((code: number) => { - throw new Error(String(code)); - }) as never, + exit: exitWithCode, }, ), ).toEqual({ @@ -44,9 +50,7 @@ describe("onboard command", () => { { env: { NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE: "1" }, error: () => {}, - exit: ((code: number) => { - throw new Error(String(code)); - }) as never, + exit: exitWithCode, }, ), ).toEqual({ @@ -69,9 +73,7 @@ describe("onboard command", () => { env: {}, runOnboard, error: () => {}, - exit: ((code: number) => { - throw new Error(String(code)); - }) as never, + exit: exitWithCode, }); expect(runOnboard).toHaveBeenCalledWith({ nonInteractive: false, @@ -95,9 +97,7 @@ describe("onboard command", () => { runOnboard, log: (message = "") => lines.push(message), error: () => {}, - exit: ((code: number) => { - throw new Error(String(code)); - }) as never, + exit: exitWithCode, }); expect(runOnboard).not.toHaveBeenCalled(); expect(lines.join("\n")).toContain("Usage: nemoclaw onboard"); @@ -115,9 +115,7 @@ describe("onboard command", () => { { env: {}, error: () => {}, - exit: ((code: number) => { - throw new Error(String(code)); - }) as never, + exit: exitWithCode, }, ), ).toEqual({ @@ -140,9 +138,7 @@ describe("onboard command", () => { { env: {}, error: () => {}, - exit: ((code: number) => { - throw new Error(`exit:${code}`); - }) as never, + exit: exitWithPrefixedCode, }, ), ).toThrow("exit:1"); @@ -158,9 +154,7 @@ describe("onboard command", () => { { env: {}, error: (message = "") => errors.push(message), - exit: ((code: number) => { - throw new Error(`exit:${code}`); - }) as never, + exit: exitWithPrefixedCode, }, ), ).toThrow("exit:1"); @@ -178,9 +172,7 @@ describe("onboard command", () => { env: {}, listAgents: () => ["openclaw", "hermes"], error: () => {}, - exit: ((code: number) => { - throw new Error(String(code)); - }) as never, + exit: exitWithCode, }, ), ).toEqual({ @@ -205,9 +197,7 @@ describe("onboard command", () => { env: {}, listAgents: () => ["openclaw", "hermes"], error: (message = "") => errors.push(message), - exit: ((code: number) => { - throw new Error(`exit:${code}`); - }) as never, + exit: exitWithPrefixedCode, }, ), ).toThrow("exit:1"); @@ -227,9 +217,7 @@ describe("onboard command", () => { runOnboard, log: (message = "") => lines.push(message), error: () => {}, - exit: ((code: number) => { - throw new Error(String(code)); - }) as never, + exit: exitWithCode, }); expect(lines.join("\n")).toContain("setup-spark` is deprecated"); expect(lines.join("\n")).toContain("Use `nemoclaw onboard` instead"); @@ -257,9 +245,7 @@ describe("onboard command", () => { runOnboard, log: (message = "") => lines.push(message), error: () => {}, - exit: ((code: number) => { - throw new Error(String(code)); - }) as never, + exit: exitWithCode, }); expect(lines.join("\n")).toContain("`nemoclaw setup` is deprecated"); expect(lines.join("\n")).toContain("Use `nemoclaw onboard` instead"); diff --git a/src/lib/onboard-session.test.ts b/src/lib/onboard-session.test.ts index 384713de7ef..a088457404b 100644 --- a/src/lib/onboard-session.test.ts +++ b/src/lib/onboard-session.test.ts @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; @@ -10,10 +10,32 @@ import { createRequire } from "node:module"; const require = createRequire(import.meta.url); const distPath = require.resolve("../../dist/lib/onboard-session"); const originalHome = process.env.HOME; -// eslint-disable-next-line @typescript-eslint/no-explicit-any -let session: any; +type OnboardSessionModule = typeof import("../../dist/lib/onboard-session"); +type LoadedSession = NonNullable>; +type DebugSummary = NonNullable>; +let session: OnboardSessionModule; let tmpDir: string; +function requireLoadedSession( + loaded: ReturnType, +): LoadedSession { + expect(loaded).not.toBeNull(); + if (!loaded) { + throw new Error("Expected onboard session to be present"); + } + return loaded; +} + +function requireDebugSummary( + summary: ReturnType, +): DebugSummary { + expect(summary).not.toBeNull(); + if (!summary) { + throw new Error("Expected debug session summary to be present"); + } + return summary; +} + beforeEach(() => { // Recreate tmpDir per test so lock artifacts (and any other on-disk state) // from a previous test cannot leak into this one. Without this, malformed @@ -61,38 +83,46 @@ describe("onboard session", () => { "https://alice:secret@example.com/v1/models?token=abc123&sig=def456&X-Amz-Signature=ghi789&keep=yes#token=frag", }); - const loaded = session.loadSession(); + const loaded = requireLoadedSession(session.loadSession()); expect(loaded.endpointUrl).toBe( "https://example.com/v1/models?token=%3CREDACTED%3E&sig=%3CREDACTED%3E&X-Amz-Signature=%3CREDACTED%3E&keep=yes", ); - expect(session.summarizeForDebug().endpointUrl).toBe(loaded.endpointUrl); + const summary = requireDebugSummary(session.summarizeForDebug()); + expect(summary.endpointUrl).toBe(loaded.endpointUrl); }); it("marks steps started, completed, and failed", () => { session.saveSession(session.createSession()); session.markStepStarted("gateway"); - let loaded = session.loadSession(); + let loaded = requireLoadedSession(session.loadSession()); expect(loaded.steps.gateway.status).toBe("in_progress"); expect(loaded.lastStepStarted).toBe("gateway"); expect(loaded.steps.gateway.completedAt).toBeNull(); session.markStepComplete("gateway", { sandboxName: "my-assistant" }); - loaded = session.loadSession(); + loaded = requireLoadedSession(session.loadSession()); expect(loaded.steps.gateway.status).toBe("complete"); expect(loaded.sandboxName).toBe("my-assistant"); expect(loaded.steps.gateway.completedAt).toBeTruthy(); session.markStepFailed("sandbox", "Sandbox creation failed"); - loaded = session.loadSession(); + loaded = requireLoadedSession(session.loadSession()); expect(loaded.steps.sandbox.status).toBe("failed"); expect(loaded.steps.sandbox.completedAt).toBeNull(); + expect(loaded.failure).not.toBeNull(); + if (!loaded.failure) { + throw new Error("Expected failure metadata after markStepFailed()"); + } expect(loaded.failure.step).toBe("sandbox"); expect(loaded.failure.message).toMatch(/Sandbox creation failed/); }); it("persists safe provider metadata without persisting secrets", () => { session.saveSession(session.createSession()); - session.markStepComplete("provider_selection", { + const unsafeProviderUpdate: Parameters[1] & { + apiKey: string; + metadata: { gatewayName: string; token: string }; + } = { provider: "nvidia-nim", model: "nvidia/test-model", sandboxName: "my-assistant", @@ -106,9 +136,10 @@ describe("onboard session", () => { gatewayName: "nemoclaw", token: "secret", }, - }); + }; + session.markStepComplete("provider_selection", unsafeProviderUpdate); - const loaded = session.loadSession(); + const loaded = requireLoadedSession(session.loadSession()); expect(loaded.provider).toBe("nvidia-nim"); expect(loaded.model).toBe("nvidia/test-model"); expect(loaded.sandboxName).toBe("my-assistant"); @@ -117,9 +148,9 @@ describe("onboard session", () => { expect(loaded.preferredInferenceApi).toBe("openai-completions"); expect(loaded.nimContainer).toBe("nim-123"); expect(loaded.policyPresets).toEqual(["pypi", "npm"]); - expect(loaded.apiKey).toBeUndefined(); + expect("apiKey" in loaded).toBe(false); expect(loaded.metadata.gatewayName).toBe("nemoclaw"); - expect(loaded.metadata.token).toBeUndefined(); + expect("token" in loaded.metadata).toBe(false); }); it("persists messagingChannels across save/load roundtrips", () => { @@ -127,16 +158,22 @@ describe("onboard session", () => { created.messagingChannels = ["telegram", "slack"]; session.saveSession(created); - const loaded = session.loadSession(); + const loaded = requireLoadedSession(session.loadSession()); expect(loaded.messagingChannels).toEqual(["telegram", "slack"]); }); it("filters non-string entries out of persisted messagingChannels", () => { const created = session.createSession(); - created.messagingChannels = ["telegram", 42, null, "discord"]; - session.saveSession(created); + fs.mkdirSync(path.dirname(session.SESSION_FILE), { recursive: true }); + fs.writeFileSync( + session.SESSION_FILE, + JSON.stringify({ + ...created, + messagingChannels: ["telegram", 42, null, "discord"], + }), + ); - const loaded = session.loadSession(); + const loaded = requireLoadedSession(session.loadSession()); expect(loaded.messagingChannels).toEqual(["telegram", "discord"]); }); @@ -151,25 +188,30 @@ describe("onboard session", () => { webSearchConfig: { fetchEnabled: true }, }); - let loaded = session.loadSession(); + let loaded = requireLoadedSession(session.loadSession()); expect(loaded.webSearchConfig).toEqual({ fetchEnabled: true }); session.completeSession({ webSearchConfig: null }); - loaded = session.loadSession(); + loaded = requireLoadedSession(session.loadSession()); expect(loaded.webSearchConfig).toBeNull(); }); it("does not clear existing metadata when updates omit whitelisted metadata fields", () => { - session.saveSession(session.createSession({ metadata: { gatewayName: "nemoclaw" } })); - session.markStepComplete("provider_selection", { + session.saveSession( + session.createSession({ metadata: { gatewayName: "nemoclaw", fromDockerfile: null } }), + ); + const unsafeMetadataUpdate: Parameters[1] & { + metadata: { token: string }; + } = { metadata: { token: "should-not-persist", }, - }); + }; + session.markStepComplete("provider_selection", unsafeMetadataUpdate); - const loaded = session.loadSession(); + const loaded = requireLoadedSession(session.loadSession()); expect(loaded.metadata.gatewayName).toBe("nemoclaw"); - expect(loaded.metadata.token).toBeUndefined(); + expect("token" in loaded.metadata).toBe(false); }); it("drops non-string gatewayName during normalization", () => { @@ -178,9 +220,8 @@ describe("onboard session", () => { session.SESSION_FILE, JSON.stringify({ version: 1, metadata: { gatewayName: 123 } }), ); - const loaded = session.loadSession(); - expect(loaded).not.toBeNull(); - expect(loaded!.metadata.gatewayName).toBe("nemoclaw"); + const loaded = requireLoadedSession(session.loadSession()); + expect(loaded.metadata.gatewayName).toBe("nemoclaw"); }); it("returns null for corrupt session data", () => { @@ -251,8 +292,7 @@ describe("onboard session", () => { // via isProcessAlive, never reaching unlinkIfInodeMatches. let statCallCount = 0; const originalStatSync = fs.statSync; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (fs as any).statSync = function (...args: unknown[]) { + const statSpy = vi.spyOn(fs, "statSync").mockImplementation((...args) => { statCallCount += 1; // Just before stat #2 (inside unlinkIfInodeMatches), simulate // the race: a concurrent fast process unlinks the stale lock @@ -273,8 +313,8 @@ describe("onboard session", () => { ); fs.renameSync(tmpClaim, session.LOCK_FILE); } - return (originalStatSync as unknown as (...a: unknown[]) => unknown).apply(fs, args); - }; + return originalStatSync(...args); + }); try { // The acquire call will see EEXIST (stale lock present), stat it, @@ -298,8 +338,7 @@ describe("onboard session", () => { expect(result.acquired).toBe(false); expect(result.holderPid).toBe(process.ppid); } finally { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (fs as any).statSync = originalStatSync; + statSpy.mockRestore(); } }); @@ -328,13 +367,17 @@ describe("onboard session", () => { "provider auth failed with NVIDIA_API_KEY=nvapi-secret Bearer topsecret sk-secret-value-that-is-long-enough ghp_1234567890123456789012345", ); - const loaded = session.loadSession(); + const loaded = requireLoadedSession(session.loadSession()); expect(loaded.steps.inference.error).toContain("NVIDIA_API_KEY="); expect(loaded.steps.inference.error).toContain("Bearer "); expect(loaded.steps.inference.error).not.toContain("nvapi-secret"); expect(loaded.steps.inference.error).not.toContain("topsecret"); expect(loaded.steps.inference.error).not.toContain("sk-secret-value-that-is-long-enough"); expect(loaded.steps.inference.error).not.toContain("ghp_1234567890123456789012345"); + expect(loaded.failure).not.toBeNull(); + if (!loaded.failure) { + throw new Error("Expected failure metadata after markStepFailed()"); + } expect(loaded.failure.message).toBe(loaded.steps.inference.error); }); @@ -342,7 +385,7 @@ describe("onboard session", () => { const created = session.createSession(); expect(created.messagingChannels).toBeNull(); const saved = session.saveSession(created); - const loaded = session.loadSession(); + const loaded = requireLoadedSession(session.loadSession()); expect(saved.messagingChannels).toBeNull(); expect(loaded.messagingChannels).toBeNull(); }); @@ -351,7 +394,7 @@ describe("onboard session", () => { const created = session.createSession({ messagingChannels: ["telegram"] }); expect(created.messagingChannels).toEqual(["telegram"]); const saved = session.saveSession(created); - const loaded = session.loadSession(); + const loaded = requireLoadedSession(session.loadSession()); expect(saved.messagingChannels).toEqual(["telegram"]); expect(loaded.messagingChannels).toEqual(["telegram"]); }); @@ -362,7 +405,7 @@ describe("onboard session", () => { messagingChannels: ["slack", "discord"], }); - const loaded = session.loadSession(); + const loaded = requireLoadedSession(session.loadSession()); expect(loaded.messagingChannels).toEqual(["slack", "discord"]); }); @@ -372,12 +415,22 @@ describe("onboard session", () => { expect(created.provider).toBeNull(); }); + it("filters non-string array entries in createSession overrides", () => { + const created = session.createSession({ + policyPresets: ["pypi", 7, null, "npm"] as unknown as string[], + messagingChannels: ["telegram", 42, null, "discord"] as unknown as string[], + }); + + expect(created.policyPresets).toEqual(["pypi", "npm"]); + expect(created.messagingChannels).toEqual(["telegram", "discord"]); + }); + it("summarizes the session for debug output", () => { session.saveSession(session.createSession({ sandboxName: "my-assistant" })); session.markStepStarted("preflight"); session.markStepComplete("preflight"); session.completeSession(); - const summary = session.summarizeForDebug(); + const summary = requireDebugSummary(session.summarizeForDebug()); expect(summary.sandboxName).toBe("my-assistant"); expect(summary.steps.preflight.status).toBe("complete"); @@ -389,8 +442,12 @@ describe("onboard session", () => { it("keeps debug summaries redacted when failures were sanitized", () => { session.saveSession(session.createSession({ sandboxName: "my-assistant" })); session.markStepFailed("provider_selection", "Bearer abcdefghijklmnopqrstuvwxyz"); - const summary = session.summarizeForDebug(); + const summary = requireDebugSummary(session.summarizeForDebug()); + expect(summary.failure).not.toBeNull(); + if (!summary.failure) { + throw new Error("Expected failure metadata in debug summary"); + } expect(summary.failure.message).toContain("Bearer "); expect(summary.failure.message).not.toContain("abcdefghijklmnopqrstuvwxyz"); }); @@ -404,7 +461,11 @@ describe("onboard session", () => { }, }); - const summary = session.summarizeForDebug(rawSession); + const summary = requireDebugSummary(session.summarizeForDebug(rawSession)); + expect(summary.failure).not.toBeNull(); + if (!summary.failure) { + throw new Error("Expected failure metadata in debug summary"); + } expect(summary.failure.message).toContain("Bearer "); expect(summary.failure.message).not.toContain("abcdefghijklmnopqrstuvwxyz"); }); diff --git a/src/lib/onboard-session.ts b/src/lib/onboard-session.ts index fa8caa44b75..1acb0427e90 100644 --- a/src/lib/onboard-session.ts +++ b/src/lib/onboard-session.ts @@ -17,11 +17,20 @@ export const SESSION_VERSION = 1; export const SESSION_DIR = path.join(process.env.HOME || "/tmp", ".nemoclaw"); export const SESSION_FILE = path.join(SESSION_DIR, "onboard-session.json"); export const LOCK_FILE = path.join(SESSION_DIR, "onboard.lock"); -const STEP_STATES = ["pending", "in_progress", "complete", "failed", "skipped"] as const; -const VALID_STEP_STATES = new Set(STEP_STATES); -type UnknownRecord = { [key: string]: unknown }; -type StepStatus = (typeof STEP_STATES)[number]; +type SessionJsonPrimitive = string | number | boolean | null; +type SessionJsonValue = SessionJsonPrimitive | UnknownRecord | SessionJsonValue[]; +type UnknownRecord = { [key: string]: SessionJsonValue }; +type StepStatus = "pending" | "in_progress" | "complete" | "failed" | "skipped"; + +const STEP_STATES: readonly StepStatus[] = [ + "pending", + "in_progress", + "complete", + "failed", + "skipped", +]; +const VALID_STEP_STATES: ReadonlySet = new Set(STEP_STATES); // ── Types ──────────────────────────────────────────────────────── @@ -151,33 +160,35 @@ export function isObject(value: unknown): value is UnknownRecord { return typeof value === "object" && value !== null && !Array.isArray(value); } -function isErrnoException(error: unknown): error is NodeJS.ErrnoException { - return error instanceof Error && "code" in error; +type ErrnoLike = Error | { code?: string | number } | null; + +function isErrnoException(error: ErrnoLike): error is NodeJS.ErrnoException { + return error !== null && error instanceof Error && "code" in error; } -function readString(value: unknown): string | null { +function readString(value: SessionJsonValue | undefined): string | null { return typeof value === "string" ? value : null; } -function readStringArray(value: unknown): string[] | null { +function readStringArray(value: SessionJsonValue | undefined): string[] | null { if (!Array.isArray(value)) return null; return value.filter((entry): entry is string => typeof entry === "string"); } -function readStepStatus(value: unknown): StepStatus | null { - if (value === "pending") return value; - if (value === "in_progress") return value; - if (value === "complete") return value; - if (value === "failed") return value; - if (value === "skipped") return value; - return null; +function isStepStatus(value: string): value is StepStatus { + return VALID_STEP_STATES.has(value); +} + +function readStepStatus(value: SessionJsonValue | undefined): StepStatus | null { + if (typeof value !== "string") return null; + return isStepStatus(value) ? value : null; } -function parseWebSearchConfig(value: unknown): WebSearchConfig | null { +function parseWebSearchConfig(value: SessionJsonValue | undefined): WebSearchConfig | null { return isObject(value) && value.fetchEnabled === true ? { fetchEnabled: true } : null; } -function parseSessionMetadata(value: unknown): SessionMetadata | undefined { +function parseSessionMetadata(value: SessionJsonValue | undefined): SessionMetadata | undefined { if (!isObject(value)) return undefined; return { gatewayName: readString(value.gatewayName) ?? "nemoclaw", @@ -185,7 +196,7 @@ function parseSessionMetadata(value: unknown): SessionMetadata | undefined { }; } -function parseStepState(value: unknown): StepState | null { +function parseStepState(value: SessionJsonValue | undefined): StepState | null { if (!isObject(value)) return null; const status = readStepStatus(value.status); if (!status) return null; @@ -197,7 +208,7 @@ function parseStepState(value: unknown): StepState | null { }; } -function parseLockInfo(value: unknown): LockInfo | null { +function parseLockInfo(value: SessionJsonValue | undefined): LockInfo | null { if (!isObject(value) || typeof value.pid !== "number") return null; return { pid: value.pid, @@ -210,7 +221,10 @@ function parseLockInfo(value: unknown): LockInfo | null { export { redactSensitiveText, redactUrl }; export function sanitizeFailure( - input: { step?: unknown; message?: unknown; recordedAt?: unknown } | null | undefined, + input: + | { step?: SessionJsonValue; message?: SessionJsonValue; recordedAt?: SessionJsonValue } + | null + | undefined, ): SessionFailure | null { if (!input) return null; const step = readString(input.step); @@ -219,7 +233,7 @@ export function sanitizeFailure( return step || message ? { step, message, recordedAt } : null; } -export function validateStep(step: unknown): boolean { +export function validateStep(step: SessionJsonValue | undefined): boolean { return parseStepState(step) !== null; } @@ -246,7 +260,8 @@ export function createSession(overrides: Partial = {}): Session { credentialEnv: overrides.credentialEnv ?? null, preferredInferenceApi: overrides.preferredInferenceApi ?? null, nimContainer: overrides.nimContainer ?? null, - webSearchConfig: parseWebSearchConfig(overrides.webSearchConfig), + webSearchConfig: + overrides.webSearchConfig?.fetchEnabled === true ? { fetchEnabled: true } : null, policyPresets: readStringArray(overrides.policyPresets), messagingChannels: readStringArray(overrides.messagingChannels), metadata: { @@ -261,7 +276,7 @@ export function createSession(overrides: Partial = {}): Session { } // eslint-disable-next-line complexity -export function normalizeSession(data: unknown): Session | null { +export function normalizeSession(data: Session | SessionJsonValue | undefined): Session | null { if (!isObject(data) || data.version !== SESSION_VERSION) return null; const normalized = createSession({ @@ -350,8 +365,8 @@ function isProcessAlive(pid: number): boolean { try { process.kill(pid, 0); return true; - } catch (error: unknown) { - return isErrnoException(error) && error.code === "EPERM"; + } catch (error) { + return error instanceof Error && isErrnoException(error) && error.code === "EPERM"; } } @@ -390,8 +405,8 @@ export function acquireOnboardLock(command: string | null = null): LockResult { // releaseOnboardLock() can later confirm the on-disk path still // resolves to the same file we created (fstat ino vs stat ino). fd = fs.openSync(LOCK_FILE, "wx", 0o600); - } catch (error: unknown) { - if (!isErrnoException(error) || error.code !== "EEXIST") { + } catch (error) { + if (!(error instanceof Error && isErrnoException(error)) || error.code !== "EEXIST") { throw error; } @@ -407,8 +422,12 @@ export function acquireOnboardLock(command: string | null = null): LockResult { const stat = fs.statSync(LOCK_FILE, { bigint: true }); staleInode = stat.ino; existing = parseLockFile(fs.readFileSync(LOCK_FILE, "utf8")); - } catch (readError: unknown) { - if (isErrnoException(readError) && readError.code === "ENOENT") { + } catch (readError) { + if ( + readError instanceof Error && + isErrnoException(readError) && + readError.code === "ENOENT" + ) { continue; } throw readError; @@ -485,16 +504,22 @@ function unlinkIfInodeMatches(filePath: string, expectedInode: bigint | null): v // Someone else replaced the file. Leave it alone. return; } - } catch (statError: unknown) { - if (isErrnoException(statError) && statError.code === "ENOENT") { + } catch (statError) { + if (statError instanceof Error && isErrnoException(statError) && statError.code === "ENOENT") { return; } throw statError; } try { fs.unlinkSync(filePath); - } catch (unlinkError: unknown) { - if (!isErrnoException(unlinkError) || unlinkError.code !== "ENOENT") { + } catch (unlinkError) { + if ( + !( + unlinkError instanceof Error && + isErrnoException(unlinkError) && + unlinkError.code === "ENOENT" + ) + ) { throw unlinkError; } } @@ -514,16 +539,22 @@ export function releaseOnboardLock(): void { try { const pathStat = fs.statSync(LOCK_FILE, { bigint: true }); pathInode = pathStat.ino; - } catch (error: unknown) { - if (!isErrnoException(error) || error.code !== "ENOENT") { + } catch (error) { + if (!(error instanceof Error && isErrnoException(error) && error.code === "ENOENT")) { // Unexpected — fall through to closing the fd. } } if (pathInode !== null && pathInode === fdStat.ino) { try { fs.unlinkSync(LOCK_FILE); - } catch (unlinkError: unknown) { - if (!isErrnoException(unlinkError) || unlinkError.code !== "ENOENT") { + } catch (unlinkError) { + if ( + !( + unlinkError instanceof Error && + isErrnoException(unlinkError) && + unlinkError.code === "ENOENT" + ) + ) { // Best effort — surfacing this would mask the real error. } } @@ -550,8 +581,8 @@ export function releaseOnboardLock(): void { let existing: LockInfo | null = null; try { existing = parseLockFile(fs.readFileSync(LOCK_FILE, "utf8")); - } catch (error: unknown) { - if (isErrnoException(error) && error.code === "ENOENT") return; + } catch (error) { + if (error instanceof Error && isErrnoException(error) && error.code === "ENOENT") return; throw error; } if (!existing) return; diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 357c4372b54..056c8c10d1b 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -1,4 +1,3 @@ -// @ts-nocheck // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 // @@ -14,7 +13,7 @@ const { execFileSync, spawn, spawnSync } = require("child_process"); const pRetry = require("p-retry"); /** Parse a numeric env var, returning `fallback` when unset or non-finite. */ -function envInt(name, fallback) { +function envInt(name: string, fallback: number): number { const raw = process.env[name]; if (raw === undefined || raw === "") return fallback; const n = Number(raw); @@ -26,16 +25,32 @@ const LOCAL_INFERENCE_TIMEOUT_SECS = envInt("NEMOCLAW_LOCAL_INFERENCE_TIMEOUT", /** Strip ANSI escape sequences before printing process output to the terminal. * Covers CSI (color, erase, cursor), OSC, and C1 two-byte escapes per ECMA-48. */ const ANSI_RE = /\x1B(?:\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1B\\)|[@-_])/g; -const { - ROOT, - SCRIPTS, - redact, - run, - runCapture, - runFile, - shellQuote, - validateName, -} = require("./runner"); +const runner: typeof import("./runner") = require("./runner"); +const { ROOT, SCRIPTS, redact, run, runCapture, runFile, shellQuote, validateName } = runner; + +type RunnerOptions = { + env?: NodeJS.ProcessEnv; + stdio?: import("node:child_process").StdioOptions; + ignoreError?: boolean; + suppressOutput?: boolean; + timeout?: number; + openshellBinary?: string; +}; + +function isErrnoException(error: object | null): error is NodeJS.ErrnoException { + return error !== null && "code" in error; +} + +function parseJson(text: string): T { + return JSON.parse(text); +} + +function requireValue(value: T | null | undefined, message: string): T { + if (value == null) { + throw new Error(message); + } + return value; +} const { stageOptimizedSandboxBuildContext } = require("./sandbox-build-context"); const { buildSubprocessEnv } = require("./subprocess-env"); const { @@ -45,6 +60,7 @@ const { OLLAMA_PORT, OLLAMA_PROXY_PORT, } = require("./ports"); +const localInference: typeof import("./local-inference") = require("./local-inference"); const { getDefaultOllamaModel, getBootstrapOllamaModelOptions, @@ -55,36 +71,29 @@ const { validateOllamaPortConfiguration, validateOllamaModel, validateLocalProvider, -} = require("./local-inference"); -const { - DEFAULT_CLOUD_MODEL, - getProviderSelectionConfig, - parseGatewayInference, -} = require("./inference-config"); +} = localInference; +const inferenceConfig: typeof import("./inference-config") = require("./inference-config"); +const { DEFAULT_CLOUD_MODEL, getProviderSelectionConfig, parseGatewayInference } = inferenceConfig; // Providers that run on the host and need the local-inference policy preset. // Shared constant so getSuggestedPolicyPresets() and setupPoliciesWithSelection() // stay in sync. -const LOCAL_INFERENCE_PROVIDERS = ["ollama-local", "vllm-local"]; -const { - sleepSeconds, -} = require("./wait"); -const { inferContainerRuntime, isWsl, shouldPatchCoredns } = require("./platform"); +const LOCAL_INFERENCE_PROVIDERS: string[] = ["ollama-local", "vllm-local"]; +const { sleepSeconds } = require("./wait"); +const platformUtils: typeof import("./platform") = require("./platform"); +const { inferContainerRuntime, isWsl, shouldPatchCoredns } = platformUtils; const { resolveOpenshell } = require("./resolve-openshell"); -const { - prompt, - ensureApiKey, - getCredential, - normalizeCredentialValue, - saveCredential, -} = require("./credentials"); -const registry = require("./registry"); -const nim = require("./nim"); -const onboardSession = require("./onboard-session"); -const policies = require("./policies"); +const credentials: typeof import("./credentials") = require("./credentials"); +const { prompt, ensureApiKey, getCredential, normalizeCredentialValue, saveCredential } = + credentials; +const registry: typeof import("./registry") = require("./registry"); +const nim: typeof import("./nim") = require("./nim"); +const onboardSession: typeof import("./onboard-session") = require("./onboard-session"); +const policies: typeof import("./policies") = require("./policies"); const shields = require("./shields"); -const tiers = require("./tiers"); +const tiers: typeof import("./tiers") = require("./tiers"); const { ensureUsageNoticeConsent } = require("./usage-notice"); +const preflightUtils: typeof import("./preflight") = require("./preflight"); const { assessHost, checkPortAvailable, @@ -93,24 +102,43 @@ const { getMemoryInfo, planHostRemediation, probeContainerDns, -} = require("./preflight"); +} = preflightUtils; const agentOnboard = require("./agent-onboard"); const agentDefs = require("./agent-defs"); -const gatewayState = require("./gateway-state"); -const sandboxState = require("./sandbox-state"); -const validation = require("./validation"); -const urlUtils = require("./url-utils"); +const gatewayState: typeof import("./gateway-state") = require("./gateway-state"); +const sandboxState: typeof import("./sandbox-state") = require("./sandbox-state"); +const validation: typeof import("./validation") = require("./validation"); +const urlUtils: typeof import("./url-utils") = require("./url-utils"); const buildContext = require("./build-context"); -const dashboardContract = require("./dashboard-contract"); -const httpProbe = require("./http-probe"); -const modelPrompts = require("./model-prompts"); -const providerModels = require("./provider-models"); -const sandboxCreateStream = require("./sandbox-create-stream"); -const validationRecovery = require("./validation-recovery"); -const webSearch = require("./web-search"); +const dashboardContract: typeof import("./dashboard-contract") = require("./dashboard-contract"); +const httpProbe: typeof import("./http-probe") = require("./http-probe"); +const modelPrompts: typeof import("./model-prompts") = require("./model-prompts"); +const providerModels: typeof import("./provider-models") = require("./provider-models"); +const sandboxCreateStream: typeof import("./sandbox-create-stream") = require("./sandbox-create-stream"); +const validationRecovery: typeof import("./validation-recovery") = require("./validation-recovery"); +const webSearch: typeof import("./web-search") = require("./web-search"); import { listChannels } from "./sandbox-channels"; +import type { AgentDefinition } from "./agent-defs"; +import type { GatewayInference, ProviderSelectionConfig } from "./inference-config"; +import type { GpuInfo, ValidationResult } from "./local-inference"; +import type { ContainerRuntime } from "./platform"; +import type { SandboxEntry } from "./registry"; +import type { Session, SessionUpdates } from "./onboard-session"; +import type { CurlProbeResult } from "./http-probe"; +import type { ProbeRecovery } from "./validation-recovery"; +import type { SandboxCreateFailure, ValidationClassification } from "./validation"; +import type { TierDefinition, TierPreset } from "./tiers"; +import type { StreamSandboxCreateResult } from "./sandbox-create-stream"; +import type { WebSearchConfig } from "./web-search"; +import type { + ModelCatalogFetchResult, + ModelValidationResult, + ProbeResult, + ValidationFailureLike, +} from "./onboard-types"; +import type { BackupResult } from "./sandbox-state"; /** * Create a temp file inside a directory with a cryptographically random name. @@ -118,7 +146,7 @@ import { listChannels } from "./sandbox-channels"; * could be exploited via symlink attacks on shared /tmp. * Ref: https://github.com/NVIDIA/NemoClaw/issues/1093 */ -function secureTempFile(prefix, ext = "") { +function secureTempFile(prefix: string, ext = ""): string { const dir = fs.mkdtempSync(path.join(os.tmpdir(), `${prefix}-`)); return path.join(dir, `${prefix}${ext}`); } @@ -127,7 +155,7 @@ function secureTempFile(prefix, ext = "") { * Safely remove a mkdtemp-created directory. Guards against accidentally * deleting the system temp root if a caller passes os.tmpdir() itself. */ -function cleanupTempDir(filePath, expectedPrefix) { +function cleanupTempDir(filePath: string, expectedPrefix: string): void { const parentDir = path.dirname(filePath); if (parentDir !== os.tmpdir() && path.basename(parentDir).startsWith(`${expectedPrefix}-`)) { fs.rmSync(parentDir, { recursive: true, force: true }); @@ -138,7 +166,7 @@ const EXPERIMENTAL = process.env.NEMOCLAW_EXPERIMENTAL === "1"; const USE_COLOR = !process.env.NO_COLOR && !!process.stdout.isTTY; const DIM = USE_COLOR ? "\x1b[2m" : ""; const RESET = USE_COLOR ? "\x1b[0m" : ""; -let OPENSHELL_BIN = null; +let OPENSHELL_BIN: string | null = null; const GATEWAY_NAME = "nemoclaw"; const GATEWAY_BOOTSTRAP_SECRET_NAMES = [ "openshell-server-tls", @@ -189,7 +217,41 @@ const ANTHROPIC_ENDPOINT_URL = "https://api.anthropic.com"; const GEMINI_ENDPOINT_URL = "https://generativelanguage.googleapis.com/v1beta/openai/"; const BRAVE_SEARCH_HELP_URL = "https://brave.com/search/api/"; -const REMOTE_PROVIDER_CONFIG = { +type RemoteProviderKey = + | "build" + | "openai" + | "anthropic" + | "anthropicCompatible" + | "gemini" + | "custom"; + +type RemoteProviderConfigEntry = { + label: string; + providerName: string; + providerType: string; + credentialEnv: string; + endpointUrl: string; + helpUrl: string | null; + modelMode: "catalog" | "curated" | "input"; + defaultModel: string; + skipVerify?: boolean; +}; + +type LooseScalar = string | number | boolean | null | undefined; +type LooseValue = LooseScalar | LooseObject | LooseValue[]; +type LooseObject = { [key: string]: LooseValue }; + +type OnboardOptions = { + nonInteractive?: boolean; + recreateSandbox?: boolean; + dangerouslySkipPermissions?: boolean; + resume?: boolean; + fromDockerfile?: string | null; + acceptThirdPartySoftware?: boolean; + agent?: string | null; +}; + +const REMOTE_PROVIDER_CONFIG: Record = { build: { label: "NVIDIA Endpoints", providerName: "nvidia-prod", @@ -263,21 +325,25 @@ const DISCORD_SNOWFLAKE_RE = /^[0-9]{17,19}$/; let NON_INTERACTIVE = false; let RECREATE_SANDBOX = false; -function isNonInteractive() { +function isNonInteractive(): boolean { return NON_INTERACTIVE || process.env.NEMOCLAW_NON_INTERACTIVE === "1"; } -function isRecreateSandbox() { +function isRecreateSandbox(): boolean { return RECREATE_SANDBOX || process.env.NEMOCLAW_RECREATE_SANDBOX === "1"; } -function note(message) { +function note(message: string): void { console.log(`${DIM}${message}${RESET}`); } // Prompt wrapper: returns env var value or default in non-interactive mode, // otherwise prompts the user interactively. -async function promptOrDefault(question, envVar, defaultValue) { +async function promptOrDefault( + question: string, + envVar: string | null, + defaultValue: string, +): Promise { if (isNonInteractive()) { const val = envVar ? process.env[envVar] : null; const result = val || defaultValue; @@ -304,7 +370,7 @@ const { * Remove known_hosts lines whose host field contains an openshell-* entry. * Preserves blank lines and comments. Returns the cleaned string. */ -function pruneKnownHostsEntries(contents) { +function pruneKnownHostsEntries(contents: string): string { return contents .split("\n") .filter((l) => { @@ -316,14 +382,14 @@ function pruneKnownHostsEntries(contents) { .join("\n"); } -function getSandboxReuseState(sandboxName) { +function getSandboxReuseState(sandboxName: string | null) { if (!sandboxName) return "missing"; const getOutput = runCaptureOpenshell(["sandbox", "get", sandboxName], { ignoreError: true }); const listOutput = runCaptureOpenshell(["sandbox", "list"], { ignoreError: true }); return getSandboxStateFromOutputs(sandboxName, getOutput, listOutput); } -function repairRecordedSandbox(sandboxName) { +function repairRecordedSandbox(sandboxName: string | null): void { if (!sandboxName) return; note(` [resume] Cleaning up recorded sandbox '${sandboxName}' before recreating it.`); runOpenshell(["forward", "stop", String(DASHBOARD_PORT)], { ignoreError: true }); @@ -334,35 +400,38 @@ function repairRecordedSandbox(sandboxName) { const { streamSandboxCreate } = sandboxCreateStream; /** Spawn `openshell gateway start` and stream its output with progress heartbeats. */ -function streamGatewayStart(command, env = process.env) { +function streamGatewayStart( + command: string, + env: NodeJS.ProcessEnv = process.env, +): Promise<{ status: number; output: string }> { const child = spawn("bash", ["-lc", command], { cwd: ROOT, env, stdio: ["ignore", "pipe", "pipe"], }); - const lines = []; + const lines: string[] = []; let pending = ""; let settled = false; - let resolvePromise; + let resolvePromise: (value: { status: number; output: string }) => void; let lastPrintedLine = ""; let currentPhase = "cluster"; let lastHeartbeatBucket = -1; let lastOutputAt = Date.now(); const startedAt = Date.now(); - function getDisplayWidth() { + function getDisplayWidth(): number { return Math.max(60, Number(process.stdout.columns || 100)); } - function trimDisplayLine(line) { + function trimDisplayLine(line: string): string { const width = getDisplayWidth(); const maxLen = Math.max(40, width - 4); if (line.length <= maxLen) return line; return `${line.slice(0, Math.max(0, maxLen - 3))}...`; } - function printProgressLine(line) { + function printProgressLine(line: string): void { const display = trimDisplayLine(line); if (display !== lastPrintedLine) { console.log(display); @@ -370,11 +439,11 @@ function streamGatewayStart(command, env = process.env) { } } - function elapsedSeconds() { + function elapsedSeconds(): number { return Math.max(0, Math.floor((Date.now() - startedAt) / 1000)); } - function setPhase(nextPhase) { + function setPhase(nextPhase: string | null): void { if (!nextPhase || nextPhase === currentPhase) return; currentPhase = nextPhase; const phaseLine = @@ -388,7 +457,7 @@ function streamGatewayStart(command, env = process.env) { printProgressLine(phaseLine); } - function classifyLine(line) { + function classifyLine(line: string): string | null { if (/ApplyJob|helm-install-openshell|Applying HelmChart/i.test(line)) return "install"; if ( /openshell-0|Observed pod startup duration|MountVolume\.MountDevice succeeded/i.test(line) @@ -399,7 +468,7 @@ function streamGatewayStart(command, env = process.env) { return null; } - function flushLine(rawLine) { + function flushLine(rawLine: string): void { const line = rawLine.replace(/\r/g, "").trimEnd(); if (!line) return; lines.push(line); @@ -408,14 +477,14 @@ function streamGatewayStart(command, env = process.env) { if (nextPhase) setPhase(nextPhase); } - function onChunk(chunk) { + function onChunk(chunk: Buffer | string): void { pending += chunk.toString(); const parts = pending.split("\n"); - pending = parts.pop(); + pending = parts.pop() ?? ""; parts.forEach(flushLine); } - function finish(result) { + function finish(result: { status: number; output: string }): void { if (settled) return; settled = true; if (pending) flushLine(pending); @@ -463,15 +532,15 @@ function streamGatewayStart(command, env = process.env) { }, GATEWAY_START_TIMEOUT); killTimer.unref?.(); - return new Promise((resolve) => { + return new Promise<{ status: number; output: string }>((resolve) => { resolvePromise = resolve; - child.on("error", (error) => { + child.on("error", (error: Error) => { clearTimeout(killTimer); const detail = error?.message || String(error); lines.push(detail); finish({ status: 1, output: lines.join("\n") }); }); - child.on("close", (code) => { + child.on("close", (code: number | null) => { clearTimeout(killTimer); const exitCode = killedByTimeout ? 1 : (code ?? 1); finish({ status: exitCode, output: lines.join("\n") }); @@ -479,13 +548,13 @@ function streamGatewayStart(command, env = process.env) { }); } -function step(n, total, msg) { +function step(n: number, total: number, msg: string): void { console.log(""); console.log(` [${n}/${total}] ${msg}`); console.log(` ${"─".repeat(50)}`); } -function getInstalledOpenshellVersion(versionOutput = null) { +function getInstalledOpenshellVersion(versionOutput: string | null = null): string | null { const openshellBin = resolveOpenshell(); if (!versionOutput && !openshellBin) return null; const output = String( @@ -500,7 +569,7 @@ function getInstalledOpenshellVersion(versionOutput = null) { * Compare two semver-like x.y.z strings. Returns true iff `left >= right`. * Non-numeric or missing components are treated as 0. */ -function versionGte(left = "0.0.0", right = "0.0.0") { +function versionGte(left = "0.0.0", right = "0.0.0"): boolean { const lhs = String(left) .split(".") .map((part) => Number.parseInt(part, 10) || 0); @@ -523,7 +592,7 @@ function versionGte(left = "0.0.0", right = "0.0.0") { * as "no constraint configured" so a malformed install does not become a hard * onboard blocker. See #1317. */ -function getBlueprintVersionField(field, rootDir = ROOT) { +function getBlueprintVersionField(field: string, rootDir = ROOT): string | null { try { // Lazy require: yaml is already a dependency via the policy helpers but // pulling it at module load would slow down `nemoclaw --help` for users @@ -543,11 +612,11 @@ function getBlueprintVersionField(field, rootDir = ROOT) { } } -function getBlueprintMinOpenshellVersion(rootDir = ROOT) { +function getBlueprintMinOpenshellVersion(rootDir = ROOT): string | null { return getBlueprintVersionField("min_openshell_version", rootDir); } -function getBlueprintMaxOpenshellVersion(rootDir = ROOT) { +function getBlueprintMaxOpenshellVersion(rootDir = ROOT): string | null { return getBlueprintVersionField("max_openshell_version", rootDir); } @@ -565,7 +634,7 @@ const SANDBOX_BASE_TAG = "latest"; * Returns { digest, ref } on success, or null when the pull or * inspect fails (offline, GHCR outage, local-only build). */ -function pullAndResolveBaseImageDigest() { +function pullAndResolveBaseImageDigest(): { digest: string; ref: string } | null { const imageWithTag = `${SANDBOX_BASE_IMAGE}:${SANDBOX_BASE_TAG}`; try { run(["docker", "pull", imageWithTag], { suppressOutput: true }); @@ -602,16 +671,16 @@ function pullAndResolveBaseImageDigest() { return { digest, ref }; } -function getStableGatewayImageRef(versionOutput = null) { +function getStableGatewayImageRef(versionOutput: string | null = null): string | null { const version = getInstalledOpenshellVersion(versionOutput); if (!version) return null; return `ghcr.io/nvidia/openshell/cluster:${version}`; } -function getOpenshellBinary() { +function getOpenshellBinary(): string { if (OPENSHELL_BIN) return OPENSHELL_BIN; const resolved = resolveOpenshell(); - if (!resolved) { + if (typeof resolved !== "string" || resolved.length === 0) { console.error(" openshell CLI not found."); console.error(" Install manually: https://github.com/NVIDIA/OpenShell/releases"); process.exit(1); @@ -620,21 +689,24 @@ function getOpenshellBinary() { return OPENSHELL_BIN; } -function openshellShellCommand(args, options = {}) { +function openshellShellCommand(args: string[], options: { openshellBinary?: string } = {}): string { const openshellBinary = options.openshellBinary || getOpenshellBinary(); return [shellQuote(openshellBinary), ...args.map((arg) => shellQuote(arg))].join(" "); } -function openshellArgv(args, options = {}) { +function openshellArgv(args: string[], options: { openshellBinary?: string } = {}): string[] { const openshellBinary = options.openshellBinary || getOpenshellBinary(); return [openshellBinary, ...args]; } -function runOpenshell(args, opts = {}) { +function runOpenshell(args: string[], opts: RunnerOptions & { openshellBinary?: string } = {}) { return run(openshellArgv(args, opts), opts); } -function runCaptureOpenshell(args, opts = {}) { +function runCaptureOpenshell( + args: string[], + opts: RunnerOptions & { openshellBinary?: string } = {}, +) { return runCapture(openshellArgv(args, opts), opts); } @@ -647,7 +719,7 @@ const { parsePolicyPresetEnv, } = urlUtils; -function hydrateCredentialEnv(envName) { +function hydrateCredentialEnv(envName: string | null | undefined): string | null { if (!envName) return null; const value = getCredential(envName); if (value) { @@ -664,7 +736,7 @@ const { runStreamingEventProbe, } = httpProbe; -function getNavigationChoice(value = "") { +function getNavigationChoice(value = ""): "back" | "exit" | null { const normalized = String(value || "") .trim() .toLowerCase(); @@ -673,7 +745,7 @@ function getNavigationChoice(value = "") { return null; } -function exitOnboardFromPrompt() { +function exitOnboardFromPrompt(): never { console.log(" Exiting onboarding."); process.exit(1); } @@ -695,7 +767,12 @@ const { // validateNvidiaApiKeyValue — see validation import above -async function replaceNamedCredential(envName, label, helpUrl = null, validator = null) { +async function replaceNamedCredential( + envName: string, + label: string, + helpUrl: string | null = null, + validator: ((value: string) => string | null) | null = null, +): Promise { if (helpUrl) { console.log(""); console.log(` Get your ${label} from: ${helpUrl}`); @@ -722,7 +799,12 @@ async function replaceNamedCredential(envName, label, helpUrl = null, validator } } -async function promptValidationRecovery(label, recovery, credentialEnv = null, helpUrl = null) { +async function promptValidationRecovery( + label: string, + recovery: ProbeRecovery, + credentialEnv: string | null = null, + helpUrl: string | null = null, +): Promise<"credential" | "selection" | "retry" | "model"> { if (isNonInteractive()) { process.exit(1); } @@ -773,7 +855,7 @@ async function promptValidationRecovery(label, recovery, credentialEnv = null, h } if (recovery.kind === "transport") { - console.log(getTransportRecoveryMessage(recovery.failure || {})); + console.log(getTransportRecoveryMessage("failure" in recovery ? recovery.failure || {} : {})); const choice = (await prompt(" Type 'retry', 'back', or 'exit' [retry]: ")) .trim() .toLowerCase(); @@ -814,7 +896,13 @@ async function promptValidationRecovery(label, recovery, credentialEnv = null, h * @param {string|null} baseUrl - Optional base URL for API-compatible endpoints. * @returns {string[]} Argument array for runOpenshell(). */ -function buildProviderArgs(action, name, type, credentialEnv, baseUrl) { +function buildProviderArgs( + action: "create" | "update", + name: string, + type: string, + credentialEnv: string, + baseUrl: string | null, +): string[] { const args = action === "create" ? ["provider", "create", "--name", name, "--type", type, "--credential", credentialEnv] @@ -839,11 +927,22 @@ function buildProviderArgs(action, name, type, credentialEnv, baseUrl) { * @param {Record} [env={}] - Environment variables for the openshell command. * @returns {{ ok: boolean, status?: number, message?: string }} */ -function upsertProvider(name, type, credentialEnv, baseUrl, env = {}) { +function upsertProvider( + name: string, + type: string, + credentialEnv: string, + baseUrl: string | null, + env: NodeJS.ProcessEnv = {}, +): { ok: boolean; status?: number; message?: string } { const exists = providerExistsInGateway(name); const action = exists ? "update" : "create"; const args = buildProviderArgs(action, name, type, credentialEnv, baseUrl); - const runOpts = { ignoreError: true, env, stdio: ["ignore", "pipe", "pipe"] }; + const stdio: RunnerOptions["stdio"] = ["ignore", "pipe", "pipe"]; + const runOpts: RunnerOptions = { + ignoreError: true, + env, + stdio, + }; const result = runOpenshell(args, runOpts); if (result.status !== 0) { const output = @@ -862,7 +961,22 @@ function upsertProvider(name, type, credentialEnv, baseUrl, env = {}) { * @param {Array<{name: string, envKey: string, token: string|null}>} tokenDefs * @returns {string[]} Provider names that were upserted. */ -function upsertMessagingProviders(tokenDefs) { +type MessagingTokenDef = { name: string; envKey: string; token: string | null }; + +type EndpointValidationResult = + | { ok: true; api: string; retry?: undefined } + | { ok: false; retry: "credential" | "selection" | "retry" | "model"; api?: undefined }; + +type SelectionDrift = { + changed: boolean; + providerChanged: boolean; + modelChanged: boolean; + existingProvider: string | null; + existingModel: string | null; + unknown: boolean; +}; + +function upsertMessagingProviders(tokenDefs: MessagingTokenDef[]): string[] { const providers = []; for (const { name, envKey, token } of tokenDefs) { if (!token) continue; @@ -885,7 +999,7 @@ function upsertMessagingProviders(tokenDefs) { * @param {string} name - Provider name to look up (e.g. "discord-bridge"). * @returns {boolean} True if the provider exists in the gateway. */ -function providerExistsInGateway(name) { +function providerExistsInGateway(name: string): boolean { const result = runOpenshell(["provider", "get", name], { ignoreError: true, stdio: ["ignore", "ignore", "ignore"], @@ -900,7 +1014,7 @@ function providerExistsInGateway(name) { * @param {string} value - Credential value to hash. * @returns {string|null} Hex-encoded SHA-256 hash, or null if value is falsy. */ -function hashCredential(value) { +function hashCredential(value: string | null | undefined): string | null { if (!value) return null; return crypto.createHash("sha256").update(String(value).trim()).digest("hex"); } @@ -917,7 +1031,10 @@ function hashCredential(value) { * @param {Array<{name: string, envKey: string, token: string|null}>} tokenDefs * @returns {{ changed: boolean, changedProviders: string[] }} */ -function detectMessagingCredentialRotation(sandboxName, tokenDefs) { +function detectMessagingCredentialRotation( + sandboxName: string, + tokenDefs: MessagingTokenDef[], +): { changed: boolean; changedProviders: string[] } { const sb = registry.getSandbox(sandboxName); const storedHashes = sb?.providerCredentialHashes || {}; const changedProviders = []; @@ -938,7 +1055,7 @@ function detectMessagingCredentialRotation(sandboxName, tokenDefs) { // gate, a transient gateway failure would be recorded as "no providers" and // permanently suppress future backfill retries. function makeConflictProbe() { - let gatewayAlive = null; + let gatewayAlive: boolean | null = null; const isGatewayAlive = () => { if (gatewayAlive === null) { const result = runCaptureOpenshell(["sandbox", "list"], { ignoreError: true }); @@ -950,14 +1067,14 @@ function makeConflictProbe() { return gatewayAlive; }; return { - providerExists: (name) => { + providerExists: (name: string) => { if (!isGatewayAlive()) return "error"; return providerExistsInGateway(name) ? "present" : "absent"; }, }; } -function verifyInferenceRoute(_provider, _model) { +function verifyInferenceRoute(_provider: string, _model: string): void { const output = runCaptureOpenshell(["inference", "get"], { ignoreError: true }); if (!output || /Gateway inference:\s*[\r\n]+\s*Not configured/i.test(output)) { console.error(" OpenShell inference route was not configured."); @@ -965,19 +1082,19 @@ function verifyInferenceRoute(_provider, _model) { } } -function isInferenceRouteReady(provider, model) { +function isInferenceRouteReady(provider: string, model: string): boolean { const live = parseGatewayInference( runCaptureOpenshell(["inference", "get"], { ignoreError: true }), ); return Boolean(live && live.provider === provider && live.model === model); } -function sandboxExistsInGateway(sandboxName) { +function sandboxExistsInGateway(sandboxName: string): boolean { const output = runCaptureOpenshell(["sandbox", "get", sandboxName], { ignoreError: true }); return Boolean(output); } -function pruneStaleSandboxEntry(sandboxName) { +function pruneStaleSandboxEntry(sandboxName: string): boolean { const existing = registry.getSandbox(sandboxName); const liveExists = sandboxExistsInGateway(sandboxName); if (existing && !liveExists) { @@ -986,7 +1103,7 @@ function pruneStaleSandboxEntry(sandboxName) { return liveExists; } -function findSelectionConfigPath(dir) { +function findSelectionConfigPath(dir: string): string | null { if (!dir || !fs.existsSync(dir)) return null; const entries = fs.readdirSync(dir, { withFileTypes: true }); for (const entry of entries) { @@ -1003,12 +1120,18 @@ function findSelectionConfigPath(dir) { return null; } -function readSandboxSelectionConfig(sandboxName) { +function readSandboxSelectionConfig(sandboxName: string): ProviderSelectionConfig | null { if (!sandboxName) return null; const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-selection-")); try { const result = runOpenshell( - ["sandbox", "download", sandboxName, "/sandbox/.nemoclaw/config.json", `${tmpDir}${path.sep}`], + [ + "sandbox", + "download", + sandboxName, + "/sandbox/.nemoclaw/config.json", + `${tmpDir}${path.sep}`, + ], { ignoreError: true, stdio: ["ignore", "ignore", "ignore"] }, ); if (result.status !== 0) return null; @@ -1031,7 +1154,11 @@ function readSandboxSelectionConfig(sandboxName) { } } -function getSelectionDrift(sandboxName, requestedProvider, requestedModel) { +function getSelectionDrift( + sandboxName: string, + requestedProvider: string | null, + requestedModel: string | null, +): SelectionDrift { const existing = readSandboxSelectionConfig(sandboxName); if (!existing) { return { @@ -1072,7 +1199,12 @@ function getSelectionDrift(sandboxName, requestedProvider, requestedModel) { }; } -async function confirmRecreateForSelectionDrift(sandboxName, drift, requestedProvider, requestedModel) { +async function confirmRecreateForSelectionDrift( + sandboxName: string, + drift: SelectionDrift, + requestedProvider: string | null, + requestedModel: string | null, +): Promise { const currentProvider = drift.existingProvider || "unknown"; const currentModel = drift.existingModel || "unknown"; const nextProvider = requestedProvider || "unknown"; @@ -1081,7 +1213,9 @@ async function confirmRecreateForSelectionDrift(sandboxName, drift, requestedPro console.log(` Sandbox '${sandboxName}' exists but requested inference selection changed.`); console.log(` Current: provider=${currentProvider} model=${currentModel}`); console.log(` Requested: provider=${nextProvider} model=${nextModel}`); - console.log(" Recreating the sandbox is required to apply this change to the running OpenClaw UI."); + console.log( + " Recreating the sandbox is required to apply this change to the running OpenClaw UI.", + ); if (isNonInteractive()) { note(" [non-interactive] Recreating sandbox due to provider/model drift."); @@ -1092,7 +1226,7 @@ async function confirmRecreateForSelectionDrift(sandboxName, drift, requestedPro return isAffirmativeAnswer(answer); } -function buildSandboxConfigSyncScript(selectionConfig) { +function buildSandboxConfigSyncScript(selectionConfig: ProviderSelectionConfig): string { // openclaw.json is immutable (root:root 444, Landlock read-only) — never // write to it at runtime. Model routing is handled by the host-side // gateway (`openshell inference set` in Step 5), not from inside the @@ -1107,21 +1241,21 @@ exit `.trim(); } -function isOpenclawReady(sandboxName) { +function isOpenclawReady(sandboxName: string): boolean { return Boolean(fetchGatewayAuthTokenFromSandbox(sandboxName)); } -function writeSandboxConfigSyncFile(script) { +function writeSandboxConfigSyncFile(script: string): string { const scriptFile = secureTempFile("nemoclaw-sync", ".sh"); fs.writeFileSync(scriptFile, `${script}\n`, { mode: 0o600 }); return scriptFile; } -function encodeDockerJsonArg(value) { +function encodeDockerJsonArg(value: LooseValue): string { return Buffer.from(JSON.stringify(value || {}), "utf8").toString("base64"); } -function isAffirmativeAnswer(value) { +function isAffirmativeAnswer(value: string | null | undefined): boolean { return ["y", "yes"].includes( String(value || "") .trim() @@ -1129,7 +1263,7 @@ function isAffirmativeAnswer(value) { ); } -function validateBraveSearchApiKey(apiKey) { +function validateBraveSearchApiKey(apiKey: string): CurlProbeResult { return runCurlProbe([ "-sS", "--compressed", @@ -1148,7 +1282,9 @@ function validateBraveSearchApiKey(apiKey) { ]); } -async function promptBraveSearchRecovery(validation) { +async function promptBraveSearchRecovery( + validation: ValidationFailureLike, +): Promise<"retry" | "skip"> { const recovery = classifyValidationFailure(validation); if (recovery.kind === "credential") { @@ -1167,7 +1303,7 @@ async function promptBraveSearchRecovery(validation) { return "retry"; } -async function promptBraveSearchApiKey() { +async function promptBraveSearchApiKey(): Promise { console.log(""); console.log(` Get your Brave Search API key from: ${BRAVE_SEARCH_HELP_URL}`); console.log(""); @@ -1184,9 +1320,12 @@ async function promptBraveSearchApiKey() { } } -async function ensureValidatedBraveSearchCredential(nonInteractive = isNonInteractive()) { +async function ensureValidatedBraveSearchCredential( + nonInteractive = isNonInteractive(), +): Promise { const savedApiKey = getCredential(webSearch.BRAVE_API_KEY_ENV); - let apiKey = savedApiKey || normalizeCredentialValue(process.env[webSearch.BRAVE_API_KEY_ENV]); + let apiKey: string | null = + savedApiKey || normalizeCredentialValue(process.env[webSearch.BRAVE_API_KEY_ENV]); let usingSavedKey = Boolean(savedApiKey); while (true) { @@ -1233,7 +1372,9 @@ async function ensureValidatedBraveSearchCredential(nonInteractive = isNonIntera } } -async function configureWebSearch(existingConfig = null) { +async function configureWebSearch( + existingConfig: WebSearchConfig | null = null, +): Promise { if (existingConfig) { return { fetchEnabled: true }; } @@ -1271,7 +1412,17 @@ async function configureWebSearch(existingConfig = null) { return { fetchEnabled: true }; } -function getSandboxInferenceConfig(model, provider = null, preferredInferenceApi = null) { +function getSandboxInferenceConfig( + model: string, + provider: string | null = null, + preferredInferenceApi: string | null = null, +): { + providerKey: string; + primaryModelRef: string; + inferenceBaseUrl: string; + inferenceApi: string; + inferenceCompat: LooseObject | null; +} { let providerKey; let primaryModelRef; let inferenceBaseUrl = "https://inference.local/v1"; @@ -1316,17 +1467,17 @@ function getSandboxInferenceConfig(model, provider = null, preferredInferenceApi } function patchStagedDockerfile( - dockerfilePath, - model, - chatUiUrl, + dockerfilePath: string, + model: string, + chatUiUrl: string, buildId = String(Date.now()), - provider = null, - preferredInferenceApi = null, - webSearchConfig = null, - messagingChannels = [], - messagingAllowedIds = {}, - discordGuilds = {}, - baseImageRef = null, + provider: string | null = null, + preferredInferenceApi: string | null = null, + webSearchConfig: WebSearchConfig | null = null, + messagingChannels: string[] = [], + messagingAllowedIds: LooseObject = {}, + discordGuilds: LooseObject = {}, + baseImageRef: string | null = null, ) { const { providerKey, primaryModelRef, inferenceBaseUrl, inferenceApi, inferenceCompat } = getSandboxInferenceConfig(model, provider, preferredInferenceApi); @@ -1337,16 +1488,19 @@ function patchStagedDockerfile( // Only rewrite when the current value already points at our sandbox-base // image — custom --from Dockerfiles may use a different base. if (baseImageRef) { - dockerfile = dockerfile.replace(/^ARG BASE_IMAGE=(.*)$/m, (line, currentValue) => { - const trimmed = String(currentValue).trim(); - if ( - trimmed.startsWith(`${SANDBOX_BASE_IMAGE}:`) || - trimmed.startsWith(`${SANDBOX_BASE_IMAGE}@`) - ) { - return `ARG BASE_IMAGE=${baseImageRef}`; - } - return line; - }); + dockerfile = dockerfile.replace( + /^ARG BASE_IMAGE=(.*)$/m, + (line: string, currentValue: string) => { + const trimmed = String(currentValue).trim(); + if ( + trimmed.startsWith(`${SANDBOX_BASE_IMAGE}:`) || + trimmed.startsWith(`${SANDBOX_BASE_IMAGE}@`) + ) { + return `ARG BASE_IMAGE=${baseImageRef}`; + } + return line; + }, + ); } dockerfile = dockerfile.replace(/^ARG NEMOCLAW_MODEL=.*$/m, `ARG NEMOCLAW_MODEL=${model}`); dockerfile = dockerfile.replace( @@ -1459,23 +1613,44 @@ function patchStagedDockerfile( fs.writeFileSync(dockerfilePath, dockerfile); } -function parseJsonObject(body) { +type ResponseOutputValue = LooseScalar | ResponseOutputItem | ResponseOutputValue[]; +type ResponseOutputRoot = { output?: ResponseOutputValue[] }; +type ResponseOutputItem = { + type?: string; + content?: ResponseOutputValue[]; +}; + +function parseJsonObject(body: string | null | undefined): ResponseOutputRoot | null { if (!body) return null; try { - return JSON.parse(body); + return parseJson(body); } catch { return null; } } -function hasResponsesToolCall(body) { +function readResponseOutputItem( + value: ResponseOutputValue | object | undefined, +): ResponseOutputItem | null { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return null; + } + const type = Reflect.get(value, "type"); + const content = Reflect.get(value, "content"); + return { + type: typeof type === "string" ? type : undefined, + content: Array.isArray(content) ? content : undefined, + }; +} + +function hasResponsesToolCall(body: string | null | undefined): boolean { const parsed = parseJsonObject(body); if (!parsed || !Array.isArray(parsed.output)) return false; const stack = [...parsed.output]; while (stack.length > 0) { - const item = stack.pop(); - if (!item || typeof item !== "object") continue; + const item = readResponseOutputItem(stack.pop()); + if (!item) continue; if (item.type === "function_call" || item.type === "tool_call") return true; if (Array.isArray(item.content)) { stack.push(...item.content); @@ -1485,7 +1660,7 @@ function hasResponsesToolCall(body) { return false; } -function shouldRequireResponsesToolCalling(provider) { +function shouldRequireResponsesToolCalling(provider: string): boolean { return ( provider === "nvidia-prod" || provider === "gemini-api" || provider === "compatible-endpoint" ); @@ -1499,7 +1674,7 @@ function shouldRequireResponsesToolCalling(provider) { // helper (probeOpenAiLikeEndpoint, probeResponsesToolCalling) target the // OpenAI-compat URL, so returning undefined for every provider is correct: // probes default to Bearer auth and Gemini onboarding succeeds. -function getProbeAuthMode(_provider) { +function getProbeAuthMode(_provider: string): "query-param" | undefined { return undefined; } @@ -1510,18 +1685,23 @@ function getProbeAuthMode(_provider) { // Per-validation-probe curl timing. Tighter than the default 60s in // getCurlTimingArgs() because validation must not hang the wizard for a // minute on a misbehaving model. See issue #1601 (Bug 3). -function getValidationProbeCurlArgs(opts) { +function getValidationProbeCurlArgs(opts?: { isWsl?: boolean }): string[] { if (isWsl(opts)) { return ["--connect-timeout", "20", "--max-time", "30"]; } return ["--connect-timeout", "10", "--max-time", "15"]; } -function probeResponsesToolCalling(endpointUrl, model, apiKey, options = {}) { +function probeResponsesToolCalling( + endpointUrl: string, + model: string, + apiKey: string | null, + options: { authMode?: "bearer" | "query-param" } = {}, +): CurlProbeResult { const useQueryParam = options.authMode === "query-param"; const normalizedKey = apiKey ? normalizeCredentialValue(apiKey) : ""; const baseUrl = String(endpointUrl).replace(/\/+$/, ""); - const authHeader = + const authHeader: string[] = !useQueryParam && normalizedKey ? ["-H", `Authorization: Bearer ${normalizedKey}`] : []; const url = useQueryParam && normalizedKey @@ -1573,13 +1753,35 @@ function probeResponsesToolCalling(endpointUrl, model, apiKey, options = {}) { }; } -function probeOpenAiLikeEndpoint(endpointUrl, model, apiKey, options = {}) { +type EndpointProbeFailure = { + name: string; + httpStatus: number; + curlStatus: number; + message: string; + body?: string; +}; + +type EndpointProbeResult = + | { ok: true; api: string; label: string } + | { ok: false; message: string; failures: EndpointProbeFailure[] }; + +function probeOpenAiLikeEndpoint( + endpointUrl: string, + model: string, + apiKey: string | null, + options: { + authMode?: "bearer" | "query-param"; + requireResponsesToolCalling?: boolean; + skipResponsesProbe?: boolean; + probeStreaming?: boolean; + } = {}, +): EndpointProbeResult { const useQueryParam = options.authMode === "query-param"; const normalizedKey = apiKey ? normalizeCredentialValue(apiKey) : ""; const baseUrl = String(endpointUrl).replace(/\/+$/, ""); - const authHeader = + const authHeader: string[] = !useQueryParam && normalizedKey ? ["-H", `Authorization: Bearer ${normalizedKey}`] : []; - const appendKey = (path) => + const appendKey = (path: string): string => useQueryParam && normalizedKey ? `${baseUrl}${path}?key=${encodeURIComponent(normalizedKey)}` : `${baseUrl}${path}`; @@ -1637,7 +1839,7 @@ function probeOpenAiLikeEndpoint(endpointUrl, model, apiKey, options = {}) { ? [chatCompletionsProbe] : [responsesProbe, chatCompletionsProbe]; - const failures = []; + const failures: EndpointProbeFailure[] = []; for (const probe of probes) { const result = probe.execute(); if (result.ok) { @@ -1709,7 +1911,7 @@ function probeOpenAiLikeEndpoint(endpointUrl, model, apiKey, options = {}) { // Single retry with doubled timeouts on timeout/connection failure. // WSL2's virtualized network stack can cause the initial probe to time out // before the TLS handshake completes. See issue #987. - const isTimeoutOrConnFailure = (cs) => cs === 28 || cs === 6 || cs === 7; + const isTimeoutOrConnFailure = (cs: number | undefined) => cs === 28 || cs === 6 || cs === 7; let retriedAfterTimeout = false; if (failures.length > 0 && isTimeoutOrConnFailure(failures[0].curlStatus)) { retriedAfterTimeout = true; @@ -1739,7 +1941,7 @@ function probeOpenAiLikeEndpoint(endpointUrl, model, apiKey, options = {}) { const accountFailure = failures.find( (failure) => isNvcfFunctionNotFoundForAccount(failure.message) || - isNvcfFunctionNotFoundForAccount(failure.body), + isNvcfFunctionNotFoundForAccount(failure.body || ""), ); if (accountFailure) { return { @@ -1762,7 +1964,11 @@ function probeOpenAiLikeEndpoint(endpointUrl, model, apiKey, options = {}) { }; } -function probeAnthropicEndpoint(endpointUrl, model, apiKey) { +function probeAnthropicEndpoint( + endpointUrl: string, + model: string, + apiKey: string | null, +): EndpointProbeResult { const result = runCurlProbe([ "-sS", ...getCurlTimingArgs(), @@ -1798,14 +2004,19 @@ function probeAnthropicEndpoint(endpointUrl, model, apiKey) { } async function validateOpenAiLikeSelection( - label, - endpointUrl, - model, - credentialEnv = null, + label: string, + endpointUrl: string, + model: string, + credentialEnv: string | null = null, retryMessage = "Please choose a provider/model again.", - helpUrl = null, - options = {}, -) { + helpUrl: string | null = null, + options: { + authMode?: "bearer" | "query-param"; + requireResponsesToolCalling?: boolean; + skipResponsesProbe?: boolean; + probeStreaming?: boolean; + } = {}, +): Promise { const apiKey = credentialEnv ? getCredential(credentialEnv) : ""; const probe = probeOpenAiLikeEndpoint(endpointUrl, model, apiKey, options); if (!probe.ok) { @@ -1831,13 +2042,13 @@ async function validateOpenAiLikeSelection( } async function validateAnthropicSelectionWithRetryMessage( - label, - endpointUrl, - model, - credentialEnv, + label: string, + endpointUrl: string, + model: string, + credentialEnv: string, retryMessage = "Please choose a provider/model again.", - helpUrl = null, -) { + helpUrl: string | null = null, +): Promise { const apiKey = getCredential(credentialEnv); const probe = probeAnthropicEndpoint(endpointUrl, model, apiKey); if (!probe.ok) { @@ -1863,12 +2074,12 @@ async function validateAnthropicSelectionWithRetryMessage( } async function validateCustomOpenAiLikeSelection( - label, - endpointUrl, - model, - credentialEnv, - helpUrl = null, -) { + label: string, + endpointUrl: string, + model: string, + credentialEnv: string, + helpUrl: string | null = null, +): Promise { const apiKey = getCredential(credentialEnv); const probe = probeOpenAiLikeEndpoint(endpointUrl, model, apiKey, { requireResponsesToolCalling: true, @@ -1898,12 +2109,12 @@ async function validateCustomOpenAiLikeSelection( } async function validateCustomAnthropicSelection( - label, - endpointUrl, - model, - credentialEnv, - helpUrl = null, -) { + label: string, + endpointUrl: string, + model: string, + credentialEnv: string, + helpUrl: string | null = null, +): Promise { const apiKey = getCredential(credentialEnv); const probe = probeAnthropicEndpoint(endpointUrl, model, apiKey); if (probe.ok) { @@ -1975,9 +2186,10 @@ function loadPersistedProxyToken(): string | null { } function persistProxyPid(pid: number | null | undefined): void { - if (!Number.isInteger(pid) || pid <= 0) return; + const validPid = typeof pid === "number" && Number.isInteger(pid) && pid > 0 ? pid : null; + if (validPid === null) return; ensureProxyStateDir(); - fs.writeFileSync(PROXY_PID_PATH, `${pid}\n`, { mode: 0o600 }); + fs.writeFileSync(PROXY_PID_PATH, `${validPid}\n`, { mode: 0o600 }); fs.chmodSync(PROXY_PID_PATH, 0o600); } @@ -2003,8 +2215,11 @@ function clearPersistedProxyPid(): void { } function isOllamaProxyProcess(pid: number | null | undefined): boolean { - if (!Number.isInteger(pid) || pid <= 0) return false; - const cmdline = runCapture(["ps", "-p", String(pid), "-o", "args="], { ignoreError: true }); + const validPid = typeof pid === "number" && Number.isInteger(pid) && pid > 0 ? pid : null; + if (validPid === null) return false; + const cmdline = runCapture(["ps", "-p", String(validPid), "-o", "args="], { + ignoreError: true, + }); return Boolean(cmdline && cmdline.includes("ollama-auth-proxy.js")); } @@ -2053,11 +2268,12 @@ function startOllamaAuthProxy(): boolean { const crypto = require("crypto"); killStaleProxy(); - ollamaProxyToken = crypto.randomBytes(24).toString("hex"); + const proxyToken = crypto.randomBytes(24).toString("hex"); + ollamaProxyToken = proxyToken; // Don't persist yet — wait until provider is confirmed in setupInference. // If the user backs out to a different provider, the token stays in memory // only and is discarded. - const pid = spawnOllamaAuthProxy(ollamaProxyToken); + const pid = spawnOllamaAuthProxy(proxyToken); sleep(1); if (!isOllamaProxyProcess(pid)) { console.error(` Error: Ollama auth proxy failed to start on :${OLLAMA_PROXY_PORT}`); @@ -2099,7 +2315,7 @@ function getOllamaProxyToken(): string | null { return ollamaProxyToken; } -async function promptOllamaModel(gpu = null) { +async function promptOllamaModel(gpu: GpuInfo | null = null): Promise { const installed = getOllamaModelOptions(); const options = installed.length > 0 ? installed : getBootstrapOllamaModelOptions(gpu); const defaultModel = getDefaultOllamaModel(gpu); @@ -2134,7 +2350,7 @@ function printOllamaExposureWarning() { console.log(""); } -function pullOllamaModel(model) { +function pullOllamaModel(model: string): boolean { const result = spawnSync("ollama", ["pull", model], { cwd: ROOT, encoding: "utf8", @@ -2151,7 +2367,10 @@ function pullOllamaModel(model) { return result.status === 0; } -function prepareOllamaModel(model, installedModels = []) { +function prepareOllamaModel( + model: string, + installedModels: string[] = [], +): ValidationResult | { ok: false; message: string } { const alreadyInstalled = installedModels.includes(model); if (!alreadyInstalled) { console.log(` Pulling Ollama model: ${model}`); @@ -2170,14 +2389,14 @@ function prepareOllamaModel(model, installedModels = []) { return validateOllamaModel(model); } -function getRequestedSandboxNameHint() { +function getRequestedSandboxNameHint(): string | null { const raw = process.env.NEMOCLAW_SANDBOX_NAME; if (typeof raw !== "string") return null; const normalized = raw.trim().toLowerCase(); return normalized || null; } -function getResumeSandboxConflict(session) { +function getResumeSandboxConflict(session: Session | null) { const requestedSandboxName = getRequestedSandboxNameHint(); if (!requestedSandboxName || !session?.sandboxName) { return null; @@ -2187,17 +2406,17 @@ function getResumeSandboxConflict(session) { : null; } -function getRequestedProviderHint(nonInteractive = isNonInteractive()) { +function getRequestedProviderHint(nonInteractive = isNonInteractive()): string | null { return nonInteractive ? getNonInteractiveProvider() : null; } -function getRequestedModelHint(nonInteractive = isNonInteractive()) { +function getRequestedModelHint(nonInteractive = isNonInteractive()): string | null { if (!nonInteractive) return null; const providerKey = getRequestedProviderHint(nonInteractive) || "cloud"; return getNonInteractiveModel(providerKey); } -function getEffectiveProviderName(providerKey) { +function getEffectiveProviderName(providerKey: string | null | undefined): string | null { if (!providerKey) return null; if (REMOTE_PROVIDER_CONFIG[providerKey]) { return REMOTE_PROVIDER_CONFIG[providerKey].providerName; @@ -2216,7 +2435,10 @@ function getEffectiveProviderName(providerKey) { } } -function getResumeConfigConflicts(session, opts = {}) { +function getResumeConfigConflicts( + session: Session | null, + opts: { nonInteractive?: boolean; fromDockerfile?: string | null; agent?: string | null } = {}, +) { const conflicts = []; const nonInteractive = opts.nonInteractive ?? isNonInteractive(); @@ -2277,12 +2499,14 @@ function getResumeConfigConflicts(session, opts = {}) { return conflicts; } -function getContainerRuntime() { +function getContainerRuntime(): ContainerRuntime { const info = runCapture(["docker", "info"], { ignoreError: true }); return inferContainerRuntime(info); } -function printRemediationActions(actions) { +function printRemediationActions( + actions: Array<{ title: string; reason: string; commands?: string[] }> | null | undefined, +): void { if (!Array.isArray(actions) || actions.length === 0) { return; } @@ -2298,18 +2522,18 @@ function printRemediationActions(actions) { } } -function isOpenshellInstalled() { +function isOpenshellInstalled(): boolean { return resolveOpenshell() !== null; } -function getFutureShellPathHint(binDir, pathValue = process.env.PATH || "") { +function getFutureShellPathHint(binDir: string, pathValue = process.env.PATH || ""): string | null { if (String(pathValue).split(path.delimiter).includes(binDir)) { return null; } return `export PATH="${binDir}:$PATH"`; } -function getPortConflictServiceHints(platform = process.platform) { +function getPortConflictServiceHints(platform = process.platform): string[] { if (platform === "darwin") { return [ " # or, if it's a launchctl service (macOS):", @@ -2324,7 +2548,11 @@ function getPortConflictServiceHints(platform = process.platform) { ]; } -function installOpenshell() { +function installOpenshell(): { + installed: boolean; + localBin: string | null; + futureShellPathHint: string | null; +} { const result = spawnSync("bash", [path.join(SCRIPTS, "install-openshell.sh")], { cwd: ROOT, env: process.env, @@ -2355,7 +2583,7 @@ function installOpenshell() { }; } -function sleep(seconds) { +function sleep(seconds: number): void { sleepSeconds(seconds); } @@ -2376,7 +2604,7 @@ function destroyGateway() { ); } -function getGatewayClusterContainerState() { +function getGatewayClusterContainerState(): string { const containerName = getGatewayClusterContainerName(); const state = runCapture( `docker inspect --type container --format '{{.State.Status}}{{if .State.Health}} {{.State.Health.Status}}{{end}}' ${shellQuote(containerName)} 2>/dev/null`, @@ -2407,15 +2635,15 @@ function getGatewayHealthWaitConfig(_startStatus = 0, containerState = "") { }; } -function getGatewayClusterContainerName() { +function getGatewayClusterContainerName(): string { return `openshell-cluster-${GATEWAY_NAME}`; } -function getGatewayLocalEndpoint() { +function getGatewayLocalEndpoint(): string { return `https://127.0.0.1:${GATEWAY_PORT}`; } -function getGatewayBootstrapRepairPlan(missingSecrets = []) { +function getGatewayBootstrapRepairPlan(missingSecrets: string[] = []) { const allowed = new Set(GATEWAY_BOOTSTRAP_SECRET_NAMES); const normalized = [ ...new Set((missingSecrets || []).map((name) => String(name).trim()).filter(Boolean)), @@ -2433,7 +2661,7 @@ function getGatewayBootstrapRepairPlan(missingSecrets = []) { }; } -function buildGatewayBootstrapSecretsScript(missingSecrets = []) { +function buildGatewayBootstrapSecretsScript(missingSecrets: string[] = []): string { const plan = getGatewayBootstrapRepairPlan(missingSecrets); if (!plan.needsRepair) return "exit 0"; @@ -2472,12 +2700,12 @@ fi `; } -function runGatewayClusterCapture(script, opts = {}) { +function runGatewayClusterCapture(script: string, opts: RunnerOptions = {}) { const containerName = getGatewayClusterContainerName(); return runCapture(`docker exec ${shellQuote(containerName)} sh -lc ${shellQuote(script)}`, opts); } -function runGatewayCluster(script, opts = {}) { +function runGatewayCluster(script: string, opts: RunnerOptions = {}) { const containerName = getGatewayClusterContainerName(); return run(`docker exec ${shellQuote(containerName)} sh -lc ${shellQuote(script)}`, opts); } @@ -2501,7 +2729,7 @@ done .filter(Boolean); } -function gatewayClusterHealthcheckPassed() { +function gatewayClusterHealthcheckPassed(): boolean { const result = runGatewayCluster("/usr/local/bin/cluster-healthcheck.sh", { ignoreError: true, suppressOutput: true, @@ -2509,7 +2737,7 @@ function gatewayClusterHealthcheckPassed() { return result.status === 0; } -function repairGatewayBootstrapSecrets() { +function repairGatewayBootstrapSecrets(): { repaired: boolean; missingSecrets: string[] } { const missingSecrets = listMissingGatewayBootstrapSecrets(); const plan = getGatewayBootstrapRepairPlan(missingSecrets); if (!plan.needsRepair) return { repaired: false, missingSecrets }; @@ -2529,7 +2757,9 @@ function repairGatewayBootstrapSecrets() { return { repaired: false, missingSecrets: remainingSecrets }; } -function attachGatewayMetadataIfNeeded({ forceRefresh = false } = {}) { +function attachGatewayMetadataIfNeeded({ + forceRefresh = false, +}: { forceRefresh?: boolean } = {}): boolean { const gwInfo = runCaptureOpenshell(["gateway", "info", "-g", GATEWAY_NAME], { ignoreError: true, }); @@ -2549,7 +2779,15 @@ function attachGatewayMetadataIfNeeded({ forceRefresh = false } = {}) { return false; } -async function ensureNamedCredential(envName, label, helpUrl = null) { +async function ensureNamedCredential( + envName: string | null, + label: string, + helpUrl: string | null = null, +): Promise { + if (!envName) { + console.error(` Missing credential target for ${label}.`); + process.exit(1); + } let key = getCredential(envName); if (key) { process.env[envName] = key; @@ -2558,7 +2796,7 @@ async function ensureNamedCredential(envName, label, helpUrl = null) { return replaceNamedCredential(envName, label, helpUrl); } -function waitForSandboxReady(sandboxName, attempts = 10, delaySeconds = 2) { +function waitForSandboxReady(sandboxName: string, attempts = 10, delaySeconds = 2): boolean { for (let i = 0; i < attempts; i += 1) { const podPhase = runCaptureOpenshell( [ @@ -2585,10 +2823,10 @@ function waitForSandboxReady(sandboxName, attempts = 10, delaySeconds = 2) { // parsePolicyPresetEnv — see urlUtils import above // isSafeModelId — see validation import above -function getNonInteractiveProvider() { +function getNonInteractiveProvider(): string | null { const providerKey = (process.env.NEMOCLAW_PROVIDER || "").trim().toLowerCase(); if (!providerKey) return null; - const aliases = { + const aliases: Record = { cloud: "build", nim: "nim-local", vllm: "vllm", @@ -2618,7 +2856,7 @@ function getNonInteractiveProvider() { return normalized; } -function getNonInteractiveModel(providerKey) { +function getNonInteractiveModel(providerKey: string): string | null { const model = (process.env.NEMOCLAW_MODEL || "").trim(); if (!model) return null; if (!isSafeModelId(model)) { @@ -2632,7 +2870,7 @@ function getNonInteractiveModel(providerKey) { // ── Step 1: Preflight ──────────────────────────────────────────── // eslint-disable-next-line complexity -async function preflight() { +async function preflight(): Promise> { step(1, 8, "Preflight checks"); const host = assessHost(); @@ -2848,7 +3086,14 @@ async function preflight() { // OpenShell CLI — install if missing, upgrade if below minimum version. // MIN_VERSION in install-openshell.sh handles the version gate; calling it // when openshell already exists is safe (it exits early if version is OK). - let openshellInstall = { localBin: null, futureShellPathHint: null }; + let openshellInstall: { + installed?: boolean; + localBin: string | null; + futureShellPathHint: string | null; + } = { + localBin: null, + futureShellPathHint: null, + }; if (!isOpenshellInstalled()) { console.log(" openshell CLI not found. Installing..."); openshellInstall = installOpenshell(); @@ -3117,12 +3362,12 @@ async function preflight() { ` ⚠ Low memory detected (${mem.totalRamMB} MB RAM + ${mem.totalSwapMB} MB swap = ${mem.totalMB} MB total)`, ); - let proceedWithSwap = false; + let proceedWithSwap: boolean = false; if (!isNonInteractive()) { const answer = await prompt( " Create a 4 GB swap file to prevent OOM during sandbox build? (requires sudo) [y/N]: ", ); - proceedWithSwap = answer && answer.toLowerCase().startsWith("y"); + proceedWithSwap = Boolean(answer && answer.toLowerCase().startsWith("y")); } if (!proceedWithSwap) { @@ -3157,7 +3402,10 @@ async function preflight() { // ── Step 2: Gateway ────────────────────────────────────────────── /** Start the OpenShell gateway with retry logic and post-start health polling. */ -async function startGatewayWithOptions(_gpu, { exitOnFailure = true } = {}) { +async function startGatewayWithOptions( + _gpu: ReturnType, + { exitOnFailure = true }: { exitOnFailure?: boolean } = {}, +) { step(2, 8, "Starting OpenShell gateway"); const gatewayStatus = runCaptureOpenshell(["status"], { ignoreError: true }); @@ -3277,7 +3525,7 @@ async function startGatewayWithOptions(_gpu, { exitOnFailure = true } = {}) { retries, minTimeout: 10_000, factor: 3, - onFailedAttempt: (err) => { + onFailedAttempt: (err: { attemptNumber: number; retriesLeft: number }) => { console.log( ` Gateway start attempt ${err.attemptNumber} failed. ${err.retriesLeft} retries left...`, ); @@ -3334,16 +3582,16 @@ async function startGatewayWithOptions(_gpu, { exitOnFailure = true } = {}) { process.env.OPENSHELL_GATEWAY = GATEWAY_NAME; } -async function startGateway(_gpu) { +async function startGateway(_gpu: ReturnType): Promise { return startGatewayWithOptions(_gpu, { exitOnFailure: true }); } -async function startGatewayForRecovery(_gpu) { +async function startGatewayForRecovery(_gpu: ReturnType): Promise { return startGatewayWithOptions(_gpu, { exitOnFailure: false }); } -function getGatewayStartEnv() { - const gatewayEnv = {}; +function getGatewayStartEnv(): Record { + const gatewayEnv: Record = {}; const openshellVersion = getInstalledOpenshellVersion(); const stableGatewayImage = openshellVersion ? `ghcr.io/nvidia/openshell/cluster:${openshellVersion}` @@ -3383,7 +3631,7 @@ async function recoverGatewayRuntime() { runOpenshell(["gateway", "select", GATEWAY_NAME], { ignoreError: true }); const recoveryWait = getGatewayHealthWaitConfig( - startResult.status, + startResult.status ?? 0, getGatewayClusterContainerState(), ); const recoveryPollCount = recoveryWait.extended @@ -3460,7 +3708,8 @@ async function promptValidatedSandboxName() { } return validatedSandboxName; } catch (error) { - console.error(` ${error.message}`); + const errorMessage = error instanceof Error ? error.message : String(error); + console.error(` ${errorMessage}`); } if (/^[0-9]/.test(sandboxName)) { @@ -3487,6 +3736,16 @@ async function promptValidatedSandboxName() { // ── Step 5: Sandbox ────────────────────────────────────────────── +type OnboardConfigSummary = { + provider: string | null; + model: string | null; + credentialEnv?: string | null; + webSearchConfig?: WebSearchConfig | null; + enabledChannels?: string[] | null; + sandboxName: string; + notes?: string[] | null; +}; + /** * Render the configuration summary shown before the destructive sandbox build. * Extracted from confirmOnboardConfiguration() for direct unit testing — see #2165. @@ -3503,17 +3762,18 @@ function formatOnboardConfigSummary({ provider, model, credentialEnv = null, - webSearchConfig, - enabledChannels, + webSearchConfig = null, + enabledChannels = null, sandboxName, notes = [], -}) { +}: OnboardConfigSummary): string { const bar = ` ${"─".repeat(50)}`; const messaging = Array.isArray(enabledChannels) && enabledChannels.length > 0 ? enabledChannels.join(", ") : "none"; - const webSearch = webSearchConfig && webSearchConfig.fetchEnabled === true ? "enabled" : "disabled"; + const webSearch = + webSearchConfig && webSearchConfig.fetchEnabled === true ? "enabled" : "disabled"; const apiKeyLine = credentialEnv ? ` API key: ${credentialEnv} (stored in ~/.nemoclaw/credentials.json)` : ` API key: (not required for ${provider ?? "this provider"})`; @@ -3538,15 +3798,15 @@ function formatOnboardConfigSummary({ // eslint-disable-next-line complexity async function createSandbox( - gpu, - model, - provider, - preferredInferenceApi = null, - sandboxNameOverride = null, - webSearchConfig = null, - enabledChannels = null, - fromDockerfile = null, - agent = null, + gpu: ReturnType, + model: string, + provider: string, + preferredInferenceApi: string | null = null, + sandboxNameOverride: string | null = null, + webSearchConfig: WebSearchConfig | null = null, + enabledChannels: string[] | null = null, + fromDockerfile: string | null = null, + agent: AgentDefinition | null = null, dangerouslySkipPermissions = false, ) { step(6, 8, "Creating sandbox"); @@ -3562,7 +3822,7 @@ async function createSandbox( // Check whether messaging providers will be needed — this must happen before // the sandbox reuse decision so we can detect stale sandboxes that were created // without provider attachments (security: prevents legacy raw-env-var leaks). - const getMessagingToken = (envKey) => + const getMessagingToken = (envKey: string): string | null => getCredential(envKey) || normalizeCredentialValue(process.env[envKey]) || null; // The UI toggle list can include channels the user toggled on but then @@ -3622,8 +3882,9 @@ async function createSandbox( // the bridge comes back. const disabledChannels = registry.getDisabledChannels(sandboxName); const disabledEnvKeys = new Set( - MESSAGING_CHANNELS.filter((c) => disabledChannels.includes(c.name)) - .flatMap((c) => (c.appTokenEnvKey ? [c.envKey, c.appTokenEnvKey] : [c.envKey])), + MESSAGING_CHANNELS.filter((c) => disabledChannels.includes(c.name)).flatMap((c) => + c.appTokenEnvKey ? [c.envKey, c.appTokenEnvKey] : [c.envKey], + ), ); const messagingTokenDefs = [ @@ -3665,7 +3926,7 @@ async function createSandbox( // Declared outside the liveExists block so it is accessible during // post-creation restore (the sandbox create path runs after the block). - let pendingStateRestore = null; + let pendingStateRestore: BackupResult | null = null; if (liveExists) { const existingSandboxState = getSandboxReuseState(sandboxName); @@ -3713,7 +3974,9 @@ async function createSandbox( } } else { console.error(` Sandbox '${sandboxName}' already exists but is not ready.`); - console.error(" Pass --recreate-sandbox or set NEMOCLAW_RECREATE_SANDBOX=1 to overwrite."); + console.error( + " Pass --recreate-sandbox or set NEMOCLAW_RECREATE_SANDBOX=1 to overwrite.", + ); process.exit(1); } } else if (existingSandboxState === "ready") { @@ -3742,7 +4005,11 @@ async function createSandbox( } else { console.log(` Sandbox '${sandboxName}' exists but is not ready.`); console.log(" Selecting 'n' will abort onboarding."); - const answer = await promptOrDefault(" Delete it and create a new one? [Y/n]: ", null, "y"); + const answer = await promptOrDefault( + " Delete it and create a new one? [Y/n]: ", + null, + "y", + ); const normalizedAnswer = answer.trim().toLowerCase(); if (normalizedAnswer === "n" || normalizedAnswer === "no") { console.log(" Aborting onboarding."); @@ -3767,9 +4034,10 @@ async function createSandbox( console.error(" Pass --recreate-sandbox to force recreation without backup."); upsertMessagingProviders(messagingTokenDefs); // Update stored hashes so the next onboard doesn't re-detect rotation. - const abortHashes = {}; + const abortHashes: Record = {}; for (const { envKey, token } of messagingTokenDefs) { - if (token) abortHashes[envKey] = hashCredential(token); + const hash = token ? hashCredential(token) : null; + if (hash) abortHashes[envKey] = hash; } if (Object.keys(abortHashes).length > 0) { registry.updateSandbox(sandboxName, { providerCredentialHashes: abortHashes }); @@ -3778,12 +4046,14 @@ async function createSandbox( return sandboxName; } } catch (err) { - console.error(` State backup threw: ${err.message} — aborting rebuild.`); + const errorMessage = err instanceof Error ? err.message : String(err); + console.error(` State backup threw: ${errorMessage} — aborting rebuild.`); console.error(" Pass --recreate-sandbox to force recreation without backup."); upsertMessagingProviders(messagingTokenDefs); - const abortHashes = {}; + const abortHashes: Record = {}; for (const { envKey, token } of messagingTokenDefs) { - if (token) abortHashes[envKey] = hashCredential(token); + const hash = token ? hashCredential(token) : null; + if (hash) abortHashes[envKey] = hash; } if (Object.keys(abortHashes).length > 0) { registry.updateSandbox(sandboxName, { providerCredentialHashes: abortHashes }); @@ -3806,10 +4076,11 @@ async function createSandbox( note(` Sandbox '${sandboxName}' exists but is not ready — recreating it.`); } - const previousEntry = registry.getSandbox(sandboxName); - if (previousEntry?.policies?.length > 0) { - onboardSession.updateSession((current) => { - current.policyPresets = previousEntry.policies; + const previousEntry: SandboxEntry | null = registry.getSandbox(sandboxName); + const previousPolicies = previousEntry?.policies ?? null; + if (previousPolicies && previousPolicies.length > 0) { + onboardSession.updateSession((current: Session) => { + current.policyPresets = previousPolicies; return current; }); } @@ -3819,7 +4090,10 @@ async function createSandbox( // Destroy old sandbox and clean up its host-side Docker image. runOpenshell(["sandbox", "delete", sandboxName], { ignoreError: true }); if (previousEntry?.imageTag) { - const rmiResult = run(["docker", "rmi", previousEntry.imageTag], { ignoreError: true, suppressOutput: true }); + const rmiResult = run(["docker", "rmi", previousEntry.imageTag], { + ignoreError: true, + suppressOutput: true, + }); if (rmiResult.status !== 0) { console.warn(` Warning: failed to remove old sandbox image '${previousEntry.imageTag}'.`); } @@ -3846,13 +4120,14 @@ async function createSandbox( try { fs.cpSync(path.dirname(fromResolved), buildCtx, { recursive: true, - filter: (src) => { + filter: (src: string) => { const base = path.basename(src); return !["node_modules", ".git", ".venv", "__pycache__"].includes(base); }, }); } catch (err) { - if (err.code === "EACCES") { + const errorObject = typeof err === "object" && err !== null ? err : null; + if (isErrnoException(errorObject) && errorObject.code === "EACCES") { console.error( ` Permission denied while copying build context from: ${path.dirname(fromResolved)}`, ); @@ -3948,22 +4223,22 @@ async function createSandbox( ...new Set( messagingTokenDefs .filter(({ token }) => !!token) - .map(({ envKey }) => { - if (envKey === "DISCORD_BOT_TOKEN") return "discord"; - if (envKey === "SLACK_BOT_TOKEN") return "slack"; + .flatMap(({ envKey }) => { + if (envKey === "DISCORD_BOT_TOKEN") return ["discord"]; + if (envKey === "SLACK_BOT_TOKEN") return ["slack"]; // SLACK_APP_TOKEN alone does not enable slack; bot token is required. - if (envKey === "SLACK_APP_TOKEN") - return tokensByEnvKey["SLACK_BOT_TOKEN"] ? "slack" : null; - if (envKey === "TELEGRAM_BOT_TOKEN") return "telegram"; - return null; - }) - .filter(Boolean), + if (envKey === "SLACK_APP_TOKEN") { + return tokensByEnvKey["SLACK_BOT_TOKEN"] ? ["slack"] : []; + } + if (envKey === "TELEGRAM_BOT_TOKEN") return ["telegram"]; + return []; + }), ), ]; // Build allowed sender IDs map from env vars set during the messaging prompt. // Each channel with a userIdEnvKey in MESSAGING_CHANNELS may have a // comma-separated list of IDs (e.g. TELEGRAM_ALLOWED_IDS="123,456"). - const messagingAllowedIds = {}; + const messagingAllowedIds: Record = {}; const enabledTokenEnvKeys = new Set(messagingTokenDefs.map(({ envKey }) => envKey)); for (const ch of MESSAGING_CHANNELS) { if ( @@ -3972,14 +4247,14 @@ async function createSandbox( ch.userIdEnvKey && process.env[ch.userIdEnvKey] ) { - const ids = process.env[ch.userIdEnvKey] + const ids = String(process.env[ch.userIdEnvKey]) .split(",") .map((s) => s.trim()) .filter(Boolean); if (ids.length > 0) messagingAllowedIds[ch.name] = ids; } } - const discordGuilds = {}; + const discordGuilds: Record = {}; if (enabledTokenEnvKeys.has("DISCORD_BOT_TOKEN")) { const serverIds = (process.env.DISCORD_SERVER_IDS || process.env.DISCORD_SERVER_ID || "") .split(",") @@ -4206,10 +4481,11 @@ async function createSandbox( // Register only after confirmed ready — prevents phantom entries const effectiveAgent = agent || agentDefs.loadAgent("openclaw"); - const providerCredentialHashes = {}; + const providerCredentialHashes: Record = {}; for (const { envKey, token } of messagingTokenDefs) { - if (token) { - providerCredentialHashes[envKey] = hashCredential(token); + const hash = token ? hashCredential(token) : null; + if (hash) { + providerCredentialHashes[envKey] = hash; } } registry.registerSandbox({ @@ -4228,7 +4504,7 @@ async function createSandbox( }); // Restore workspace state if we backed it up during credential rotation. - if (pendingStateRestore?.success) { + if (pendingStateRestore?.success && pendingStateRestore.manifest) { note(" Restoring workspace state after credential rotation..."); const restore = sandboxState.restoreSandboxState( sandboxName, @@ -4304,15 +4580,24 @@ async function createSandbox( // ── Step 3: Inference selection ────────────────────────────────── // eslint-disable-next-line complexity -async function setupNim(gpu) { +type ProviderChoice = { key: string; label: string }; + +async function setupNim(gpu: ReturnType): Promise<{ + model: string | null; + provider: string; + endpointUrl: string | null; + credentialEnv: string | null; + preferredInferenceApi: string | null; + nimContainer: string | null; +}> { step(3, 8, "Configuring inference (NIM)"); - let model = null; - let provider = REMOTE_PROVIDER_CONFIG.build.providerName; - let nimContainer = null; - let endpointUrl = REMOTE_PROVIDER_CONFIG.build.endpointUrl; - let credentialEnv = REMOTE_PROVIDER_CONFIG.build.credentialEnv; - let preferredInferenceApi = null; + let model: string | null = null; + let provider: string = REMOTE_PROVIDER_CONFIG.build.providerName; + let nimContainer: string | null = null; + let endpointUrl: string | null = REMOTE_PROVIDER_CONFIG.build.endpointUrl; + let credentialEnv: string | null = REMOTE_PROVIDER_CONFIG.build.credentialEnv; + let preferredInferenceApi: string | null = null; // Detect local inference options // "command -v" is a shell builtin — must go through bash. @@ -4327,7 +4612,7 @@ async function setupNim(gpu) { const requestedModel = isNonInteractive() ? getNonInteractiveModel(requestedProvider || "build") : null; - const options = []; + const options: Array<{ key: string; label: string }> = []; options.push({ key: "build", label: "NVIDIA Endpoints" }); options.push({ key: "openai", label: "OpenAI" }); options.push({ key: "custom", label: "Other OpenAI-compatible endpoint" }); @@ -4377,7 +4662,7 @@ async function setupNim(gpu) { if (options.length > 1) { selectionLoop: while (true) { - let selected; + let selected: ProviderChoice | undefined; if (isNonInteractive()) { const providerKey = requestedProvider || "build"; @@ -4397,7 +4682,7 @@ async function setupNim(gpu) { } note(` [non-interactive] Provider: ${selected.key}`); } else { - const suggestions = []; + const suggestions: string[] = []; if (vllmRunning) suggestions.push("vLLM"); if (ollamaRunning) suggestions.push("Ollama"); if (suggestions.length > 0) { @@ -4425,6 +4710,11 @@ async function setupNim(gpu) { selected = options[idx] || options[defaultIdx - 1]; } + if (!selected) { + console.error(" No provider was selected."); + process.exit(1); + } + if (REMOTE_PROVIDER_CONFIG[selected.key]) { const remoteConfig = REMOTE_PROVIDER_CONFIG[selected.key]; provider = remoteConfig.providerName; @@ -4552,15 +4842,19 @@ async function setupNim(gpu) { } const _envModelRemote = (process.env.NEMOCLAW_MODEL || "").trim(); const defaultModel = requestedModel || _envModelRemote || remoteConfig.defaultModel; - let modelValidator = null; + const selectedCredentialEnv = requireValue( + credentialEnv, + `Missing credential env for ${remoteConfig.label}`, + ); + let modelValidator: ((candidate: string) => ModelValidationResult) | null = null; if (selected.key === "openai" || selected.key === "gemini") { const modelAuthMode = getProbeAuthMode(provider); modelValidator = (candidate) => validateOpenAiLikeModel( remoteConfig.label, - endpointUrl, + endpointUrl || remoteConfig.endpointUrl, candidate, - getCredential(credentialEnv), + getCredential(selectedCredentialEnv) || "", ...(modelAuthMode ? [{ authMode: modelAuthMode }] : []), ); } else if (selected.key === "anthropic") { @@ -4568,7 +4862,7 @@ async function setupNim(gpu) { validateAnthropicModel( endpointUrl || ANTHROPIC_ENDPOINT_URL, candidate, - getCredential(credentialEnv), + getCredential(selectedCredentialEnv) || "", ); } while (true) { @@ -4593,9 +4887,9 @@ async function setupNim(gpu) { if (selected.key === "custom") { const validation = await validateCustomOpenAiLikeSelection( remoteConfig.label, - endpointUrl, + endpointUrl || OPENAI_ENDPOINT_URL, model, - credentialEnv, + selectedCredentialEnv, remoteConfig.helpUrl, ); if (validation.ok) { @@ -4640,7 +4934,7 @@ async function setupNim(gpu) { remoteConfig.label, endpointUrl || ANTHROPIC_ENDPOINT_URL, model, - credentialEnv, + selectedCredentialEnv, remoteConfig.helpUrl, ); if (validation.ok) { @@ -4664,7 +4958,7 @@ async function setupNim(gpu) { remoteConfig.label, endpointUrl || ANTHROPIC_ENDPOINT_URL, model, - credentialEnv, + selectedCredentialEnv, retryMessage, remoteConfig.helpUrl, ); @@ -4684,7 +4978,7 @@ async function setupNim(gpu) { remoteConfig.label, endpointUrl, model, - credentialEnv, + selectedCredentialEnv, retryMessage, remoteConfig.helpUrl, { @@ -4739,8 +5033,12 @@ async function setupNim(gpu) { console.log(` Using ${remoteConfig.label} with model: ${model}`); break; } else if (selected.key === "nim-local") { + const localGpu = requireValue( + gpu, + "GPU details are required for local NIM model selection", + ); // List models that fit GPU VRAM - const models = nim.listModels().filter((m) => m.minGpuMemoryMB <= gpu.totalMemoryMB); + const models = nim.listModels().filter((m) => m.minGpuMemoryMB <= localGpu.totalMemoryMB); if (models.length === 0) { console.log(" No NIM models fit your GPU VRAM. Falling back to cloud API."); } else { @@ -4815,17 +5113,17 @@ async function setupNim(gpu) { provider = "vllm-local"; credentialEnv = "OPENAI_API_KEY"; endpointUrl = getLocalProviderBaseUrl(provider); + if (!endpointUrl) { + console.error(" Local NVIDIA NIM base URL could not be determined."); + process.exit(1); + } const validation = await validateOpenAiLikeSelection( "Local NVIDIA NIM", endpointUrl, - model, + requireValue(model, "Expected a Local NVIDIA NIM model after startup"), credentialEnv, ); - if ( - validation.retry === "selection" || - validation.retry === "back" || - validation.retry === "model" - ) { + if (validation.retry === "selection" || validation.retry === "model") { continue selectionLoop; } if (!validation.ok) { @@ -4870,6 +5168,10 @@ async function setupNim(gpu) { provider = "ollama-local"; credentialEnv = "OPENAI_API_KEY"; endpointUrl = getLocalProviderBaseUrl(provider); + if (!endpointUrl) { + console.error(" Local Ollama base URL could not be determined."); + process.exit(1); + } while (true) { const installedModels = getOllamaModelOptions(); if (isNonInteractive()) { @@ -4882,7 +5184,8 @@ async function setupNim(gpu) { console.log(""); continue selectionLoop; } - const probe = prepareOllamaModel(model, installedModels); + const selectedModel = requireValue(model, "Expected an Ollama model selection"); + const probe = prepareOllamaModel(selectedModel, installedModels); if (!probe.ok) { console.error(` ${probe.message}`); if (isNonInteractive()) { @@ -4892,14 +5195,19 @@ async function setupNim(gpu) { console.log(""); continue; } + const validationBaseUrl = getLocalProviderValidationBaseUrl(provider); + if (!validationBaseUrl) { + console.error(" Local Ollama validation URL could not be determined."); + process.exit(1); + } const validation = await validateOpenAiLikeSelection( "Local Ollama", - getLocalProviderValidationBaseUrl(provider), - model, + validationBaseUrl, + selectedModel, null, "Choose a different Ollama model or select Other.", ); - if (validation.retry === "selection" || validation.retry === "back") { + if (validation.retry === "selection") { continue selectionLoop; } if (!validation.ok) { @@ -4940,6 +5248,10 @@ async function setupNim(gpu) { provider = "ollama-local"; credentialEnv = "OPENAI_API_KEY"; endpointUrl = getLocalProviderBaseUrl(provider); + if (!endpointUrl) { + console.error(" Local Ollama base URL could not be determined."); + process.exit(1); + } while (true) { const installedModels = getOllamaModelOptions(); if (isNonInteractive()) { @@ -4952,7 +5264,8 @@ async function setupNim(gpu) { console.log(""); continue selectionLoop; } - const probe = prepareOllamaModel(model, installedModels); + const selectedModel = requireValue(model, "Expected an Ollama model selection"); + const probe = prepareOllamaModel(selectedModel, installedModels); if (!probe.ok) { console.error(` ${probe.message}`); if (isNonInteractive()) { @@ -4962,14 +5275,19 @@ async function setupNim(gpu) { console.log(""); continue; } + const validationBaseUrl = getLocalProviderValidationBaseUrl(provider); + if (!validationBaseUrl) { + console.error(" Local Ollama validation URL could not be determined."); + process.exit(1); + } const validation = await validateOpenAiLikeSelection( "Local Ollama", - getLocalProviderValidationBaseUrl(provider), - model, + validationBaseUrl, + selectedModel, null, "Choose a different Ollama model or select Other.", ); - if (validation.retry === "selection" || validation.retry === "back") { + if (validation.retry === "selection") { continue selectionLoop; } if (!validation.ok) { @@ -4991,6 +5309,10 @@ async function setupNim(gpu) { provider = "vllm-local"; credentialEnv = "OPENAI_API_KEY"; endpointUrl = getLocalProviderBaseUrl(provider); + if (!endpointUrl) { + console.error(" Local vLLM base URL could not be determined."); + process.exit(1); + } // Query vLLM for the actual model ID const vllmModelsRaw = runCapture( ["curl", "-sf", `http://127.0.0.1:${VLLM_PORT}/v1/models`], @@ -5001,8 +5323,10 @@ async function setupNim(gpu) { try { const vllmModels = JSON.parse(vllmModelsRaw); if (vllmModels.data && vllmModels.data.length > 0) { - model = vllmModels.data[0].id; - if (!isSafeModelId(model)) { + const detectedModel = + typeof vllmModels.data[0]?.id === "string" ? vllmModels.data[0].id : null; + model = detectedModel; + if (!detectedModel || !isSafeModelId(detectedModel)) { console.error(` Detected model ID contains invalid characters: ${model}`); process.exit(1); } @@ -5017,17 +5341,18 @@ async function setupNim(gpu) { ); process.exit(1); } + const validationBaseUrl = getLocalProviderValidationBaseUrl(provider); + if (!validationBaseUrl) { + console.error(" Local vLLM validation URL could not be determined."); + process.exit(1); + } const validation = await validateOpenAiLikeSelection( "Local vLLM", - getLocalProviderValidationBaseUrl(provider), - model, + validationBaseUrl, + requireValue(model, "Expected a detected vLLM model"), credentialEnv, ); - if ( - validation.retry === "selection" || - validation.retry === "back" || - validation.retry === "model" - ) { + if (validation.retry === "selection" || validation.retry === "model") { continue selectionLoop; } if (!validation.ok) { @@ -5055,12 +5380,12 @@ async function setupNim(gpu) { // eslint-disable-next-line complexity async function setupInference( - sandboxName, - model, - provider, - endpointUrl = null, - credentialEnv = null, -) { + sandboxName: string | null, + model: string, + provider: string, + endpointUrl: string | null = null, + credentialEnv: string | null = null, +): Promise<{ ok: true; retry?: undefined } | { retry: "selection" }> { step(4, 8, "Setting up inference provider"); runOpenshell(["gateway", "select", GATEWAY_NAME], { ignoreError: true }); @@ -5077,6 +5402,10 @@ async function setupInference( provider === "nvidia-nim" ? REMOTE_PROVIDER_CONFIG.build : Object.values(REMOTE_PROVIDER_CONFIG).find((entry) => entry.providerName === provider); + if (!config) { + console.error(` Unsupported provider configuration: ${provider}`); + process.exit(1); + } while (true) { const resolvedCredentialEnv = credentialEnv || (config && config.credentialEnv); const resolvedEndpointUrl = endpointUrl || (config && config.endpointUrl); @@ -5221,7 +5550,9 @@ async function setupInference( } verifyInferenceRoute(provider, model); - registry.updateSandbox(sandboxName, { model, provider }); + if (sandboxName) { + registry.updateSandbox(sandboxName, { model, provider }); + } console.log(` ✓ Inference route set: ${provider} / ${model}`); return { ok: true }; } @@ -5287,10 +5618,10 @@ async function checkTelegramReachability(token: string) { } } -async function setupMessagingChannels() { +async function setupMessagingChannels(): Promise { step(5, 8, "Messaging channels"); - const getMessagingToken = (envKey) => + const getMessagingToken = (envKey: string): string | null => getCredential(envKey) || normalizeCredentialValue(process.env[envKey]) || null; // Non-interactive: skip prompt, tokens come from env/credentials @@ -5299,7 +5630,10 @@ async function setupMessagingChannels() { if (found.length > 0) { note(` [non-interactive] Messaging tokens detected: ${found.join(", ")}`); if (found.includes("telegram")) { - await checkTelegramReachability(getMessagingToken("TELEGRAM_BOT_TOKEN")); + const telegramToken = getMessagingToken("TELEGRAM_BOT_TOKEN"); + if (telegramToken) { + await checkTelegramReachability(telegramToken); + } } } else { note(" [non-interactive] No messaging tokens configured. Skipping."); @@ -5336,7 +5670,7 @@ async function setupMessagingChannels() { showList(); - await new Promise((resolve, reject) => { + await new Promise((resolve, reject) => { const input = process.stdin; let rawModeEnabled = false; let finished = false; @@ -5348,7 +5682,7 @@ async function setupMessagingChannels() { } } - function finish() { + function finish(): void { if (finished) return; finished = true; cleanup(); @@ -5356,7 +5690,7 @@ async function setupMessagingChannels() { resolve(); } - function onData(chunk) { + function onData(chunk: Buffer | string): void { const text = chunk.toString("utf8"); for (let i = 0; i < text.length; i += 1) { const ch = text[i]; @@ -5504,8 +5838,11 @@ async function setupMessagingChannels() { // The non-interactive branch above already ran this probe and returned early, // so this second call only fires on the interactive path — guard explicitly // to make the no-double-probe invariant visible at the call site. - if (!isNonInteractive() && enabled.has("telegram") && getMessagingToken("TELEGRAM_BOT_TOKEN")) { - await checkTelegramReachability(getMessagingToken("TELEGRAM_BOT_TOKEN")); + if (!isNonInteractive() && enabled.has("telegram")) { + const telegramToken = getMessagingToken("TELEGRAM_BOT_TOKEN"); + if (telegramToken) { + await checkTelegramReachability(telegramToken); + } } return Array.from(enabled); @@ -5515,7 +5852,11 @@ function getSuggestedPolicyPresets({ enabledChannels = null, webSearchConfig = null, provider = null, -} = {}) { +}: { + enabledChannels?: string[] | null; + webSearchConfig?: WebSearchConfig | null; + provider?: string | null; +} = {}): string[] { const suggestions = ["pypi", "npm"]; // Auto-suggest local-inference preset when a local provider is selected @@ -5524,7 +5865,7 @@ function getSuggestedPolicyPresets({ } const usesExplicitMessagingSelection = Array.isArray(enabledChannels); - const maybeSuggestMessagingPreset = (channel, envKey) => { + const maybeSuggestMessagingPreset = (channel: string, envKey: string): void => { if (usesExplicitMessagingSelection) { if (enabledChannels.includes(channel)) suggestions.push(channel); return; @@ -5548,7 +5889,7 @@ function getSuggestedPolicyPresets({ // ── Step 7: OpenClaw ───────────────────────────────────────────── -async function setupOpenclaw(sandboxName, model, provider) { +async function setupOpenclaw(sandboxName: string, model: string, provider: string): Promise { step(7, 8, "Setting up OpenClaw inside sandbox"); const selectionConfig = getProviderSelectionConfig(provider, model); @@ -5576,7 +5917,14 @@ async function setupOpenclaw(sandboxName, model, provider) { // ── Step 7: Policy presets ─────────────────────────────────────── // eslint-disable-next-line complexity -async function _setupPolicies(sandboxName, options = {}) { +async function _setupPolicies( + sandboxName: string, + options: { + enabledChannels?: string[] | null; + webSearchConfig?: WebSearchConfig | null; + provider?: string | null; + } = {}, +) { step(8, 8, "Policy presets"); const suggestions = getSuggestedPolicyPresets(options); @@ -5585,7 +5933,7 @@ async function _setupPolicies(sandboxName, options = {}) { if (isNonInteractive()) { const policyMode = (process.env.NEMOCLAW_POLICY_MODE || "suggested").trim().toLowerCase(); - let selectedPresets = suggestions; + let selectedPresets: string[] = suggestions; if (policyMode === "skip" || policyMode === "none" || policyMode === "no") { note(" [non-interactive] Skipping policy presets."); @@ -5593,13 +5941,13 @@ async function _setupPolicies(sandboxName, options = {}) { } if (policyMode === "custom" || policyMode === "list") { - selectedPresets = parsePolicyPresetEnv(process.env.NEMOCLAW_POLICY_PRESETS); + selectedPresets = parsePolicyPresetEnv(process.env.NEMOCLAW_POLICY_PRESETS || ""); if (selectedPresets.length === 0) { console.error(" NEMOCLAW_POLICY_PRESETS is required when NEMOCLAW_POLICY_MODE=custom."); process.exit(1); } } else if (policyMode === "suggested" || policyMode === "default" || policyMode === "auto") { - const envPresets = parsePolicyPresetEnv(process.env.NEMOCLAW_POLICY_PRESETS); + const envPresets = parsePolicyPresetEnv(process.env.NEMOCLAW_POLICY_PRESETS || ""); if (envPresets.length > 0) { selectedPresets = envPresets; } @@ -5627,7 +5975,7 @@ async function _setupPolicies(sandboxName, options = {}) { policies.applyPreset(sandboxName, name); break; } catch (err) { - const message = err && err.message ? err.message : String(err); + const message = err instanceof Error ? err.message : String(err); if (!message.includes("sandbox not found") || attempt === 2) { throw err; } @@ -5680,7 +6028,7 @@ async function _setupPolicies(sandboxName, options = {}) { console.log(" ✓ Policies applied"); } -function arePolicyPresetsApplied(sandboxName, selectedPresets = []) { +function arePolicyPresetsApplied(sandboxName: string, selectedPresets: string[] = []): boolean { if (!Array.isArray(selectedPresets) || selectedPresets.length === 0) return false; const applied = new Set(policies.getAppliedPresets(sandboxName)); return selectedPresets.every((preset) => applied.has(preset)); @@ -5694,7 +6042,7 @@ function arePolicyPresetsApplied(sandboxName, selectedPresets = []) { * * @returns {Promise} */ -async function selectPolicyTier() { +async function selectPolicyTier(): Promise { const allTiers = tiers.listTiers(); const defaultTier = allTiers.find((t) => t.name === "balanced") || allTiers[1]; @@ -5772,7 +6120,7 @@ async function selectPolicyTier() { process.stdin.resume(); process.stdin.setEncoding("utf8"); - return new Promise((resolve) => { + return new Promise((resolve) => { const cleanup = () => { process.stdin.setRawMode(false); process.stdin.pause(); @@ -5786,7 +6134,7 @@ async function selectPolicyTier() { }; process.once("SIGTERM", onSigterm); - const onData = (key) => { + const onData = (key: string) => { if (key === "\r" || key === "\n") { cleanup(); process.stdout.write("\n"); @@ -5829,9 +6177,13 @@ async function selectPolicyTier() { * @param {string[]} [extraSelected] — names pre-checked even if not in tier (e.g. already-applied) * @returns {Promise>} */ -async function selectTierPresetsAndAccess(tierName, allPresets, extraSelected = []) { +async function selectTierPresetsAndAccess( + tierName: string, + allPresets: Array<{ name: string; description?: string }>, + extraSelected: string[] = [], +): Promise> { const tierDef = tiers.getTier(tierName); - const tierPresetMap = {}; + const tierPresetMap: Record = {}; if (tierDef) { for (const p of tierDef.presets) { tierPresetMap[p.name] = p.access; @@ -5841,8 +6193,10 @@ async function selectTierPresetsAndAccess(tierName, allPresets, extraSelected = // Tier presets first (in tier order), then the rest in their original order. const tierNames = tierDef ? tierDef.presets.map((p) => p.name) : []; const tierSet = new Set(tierNames); - const ordered = [ - ...tierNames.map((name) => allPresets.find((p) => p.name === name)).filter(Boolean), + const ordered: Array<{ name: string; description?: string }> = [ + ...tierNames + .map((name) => allPresets.find((p) => p.name === name)) + .filter((p): p is { name: string; description?: string } => Boolean(p)), ...allPresets.filter((p) => !tierSet.has(p.name)), ]; @@ -5853,7 +6207,7 @@ async function selectTierPresetsAndAccess(tierName, allPresets, extraSelected = ]); // Access levels: tier defaults for tier presets, read-write default for others. - const accessModes = {}; + const accessModes: Record = {}; for (const p of ordered) { accessModes[p.name] = tierPresetMap[p.name] ?? "read-write"; } @@ -5950,7 +6304,7 @@ async function selectTierPresetsAndAccess(tierName, allPresets, extraSelected = process.stdin.resume(); process.stdin.setEncoding("utf8"); - return new Promise((resolve) => { + return new Promise>((resolve) => { const cleanup = () => { process.stdin.setRawMode(false); process.stdin.pause(); @@ -5964,7 +6318,7 @@ async function selectTierPresetsAndAccess(tierName, allPresets, extraSelected = }; process.once("SIGTERM", onSigterm); - const onData = (key) => { + const onData = (key: string) => { if (key === "\r" || key === "\n") { cleanup(); process.stdout.write("\n"); @@ -5983,7 +6337,9 @@ async function selectTierPresetsAndAccess(tierName, allPresets, extraSelected = cursor = (cursor + 1) % n; redraw(); } else if (key === " ") { - const name = ordered[cursor].name; + const currentPreset = ordered[cursor]; + if (!currentPreset) return; + const name = currentPreset.name; if (included.has(name)) { included.delete(name); } else { @@ -5991,7 +6347,9 @@ async function selectTierPresetsAndAccess(tierName, allPresets, extraSelected = } redraw(); } else if (key === "r" || key === "R") { - const name = ordered[cursor].name; + const currentPreset = ordered[cursor]; + if (!currentPreset) return; + const name = currentPreset.name; accessModes[name] = accessModes[name] === "read-write" ? "read" : "read-write"; redraw(); } @@ -6006,8 +6364,11 @@ async function selectTierPresetsAndAccess(tierName, allPresets, extraSelected = * Keys: ↑/↓ or k/j to move, Space to toggle, a to select/unselect all, Enter to confirm. * Falls back to a simple line-based prompt when stdin is not a TTY. */ -async function presetsCheckboxSelector(allPresets, initialSelected) { - const selected = new Set(initialSelected); +async function presetsCheckboxSelector( + allPresets: Array<{ name: string; description: string }>, + initialSelected: string[], +): Promise { + const selected = new Set(initialSelected); const n = allPresets.length; // ── Zero-presets guard ──────────────────────────────────────────── @@ -6086,7 +6447,7 @@ async function presetsCheckboxSelector(allPresets, initialSelected) { process.stdin.resume(); process.stdin.setEncoding("utf8"); - return new Promise((resolve) => { + return new Promise((resolve) => { const cleanup = () => { process.stdin.setRawMode(false); process.stdin.pause(); @@ -6100,7 +6461,7 @@ async function presetsCheckboxSelector(allPresets, initialSelected) { }; process.once("SIGTERM", onSigterm); - const onData = (key) => { + const onData = (key: string) => { if (key === "\r" || key === "\n") { cleanup(); process.stdout.write("\n"); @@ -6116,7 +6477,9 @@ async function presetsCheckboxSelector(allPresets, initialSelected) { cursor = (cursor + 1) % n; redraw(); } else if (key === " ") { - const name = allPresets[cursor].name; + const currentPreset = allPresets[cursor]; + if (!currentPreset) return; + const name = currentPreset.name; if (selected.has(name)) selected.delete(name); else selected.add(name); redraw(); @@ -6131,11 +6494,19 @@ async function presetsCheckboxSelector(allPresets, initialSelected) { }); } -function computeSetupPresetSuggestions(tierName, options = {}) { +function computeSetupPresetSuggestions( + tierName: string, + options: { + enabledChannels?: string[] | null; + webSearchConfig?: WebSearchConfig | null; + provider?: string | null; + knownPresetNames?: string[] | null; + } = {}, +): string[] { const { enabledChannels = null, webSearchConfig = null, provider = null } = options; const known = Array.isArray(options.knownPresetNames) ? new Set(options.knownPresetNames) : null; const suggestions = tiers.resolveTierPresets(tierName).map((p) => p.name); - const add = (name) => { + const add = (name: string) => { if (suggestions.includes(name)) return; if (known && !known.has(name)) return; suggestions.push(name); @@ -6149,7 +6520,17 @@ function computeSetupPresetSuggestions(tierName, options = {}) { } // eslint-disable-next-line complexity -async function setupPoliciesWithSelection(sandboxName, options = {}) { +async function setupPoliciesWithSelection( + sandboxName: string, + options: { + selectedPresets?: string[] | null; + onSelection?: ((policyPresets: string[]) => void) | null; + webSearchConfig?: WebSearchConfig | null; + enabledChannels?: string[] | null; + provider?: string | null; + knownPresetNames?: string[]; + } = {}, +) { const selectedPresets = Array.isArray(options.selectedPresets) ? options.selectedPresets : null; const onSelection = typeof options.onSelection === "function" ? options.onSelection : null; const webSearchConfig = options.webSearchConfig || null; @@ -6194,13 +6575,13 @@ async function setupPoliciesWithSelection(sandboxName, options = {}) { } if (policyMode === "custom" || policyMode === "list") { - chosen = parsePolicyPresetEnv(process.env.NEMOCLAW_POLICY_PRESETS); + chosen = parsePolicyPresetEnv(process.env.NEMOCLAW_POLICY_PRESETS || ""); if (chosen.length === 0) { console.error(" NEMOCLAW_POLICY_PRESETS is required when NEMOCLAW_POLICY_MODE=custom."); process.exit(1); } } else if (policyMode === "suggested" || policyMode === "default" || policyMode === "auto") { - const envPresets = parsePolicyPresetEnv(process.env.NEMOCLAW_POLICY_PRESETS); + const envPresets = parsePolicyPresetEnv(process.env.NEMOCLAW_POLICY_PRESETS || ""); if (envPresets.length > 0) chosen = envPresets; } else { // #2429: step 8/8 runs after the sandbox is created. Exiting here left @@ -6251,7 +6632,7 @@ async function setupPoliciesWithSelection(sandboxName, options = {}) { process.exit(1); } - const accessByName = {}; + const accessByName: Record = {}; for (const p of resolvedPresets) accessByName[p.name] = p.access; syncPresetSelection(sandboxName, applied, interactiveChoice, accessByName); return interactiveChoice; @@ -6296,7 +6677,7 @@ function syncPresetSelection( } break; } catch (err) { - const message = err && err.message ? err.message : String(err); + const message = err instanceof Error ? err.message : String(err); if (!message.includes("sandbox not found") || attempt === 2) { throw err; } @@ -6318,7 +6699,7 @@ function syncPresetSelection( } break; } catch (err) { - const message = err && err.message ? err.message : String(err); + const message = err instanceof Error ? err.message : String(err); if (!message.includes("sandbox not found") || attempt === 2) { throw err; } @@ -6338,7 +6719,10 @@ const { buildChain, buildControlUiUrls } = dashboardContract; // Parses `openshell forward list` output and returns the sandbox currently // owning `portToStop`, or null. Exported for unit testing — see #2169. // Columns: SANDBOX BIND PORT PID STATUS (whitespace-separated). -function findDashboardForwardOwner(forwardListOutput, portToStop) { +function findDashboardForwardOwner( + forwardListOutput: string | null | undefined, + portToStop: string, +): string | null { if (!forwardListOutput) return null; const portLine = forwardListOutput .split("\n") @@ -6350,7 +6734,10 @@ function findDashboardForwardOwner(forwardListOutput, portToStop) { return portLine ? (portLine.split(/\s+/)[0] ?? null) : null; } -function ensureDashboardForward(sandboxName, chatUiUrl = `http://127.0.0.1:${CONTROL_UI_PORT}`) { +function ensureDashboardForward( + sandboxName: string, + chatUiUrl = `http://127.0.0.1:${CONTROL_UI_PORT}`, +) { const chain = buildChain({ chatUiUrl, isWsl: isWsl() }); const portToStop = String(chain.port); const forwardTarget = chain.forwardTarget; @@ -6363,12 +6750,8 @@ function ensureDashboardForward(sandboxName, chatUiUrl = `http://127.0.0.1:${CON // Match the preflight pattern (printed error + exit) instead of throwing, // so the user sees a clean message rather than a raw Node stack trace // from the top-level IIFE's unhandled rejection. See #2169. - console.error( - ` Port ${portToStop} is already forwarded for sandbox '${portOwner}'.`, - ); - console.error( - ` Set CHAT_UI_URL to a different local port (e.g. http://127.0.0.1:18790)`, - ); + console.error(` Port ${portToStop} is already forwarded for sandbox '${portOwner}'.`); + console.error(` Set CHAT_UI_URL to a different local port (e.g. http://127.0.0.1:18790)`); console.error(` before onboarding a second sandbox.`); process.exit(1); } @@ -6395,7 +6778,7 @@ function ensureDashboardForward(sandboxName, chatUiUrl = `http://127.0.0.1:${CON } } -function findFileRecursive(dir, filename) { +function findFileRecursive(dir: string, filename: string): string | null { if (!fs.existsSync(dir)) return null; const entries = fs.readdirSync(dir, { withFileTypes: true }); for (const e of entries) { @@ -6410,13 +6793,13 @@ function findFileRecursive(dir, filename) { return null; } -function findOpenclawJsonPath(dir) { +function findOpenclawJsonPath(dir: string): string | null { if (!fs.existsSync(dir)) return null; const entries = fs.readdirSync(dir, { withFileTypes: true }); for (const e of entries) { const p = path.join(dir, e.name); if (e.isDirectory()) { - const found = findOpenclawJsonPath(p); + const found: string | null = findOpenclawJsonPath(p); if (found) return found; } else if (e.name === "openclaw.json") { return p; @@ -6438,15 +6821,28 @@ function findOpenclawJsonPath(dir) { * Path 2 works because sandbox download runs as the sandbox user, which owns * the non-root token file. */ -function fetchGatewayAuthTokenFromSandbox(sandboxName) { +function fetchGatewayAuthTokenFromSandbox(sandboxName: string): string | null { // 1. Root mode: kubectl exec reads gateway:gateway 0400 file (same as shields.ts) try { - const K3S_CONTAINER = "openshell-cluster-nemoclaw"; - const result = execFileSync("docker", [ - "exec", K3S_CONTAINER, - "kubectl", "exec", "-n", "openshell", sandboxName, "-c", "agent", "--", - "cat", "/run/nemoclaw/gateway-token", - ], { stdio: ["ignore", "pipe", "pipe"], timeout: 15000 }); + const k3sContainer = "openshell-cluster-nemoclaw"; + const result = execFileSync( + "docker", + [ + "exec", + k3sContainer, + "kubectl", + "exec", + "-n", + "openshell", + sandboxName, + "-c", + "agent", + "--", + "cat", + "/run/nemoclaw/gateway-token", + ], + { stdio: ["ignore", "pipe", "pipe"], timeout: 15000 }, + ); const token = result.toString().trim(); if (token.length > 0) return token; } catch { @@ -6492,8 +6888,180 @@ function fetchGatewayAuthTokenFromSandbox(sandboxName) { } } +// buildControlUiUrls — see dashboard-contract import above + +function buildDashboardChain( + chatUiUrl = process.env.CHAT_UI_URL || `http://127.0.0.1:${CONTROL_UI_PORT}`, + options: { + wslHostAddress?: string | null; + runCapture?: typeof runCapture; + env?: NodeJS.ProcessEnv; + platform?: NodeJS.Platform; + release?: string; + isWsl?: boolean; + } = {}, +) { + return buildChain({ + chatUiUrl, + isWsl: isWsl(options), + wslHostAddress: getWslHostAddress(options), + }); +} + +function getDashboardForwardPort( + chatUiUrl = process.env.CHAT_UI_URL || `http://127.0.0.1:${CONTROL_UI_PORT}`, + options: { + wslHostAddress?: string | null; + runCapture?: typeof runCapture; + env?: NodeJS.ProcessEnv; + platform?: NodeJS.Platform; + release?: string; + isWsl?: boolean; + } = {}, +): string { + return String(buildDashboardChain(chatUiUrl, options).port); +} + +function getDashboardForwardTarget( + chatUiUrl = process.env.CHAT_UI_URL || `http://127.0.0.1:${CONTROL_UI_PORT}`, + options: { + wslHostAddress?: string | null; + runCapture?: typeof runCapture; + env?: NodeJS.ProcessEnv; + platform?: NodeJS.Platform; + release?: string; + isWsl?: boolean; + chatUiUrl?: string; + token?: string | null; + } = {}, +): string { + return buildDashboardChain(chatUiUrl, options).forwardTarget; +} + +function getDashboardForwardStartCommand( + sandboxName: string, + options: { + chatUiUrl?: string; + openshellBinary?: string; + wslHostAddress?: string | null; + runCapture?: typeof runCapture; + env?: NodeJS.ProcessEnv; + platform?: NodeJS.Platform; + release?: string; + isWsl?: boolean; + token?: string | null; + } = {}, +): string { + const chatUiUrl = + options.chatUiUrl || process.env.CHAT_UI_URL || `http://127.0.0.1:${CONTROL_UI_PORT}`; + const forwardTarget = getDashboardForwardTarget(chatUiUrl, options); + return `${openshellShellCommand( + ["forward", "start", "--background", forwardTarget, sandboxName], + options, + )}`; +} + +function buildAuthenticatedDashboardUrl(baseUrl: string, token: string | null = null): string { + if (!token) return baseUrl; + return `${baseUrl}#token=${encodeURIComponent(token)}`; +} + +function getWslHostAddress( + options: { + wslHostAddress?: string | null; + runCapture?: typeof runCapture; + env?: NodeJS.ProcessEnv; + platform?: NodeJS.Platform; + release?: string; + isWsl?: boolean; + } = {}, +): string | null { + if (options.wslHostAddress) { + return options.wslHostAddress; + } + if (!isWsl(options)) { + return null; + } + const runCaptureFn = options.runCapture || runCapture; + const output = runCaptureFn("hostname -I 2>/dev/null", { ignoreError: true }); + const candidates = String(output || "") + .trim() + .split(/\s+/) + .filter(Boolean); + return candidates[0] || null; +} + +function getDashboardAccessInfo( + sandboxName: string, + options: { + token?: string | null; + chatUiUrl?: string; + wslHostAddress?: string | null; + runCapture?: typeof runCapture; + env?: NodeJS.ProcessEnv; + platform?: NodeJS.Platform; + release?: string; + isWsl?: boolean; + } = {}, +) { + const token = Object.prototype.hasOwnProperty.call(options, "token") + ? options.token + : fetchGatewayAuthTokenFromSandbox(sandboxName); + const chatUiUrl = + options.chatUiUrl || process.env.CHAT_UI_URL || `http://127.0.0.1:${CONTROL_UI_PORT}`; + const chain = buildDashboardChain(chatUiUrl, options); + const dashboardAccess = buildControlUiUrls(token, chain.port, chain.accessUrl).map( + (url, index) => ({ + label: index === 0 ? "Dashboard" : `Alt ${index}`, + url: buildAuthenticatedDashboardUrl(url, null), + }), + ); + + const wslHostAddress = getWslHostAddress(options); + if (wslHostAddress) { + const wslUrl = buildAuthenticatedDashboardUrl(`http://${wslHostAddress}:${chain.port}/`, token); + if (!dashboardAccess.some((access) => access.url === wslUrl)) { + dashboardAccess.push({ label: "VS Code/WSL", url: wslUrl }); + } + } + + return dashboardAccess; +} + +function getDashboardGuidanceLines( + dashboardAccess: Array<{ label: string; url: string }> = [], + options: { + chatUiUrl?: string; + wslHostAddress?: string | null; + runCapture?: typeof runCapture; + env?: NodeJS.ProcessEnv; + platform?: NodeJS.Platform; + release?: string; + isWsl?: boolean; + } = {}, +): string[] { + const chatUiUrl = + options.chatUiUrl || process.env.CHAT_UI_URL || `http://127.0.0.1:${CONTROL_UI_PORT}`; + const chain = buildDashboardChain(chatUiUrl, options); + const guidance = [`Port ${String(chain.port)} must be forwarded before opening these URLs.`]; + if (isWsl(options)) { + guidance.push( + "WSL detected: if localhost fails in Windows, use the WSL host IP shown by `hostname -I`.", + ); + } + if (dashboardAccess.length === 0) { + guidance.push("No dashboard URLs were generated."); + } + return guidance; +} /** Print the post-onboard dashboard with sandbox status and reconfiguration hints. */ -function printDashboard(sandboxName, model, provider, nimContainer = null, agent = null) { +function printDashboard( + sandboxName: string, + model: string, + provider: string, + nimContainer: string | null = null, + agent: AgentDefinition | null = null, +): void { const nimStat = nimContainer ? nim.nimStatusByName(nimContainer) : nim.nimStatus(sandboxName); const nimLabel = nimStat.running ? "running" : "not running"; @@ -6541,7 +7109,7 @@ function printDashboard(sandboxName, model, provider, nimContainer = null, agent if (agent) { agentOnboard.printDashboardUi(sandboxName, token, agent, { note, - buildControlUiUrls: (tokenValue, port) => { + buildControlUiUrls: (tokenValue: string | null, port: number) => { return buildControlUiUrls(tokenValue, port, chain.accessUrl); }, }); @@ -6564,9 +7132,7 @@ function printDashboard(sandboxName, model, provider, nimContainer = null, agent for (const entry of dashboardAccess) { console.log(` ${entry.label}: ${entry.url}`); } - console.log( - ` Token: see /tmp/gateway.log inside the sandbox, or re-run onboard.`, - ); + console.log(` Token: see /tmp/gateway.log inside the sandbox, or re-run onboard.`); console.log( ` append #token= to the URL, or see /tmp/gateway.log inside the sandbox.`, ); @@ -6574,25 +7140,74 @@ function printDashboard(sandboxName, model, provider, nimContainer = null, agent console.log(` ${"─".repeat(50)}`); console.log(""); console.log(" To change settings later:"); - console.log(` Model: openshell inference set -g nemoclaw --model --provider `); + console.log( + ` Model: openshell inference set -g nemoclaw --model --provider `, + ); console.log(` Policies: nemoclaw ${sandboxName} policy-add`); console.log(" Credentials: nemoclaw credentials reset then nemoclaw onboard"); console.log(""); } -function startRecordedStep(stepName, updates = {}) { +function toOptionalString(value: string | null | undefined): string | undefined { + return value ?? undefined; +} + +function toSessionUpdates( + updates: { + sandboxName?: string | null; + provider?: string | null; + model?: string | null; + endpointUrl?: string | null; + credentialEnv?: string | null; + preferredInferenceApi?: string | null; + nimContainer?: string | null; + webSearchConfig?: WebSearchConfig | null; + policyPresets?: string[] | null; + messagingChannels?: string[] | null; + } = {}, +): SessionUpdates { + const normalized: SessionUpdates = {}; + if (updates.sandboxName !== undefined) + normalized.sandboxName = toOptionalString(updates.sandboxName); + if (updates.provider !== undefined) normalized.provider = toOptionalString(updates.provider); + if (updates.model !== undefined) normalized.model = toOptionalString(updates.model); + if (updates.endpointUrl !== undefined) + normalized.endpointUrl = toOptionalString(updates.endpointUrl); + if (updates.credentialEnv !== undefined) + normalized.credentialEnv = toOptionalString(updates.credentialEnv); + if (updates.preferredInferenceApi !== undefined) { + normalized.preferredInferenceApi = toOptionalString(updates.preferredInferenceApi); + } + if (updates.nimContainer !== undefined) + normalized.nimContainer = toOptionalString(updates.nimContainer); + if (updates.webSearchConfig !== undefined) normalized.webSearchConfig = updates.webSearchConfig; + if (updates.policyPresets) normalized.policyPresets = updates.policyPresets; + if (updates.messagingChannels) normalized.messagingChannels = updates.messagingChannels; + return normalized; +} + +function startRecordedStep( + stepName: string, + updates: { + sandboxName?: string | null; + provider?: string | null; + model?: string | null; + policyPresets?: string[] | null; + } = {}, +): void { onboardSession.markStepStarted(stepName); if (Object.keys(updates).length > 0) { - onboardSession.updateSession((session) => { - if (typeof updates.sandboxName === "string") session.sandboxName = updates.sandboxName; - if (typeof updates.provider === "string") session.provider = updates.provider; - if (typeof updates.model === "string") session.model = updates.model; + onboardSession.updateSession((session: Session) => { + if (updates.sandboxName !== undefined) session.sandboxName = updates.sandboxName; + if (updates.provider !== undefined) session.provider = updates.provider; + if (updates.model !== undefined) session.model = updates.model; + if (updates.policyPresets) session.policyPresets = updates.policyPresets; return session; }); } } -const ONBOARD_STEP_INDEX = { +const ONBOARD_STEP_INDEX: Record = { preflight: { number: 1, title: "Preflight checks" }, gateway: { number: 2, title: "Starting OpenShell gateway" }, provider_selection: { number: 3, title: "Configuring inference (NIM)" }, @@ -6603,7 +7218,11 @@ const ONBOARD_STEP_INDEX = { policies: { number: 8, title: "Policy presets" }, }; -function skippedStepMessage(stepName, detail, reason = "resume") { +function skippedStepMessage( + stepName: string, + detail?: string | null, + reason: "resume" | "reuse" = "resume", +): void { const stepInfo = ONBOARD_STEP_INDEX[stepName]; if (stepInfo) { step(stepInfo.number, 8, stepInfo.title); @@ -6615,7 +7234,7 @@ function skippedStepMessage(stepName, detail, reason = "resume") { // ── Main ───────────────────────────────────────────────────────── // eslint-disable-next-line complexity -async function onboard(opts = {}) { +async function onboard(opts: OnboardOptions = {}): Promise { NON_INTERACTIVE = opts.nonInteractive || process.env.NEMOCLAW_NON_INTERACTIVE === "1"; RECREATE_SANDBOX = opts.recreateSandbox || process.env.NEMOCLAW_RECREATE_SANDBOX === "1"; const dangerouslySkipPermissions = @@ -6676,12 +7295,12 @@ async function onboard(opts = {}) { process.once("exit", releaseOnboardLock); try { - let session; - let selectedMessagingChannels = []; + let session: Session | null; + let selectedMessagingChannels: string[] = []; // Merged, absolute fromDockerfile: explicit flag/env takes precedence; on // resume falls back to what the original session recorded so the same image // is used even when --from is omitted from the resume invocation. - let fromDockerfile; + let fromDockerfile: string | null; if (resume) { session = onboardSession.loadSession(); if (!session || session.resumable === false) { @@ -6736,7 +7355,7 @@ async function onboard(opts = {}) { console.error(" Or rerun with the original settings to continue that session."); process.exit(1); } - onboardSession.updateSession((current) => { + onboardSession.updateSession((current: Session) => { current.mode = isNonInteractive() ? "non-interactive" : "interactive"; current.failure = null; current.status = "in_progress"; @@ -6772,7 +7391,7 @@ async function onboard(opts = {}) { const agent = agentOnboard.resolveAgent({ agentFlag: opts.agent, session }); if (agent) { - onboardSession.updateSession((s) => { + onboardSession.updateSession((s: Session) => { s.agent = agent.name; return s; }); @@ -6867,35 +7486,36 @@ async function onboard(opts = {}) { credentialEnv = selection.credentialEnv; preferredInferenceApi = selection.preferredInferenceApi; nimContainer = selection.nimContainer; - onboardSession.markStepComplete("provider_selection", { - sandboxName, - provider, - model, - endpointUrl, - credentialEnv, - preferredInferenceApi, - nimContainer, - }); + onboardSession.markStepComplete( + "provider_selection", + toSessionUpdates({ + sandboxName, + provider, + model, + endpointUrl, + credentialEnv, + preferredInferenceApi, + nimContainer, + }), + ); } + if (typeof provider !== "string" || typeof model !== "string") { + console.error(" Inference selection did not yield a provider/model."); + process.exit(1); + } process.env.NEMOCLAW_OPENSHELL_BIN = getOpenshellBinary(); const resumeInference = - !forceProviderSelection && - resume && - typeof provider === "string" && - typeof model === "string" && - isInferenceRouteReady(provider, model); + !forceProviderSelection && resume && isInferenceRouteReady(provider, model); if (resumeInference) { skippedStepMessage("inference", `${provider} / ${model}`); - if (nimContainer) { + if (nimContainer && sandboxName) { registry.updateSandbox(sandboxName, { nimContainer }); } - onboardSession.markStepComplete("inference", { - sandboxName, - provider, - model, - nimContainer, - }); + onboardSession.markStepComplete( + "inference", + toSessionUpdates({ sandboxName, provider, model, nimContainer }), + ); break; } @@ -6913,8 +7533,7 @@ async function onboard(opts = {}) { model, credentialEnv, webSearchConfig, - enabledChannels: - selectedMessagingChannels.length > 0 ? selectedMessagingChannels : null, + enabledChannels: selectedMessagingChannels.length > 0 ? selectedMessagingChannels : null, sandboxName, notes: ["Sandbox build takes ~6 minutes on this host."], }), @@ -6926,9 +7545,7 @@ async function onboard(opts = {}) { .toLowerCase(); if (answer === "n" || answer === "no") { console.log(" Aborted. Re-run `nemoclaw onboard` to start over."); - console.log( - " Credentials entered so far are stored in ~/.nemoclaw/credentials.json —", - ); + console.log(" Credentials entered so far are stored in ~/.nemoclaw/credentials.json —"); console.log( " clear them with `nemoclaw credentials reset ` if you no longer want them.", ); @@ -6949,10 +7566,13 @@ async function onboard(opts = {}) { forceProviderSelection = true; continue; } - if (nimContainer) { + if (nimContainer && sandboxName) { registry.updateSandbox(sandboxName, { nimContainer }); } - onboardSession.markStepComplete("inference", { sandboxName, provider, model, nimContainer }); + onboardSession.markStepComplete( + "inference", + toSessionUpdates({ sandboxName, provider, model, nimContainer }), + ); break; } @@ -7001,10 +7621,14 @@ async function onboard(opts = {}) { } startRecordedStep("sandbox", { sandboxName, provider, model }); selectedMessagingChannels = await setupMessagingChannels(); - onboardSession.updateSession((current) => { + onboardSession.updateSession((current: Session) => { current.messagingChannels = selectedMessagingChannels; return current; }); + if (typeof model !== "string" || typeof provider !== "string") { + console.error(" Inference selection is incomplete; cannot create sandbox."); + process.exit(1); + } sandboxName = await createSandbox( gpu, model, @@ -7022,13 +7646,19 @@ async function onboard(opts = {}) { // updateSandbox() silently no-ops when the entry is missing, so this must // run after createSandbox() / registerSandbox() — not before. Fixes #1881. registry.updateSandbox(sandboxName, { model, provider }); - onboardSession.markStepComplete("sandbox", { - sandboxName, - provider, - model, - nimContainer, - webSearchConfig, - }); + onboardSession.markStepComplete( + "sandbox", + toSessionUpdates({ sandboxName, provider, model, nimContainer, webSearchConfig }), + ); + } + + if ( + typeof sandboxName !== "string" || + typeof provider !== "string" || + typeof model !== "string" + ) { + console.error(" Onboarding state is incomplete after sandbox setup."); + process.exit(1); } if (agent) { @@ -7048,11 +7678,17 @@ async function onboard(opts = {}) { const resumeOpenclaw = resume && sandboxName && isOpenclawReady(sandboxName); if (resumeOpenclaw) { skippedStepMessage("openclaw", sandboxName); - onboardSession.markStepComplete("openclaw", { sandboxName, provider, model }); + onboardSession.markStepComplete( + "openclaw", + toSessionUpdates({ sandboxName, provider, model }), + ); } else { startRecordedStep("openclaw", { sandboxName, provider, model }); await setupOpenclaw(sandboxName, model, provider); - onboardSession.markStepComplete("openclaw", { sandboxName, provider, model }); + onboardSession.markStepComplete( + "openclaw", + toSessionUpdates({ sandboxName, provider, model }), + ); } onboardSession.markStepSkipped("agent_setup"); } @@ -7071,23 +7707,24 @@ async function onboard(opts = {}) { process.exit(1); } shields.shieldsDownPermanent(sandboxName); - onboardSession.markStepComplete("policies", { - sandboxName, - provider, - model, - policyPresets: [], - }); + onboardSession.markStepComplete( + "policies", + toSessionUpdates({ sandboxName, provider, model, policyPresets: [] }), + ); } else { const resumePolicies = resume && sandboxName && arePolicyPresetsApplied(sandboxName, recordedPolicyPresets || []); if (resumePolicies) { skippedStepMessage("policies", (recordedPolicyPresets || []).join(", ")); - onboardSession.markStepComplete("policies", { - sandboxName, - provider, - model, - policyPresets: recordedPolicyPresets || [], - }); + onboardSession.markStepComplete( + "policies", + toSessionUpdates({ + sandboxName, + provider, + model, + policyPresets: recordedPolicyPresets || [], + }), + ); } else { startRecordedStep("policies", { sandboxName, @@ -7107,22 +7744,20 @@ async function onboard(opts = {}) { webSearchConfig, provider, onSelection: (policyPresets) => { - onboardSession.updateSession((current) => { + onboardSession.updateSession((current: Session) => { current.policyPresets = policyPresets; return current; }); }, }); - onboardSession.markStepComplete("policies", { - sandboxName, - provider, - model, - policyPresets: appliedPolicyPresets, - }); + onboardSession.markStepComplete( + "policies", + toSessionUpdates({ sandboxName, provider, model, policyPresets: appliedPolicyPresets }), + ); } } - onboardSession.completeSession({ sandboxName, provider, model }); + onboardSession.completeSession(toSessionUpdates({ sandboxName, provider, model })); completed = true; printDashboard(sandboxName, model, provider, nimContainer, agent); } finally { diff --git a/src/lib/openshell.test.ts b/src/lib/openshell.test.ts index f50d16c1647..066c968be91 100644 --- a/src/lib/openshell.test.ts +++ b/src/lib/openshell.test.ts @@ -1,17 +1,48 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import type { SpawnSyncReturns } from "node:child_process"; + import { describe, expect, it } from "vitest"; import { captureOpenshellCommand, getInstalledOpenshellVersion, + type OpenshellSpawnSync, parseVersionFromText, runOpenshellCommand, stripAnsi, versionGte, } from "./openshell"; +interface SpawnResultSpec { + status: number | null; + stdout: string; + stderr: string; + error?: Error; + signal?: NodeJS.Signals | null; +} + +function makeSpawnResult(spec: SpawnResultSpec): SpawnSyncReturns { + return { + pid: 123, + output: [spec.stdout, spec.stderr], + stdout: spec.stdout, + stderr: spec.stderr, + status: spec.status, + signal: spec.signal ?? null, + error: spec.error, + }; +} + +function stubSpawnSync(spec: SpawnResultSpec): OpenshellSpawnSync { + return () => makeSpawnResult(spec); +} + +function exitWithCode(code: number): never { + throw new Error(`exit:${code}`); +} + describe("openshell helpers", () => { it("strips ANSI sequences", () => { expect(stripAnsi("\u001b[32mConnected\u001b[0m")).toBe("Connected"); @@ -31,11 +62,11 @@ describe("openshell helpers", () => { it("captures stdout and stderr like the legacy helper", () => { const result = captureOpenshellCommand("openshell", ["status"], { - spawnSyncImpl: (() => ({ + spawnSyncImpl: stubSpawnSync({ status: 1, stdout: "hello\n", stderr: "boom\n", - })) as never, + }), }); expect(result).toEqual({ status: 1, output: "hello\nboom" }); }); @@ -43,22 +74,22 @@ describe("openshell helpers", () => { it("omits stderr from capture output when ignoreError is set", () => { const result = captureOpenshellCommand("openshell", ["status"], { ignoreError: true, - spawnSyncImpl: (() => ({ + spawnSyncImpl: stubSpawnSync({ status: 1, stdout: "hello\n", stderr: "boom\n", - })) as never, + }), }); expect(result).toEqual({ status: 1, output: "hello" }); }); it("returns the spawn result when the command succeeds", () => { const result = runOpenshellCommand("openshell", ["status"], { - spawnSyncImpl: (() => ({ + spawnSyncImpl: stubSpawnSync({ status: 0, stdout: "ok\n", stderr: "", - })) as never, + }), }); expect(result.status).toBe(0); }); @@ -66,15 +97,13 @@ describe("openshell helpers", () => { it("uses the injected exit handler on failure", () => { expect(() => runOpenshellCommand("openshell", ["status"], { - spawnSyncImpl: (() => ({ + spawnSyncImpl: stubSpawnSync({ status: 17, stdout: "", stderr: "bad\n", - })) as never, + }), errorLine: () => {}, - exit: ((code: number) => { - throw new Error(`exit:${code}`); - }) as never, + exit: exitWithCode, }), ).toThrow("exit:17"); }); @@ -83,16 +112,14 @@ describe("openshell helpers", () => { const errors: string[] = []; expect(() => runOpenshellCommand("openshell", ["status"], { - spawnSyncImpl: (() => ({ + spawnSyncImpl: stubSpawnSync({ status: null, stdout: "", stderr: "", error: new Error("spawn EACCES"), - })) as never, + }), errorLine: (message) => errors.push(message), - exit: ((code: number) => { - throw new Error(`exit:${code}`); - }) as never, + exit: exitWithCode, }), ).toThrow("exit:1"); expect(errors).toEqual([" Failed to start openshell status: spawn EACCES"]); @@ -102,16 +129,14 @@ describe("openshell helpers", () => { const errors: string[] = []; expect(() => captureOpenshellCommand("openshell", ["status"], { - spawnSyncImpl: (() => ({ + spawnSyncImpl: stubSpawnSync({ status: null, stdout: "", stderr: "", error: new Error("spawn ENOENT"), - })) as never, + }), errorLine: (message) => errors.push(message), - exit: ((code: number) => { - throw new Error(`exit:${code}`); - }) as never, + exit: exitWithCode, }), ).toThrow("exit:1"); expect(errors).toEqual([" Failed to start openshell status: spawn ENOENT"]); @@ -119,11 +144,11 @@ describe("openshell helpers", () => { it("reads the installed openshell version through the capture helper", () => { const version = getInstalledOpenshellVersion("openshell", { - spawnSyncImpl: (() => ({ + spawnSyncImpl: stubSpawnSync({ status: 0, stdout: "openshell 0.0.11\n", stderr: "", - })) as never, + }), }); expect(version).toBe("0.0.11"); }); diff --git a/src/lib/openshell.ts b/src/lib/openshell.ts index 91e56f9f5d2..82161a8b175 100644 --- a/src/lib/openshell.ts +++ b/src/lib/openshell.ts @@ -4,13 +4,20 @@ import { spawnSync, type SpawnSyncOptions, + type SpawnSyncOptionsWithStringEncoding, type SpawnSyncReturns, } from "node:child_process"; +export type OpenshellSpawnSync = ( + command: string, + args: readonly string[], + options: SpawnSyncOptionsWithStringEncoding, +) => SpawnSyncReturns; + interface OpenshellSpawnOptions { cwd?: string; env?: NodeJS.ProcessEnv; - spawnSyncImpl?: typeof spawnSync; + spawnSyncImpl?: OpenshellSpawnSync; errorLine?: (message: string) => void; exit?: (code: number) => never; } diff --git a/src/lib/policies.ts b/src/lib/policies.ts index 8529fefe82d..08e0e2da743 100644 --- a/src/lib/policies.ts +++ b/src/lib/policies.ts @@ -1,4 +1,3 @@ -// @ts-nocheck // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 // @@ -15,12 +14,35 @@ const { loadAgent } = require("./agent-defs"); const PRESETS_DIR = path.join(ROOT, "nemoclaw-blueprint", "policies", "presets"); -function listPresets() { +type PresetInfo = { + file: string; + name: string; + description: string; +}; + +type PolicyScalar = string | number | boolean | null | undefined; +type PolicyValue = PolicyScalar | PolicyObject | PolicyValue[]; +type PolicyObject = { [key: string]: PolicyValue }; + +type PolicyDocument = PolicyObject & { + version?: number; + network_policies?: PolicyObject; +}; + +type SelectionOptions = { + applied?: string[]; +}; + +function isPolicyDocument(value: PolicyValue): value is PolicyDocument { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function listPresets(): PresetInfo[] { if (!fs.existsSync(PRESETS_DIR)) return []; return fs .readdirSync(PRESETS_DIR) - .filter((f) => f.endsWith(".yaml")) - .map((f) => { + .filter((f: string) => f.endsWith(".yaml")) + .map((f: string) => { const content = fs.readFileSync(path.join(PRESETS_DIR, f), "utf-8"); const nameMatch = content.match(/^\s*name:\s*(.+)$/m); const descMatch = content.match(/^\s*description:\s*"?([^"]*)"?$/m); @@ -32,7 +54,7 @@ function listPresets() { }); } -function loadPreset(name) { +function loadPreset(name: string): string | null { const file = path.resolve(PRESETS_DIR, `${name}.yaml`); if (!file.startsWith(PRESETS_DIR + path.sep) && file !== PRESETS_DIR) { console.error(` Invalid preset name: ${name}`); @@ -45,8 +67,8 @@ function loadPreset(name) { return fs.readFileSync(file, "utf-8"); } -function getPresetEndpoints(content) { - const hosts = []; +function getPresetEndpoints(content: string): string[] { + const hosts: string[] = []; const regex = /host:\s*([^\s,}]+)/g; let match; while ((match = regex.exec(content)) !== null) { @@ -60,7 +82,7 @@ function getPresetEndpoints(content) { * the `network_policies:` key) from a preset file, stripping the * `preset:` metadata header. */ -function extractPresetEntries(presetContent) { +function extractPresetEntries(presetContent: string | null | undefined): string | null { if (!presetContent) return null; const npMatch = presetContent.match(/^network_policies:\n([\s\S]*)$/m); if (!npMatch) return null; @@ -71,7 +93,7 @@ function extractPresetEntries(presetContent) { * Parse the output of `openshell policy get --full` which has a metadata * header (Version, Hash, etc.) followed by `---` and then the actual YAML. */ -function parseCurrentPolicy(raw) { +function parseCurrentPolicy(raw: string | null | undefined): string { if (!raw) return ""; const sep = raw.indexOf("---"); const candidate = (sep === -1 ? raw : raw.slice(sep + 3)).trim(); @@ -96,7 +118,7 @@ function parseCurrentPolicy(raw) { /** * Build the openshell policy set command as an argv array. */ -function buildPolicySetCommand(policyFile, sandboxName) { +function buildPolicySetCommand(policyFile: string, sandboxName: string): string[] { const binary = process.env.NEMOCLAW_OPENSHELL_BIN || "openshell"; return [binary, "policy", "set", "--policy", policyFile, "--wait", sandboxName]; } @@ -104,7 +126,7 @@ function buildPolicySetCommand(policyFile, sandboxName) { /** * Build the openshell policy get command as an argv array. */ -function buildPolicyGetCommand(sandboxName) { +function buildPolicyGetCommand(sandboxName: string): string[] { const binary = process.env.NEMOCLAW_OPENSHELL_BIN || "openshell"; return [binary, "policy", "get", "--full", sandboxName]; } @@ -113,7 +135,7 @@ function buildPolicyGetCommand(sandboxName) { * Text-based fallback for merging preset entries into policy YAML. * Used when preset entries cannot be parsed as structured YAML. */ -function textBasedMerge(currentPolicy, presetEntries) { +function textBasedMerge(currentPolicy: string, presetEntries: string): string { if (!currentPolicy) { return "version: 1\n\nnetwork_policies:\n" + presetEntries; } @@ -160,7 +182,7 @@ function textBasedMerge(currentPolicy, presetEntries) { * @param {string} presetEntries - Indented network_policies entries from preset * @returns {string} Merged YAML */ -function mergePresetIntoPolicy(currentPolicy, presetEntries) { +function mergePresetIntoPolicy(currentPolicy: string, presetEntries: string): string { const normalizedCurrentPolicy = parseCurrentPolicy(currentPolicy); if (!presetEntries) { return normalizedCurrentPolicy || "version: 1\n\nnetwork_policies:\n"; @@ -188,15 +210,14 @@ function mergePresetIntoPolicy(currentPolicy, presetEntries) { } // Parse the current policy as structured YAML - let current; + let current: PolicyDocument | null; try { - current = YAML.parse(normalizedCurrentPolicy); + const parsed = YAML.parse(normalizedCurrentPolicy); + current = isPolicyDocument(parsed) ? parsed : {}; } catch { return textBasedMerge(normalizedCurrentPolicy, presetEntries); } - if (!current || typeof current !== "object") current = {}; - // Structured merge: preset entries override existing on name collision. // Guard: network_policies may be an array in legacy policies — only // object-merge when both sides are plain objects. @@ -208,7 +229,7 @@ function mergePresetIntoPolicy(currentPolicy, presetEntries) { mergedNp = presetPolicies; } - const output = { version: current.version || 1 }; + const output: PolicyDocument = { version: Number(current.version) || 1 }; for (const [key, val] of Object.entries(current)) { if (key !== "version" && key !== "network_policies") output[key] = val; } @@ -223,10 +244,13 @@ function mergePresetIntoPolicy(currentPolicy, presetEntries) { * removes them, and returns the resulting YAML. * * @param {string} currentPolicy - Existing policy YAML - * @param {string} presetEntries - Indented network_policies entries from preset + * @param {string | null | undefined} presetEntries - Indented network_policies entries from preset * @returns {string} Policy YAML with the preset's entries removed */ -function removePresetFromPolicy(currentPolicy, presetEntries) { +function removePresetFromPolicy( + currentPolicy: string, + presetEntries: string | null | undefined, +): string { const normalizedCurrentPolicy = parseCurrentPolicy(currentPolicy); if (!presetEntries) { return normalizedCurrentPolicy || "version: 1\n\nnetwork_policies:\n"; @@ -237,13 +261,11 @@ function removePresetFromPolicy(currentPolicy, presetEntries) { // Parse preset entries to extract the network_policies key names. // They come as indented content under network_policies:, // so we wrap them to make valid YAML for parsing. - let presetKeys; + let presetKeys: string[]; try { const wrapped = "network_policies:\n" + presetEntries; const parsed = YAML.parse(wrapped); - presetKeys = parsed?.network_policies - ? Object.keys(parsed.network_policies) - : []; + presetKeys = parsed?.network_policies ? Object.keys(parsed.network_policies) : []; } catch { presetKeys = []; } @@ -251,14 +273,15 @@ function removePresetFromPolicy(currentPolicy, presetEntries) { if (presetKeys.length === 0) return normalizedCurrentPolicy; // Parse the current policy as structured YAML - let current; + let current: PolicyDocument | null; try { - current = YAML.parse(normalizedCurrentPolicy); + const parsed = YAML.parse(normalizedCurrentPolicy); + current = isPolicyDocument(parsed) ? parsed : null; } catch { return normalizedCurrentPolicy; } - if (!current || typeof current !== "object") return normalizedCurrentPolicy; + if (!current) return normalizedCurrentPolicy; // Guard: network_policies may be an array in legacy policies — only // delete keys when it is a plain object. @@ -275,7 +298,7 @@ function removePresetFromPolicy(currentPolicy, presetEntries) { return YAML.stringify(current); } -function removePreset(sandboxName, presetName) { +function removePreset(sandboxName: string, presetName: string): boolean { // Guard against truncated sandbox names — WSL can truncate hyphenated // names during argument parsing, e.g. "my-assistant" → "m" const isRfc1123Label = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/.test(sandboxName); @@ -346,15 +369,18 @@ function removePreset(sandboxName, presetName) { const sandbox = registry.getSandbox(sandboxName); if (sandbox) { - const pols = (sandbox.policies || []).filter((p) => p !== presetName); + const pols = (sandbox.policies || []).filter((p: string) => p !== presetName); registry.updateSandbox(sandboxName, { policies: pols }); } return true; } -function selectForRemoval(items, { applied = [] } = {}) { - return new Promise((resolve) => { +function selectForRemoval( + items: PresetInfo[], + { applied = [] }: SelectionOptions = {}, +): Promise { + return new Promise((resolve) => { const appliedItems = items.filter((item) => applied.includes(item.name)); if (appliedItems.length === 0) { process.stderr.write("\n No presets are currently applied.\n\n"); @@ -369,7 +395,7 @@ function selectForRemoval(items, { applied = [] } = {}) { process.stderr.write("\n"); const question = " Choose preset to remove: "; const rl = readline.createInterface({ input: process.stdin, output: process.stderr }); - rl.question(question, (answer) => { + rl.question(question, (answer: string) => { rl.close(); if (!process.stdin.isTTY) { if (typeof process.stdin.pause === "function") process.stdin.pause(); @@ -397,7 +423,7 @@ function selectForRemoval(items, { applied = [] } = {}) { }); } -function applyPreset(sandboxName, presetName, _options = {}) { +function applyPreset(sandboxName: string, presetName: string, _options = {}): boolean { // Guard against truncated sandbox names — WSL can truncate hyphenated // names during argument parsing, e.g. "my-assistant" → "m" const isRfc1123Label = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/.test(sandboxName); @@ -469,7 +495,7 @@ function applyPreset(sandboxName, presetName, _options = {}) { return true; } -function getAppliedPresets(sandboxName) { +function getAppliedPresets(sandboxName: string): string[] { const sandbox = registry.getSandbox(sandboxName); return sandbox ? sandbox.policies || [] : []; } @@ -485,7 +511,7 @@ function getAppliedPresets(sandboxName) { * `null` to distinguish "gateway unreachable" from "gateway has no * matching presets" (`[]`). */ -function getGatewayPresets(sandboxName) { +function getGatewayPresets(sandboxName: string): string[] | null { let rawPolicy = ""; try { rawPolicy = runCapture(buildPolicyGetCommand(sandboxName), { ignoreError: true }); @@ -543,8 +569,11 @@ function getGatewayPresets(sandboxName) { return matched; } -function selectFromList(items, { applied = [] } = {}) { - return new Promise((resolve) => { +function selectFromList( + items: PresetInfo[], + { applied = [] }: SelectionOptions = {}, +): Promise { + return new Promise((resolve) => { process.stderr.write("\n Available presets:\n"); items.forEach((item, i) => { const marker = applied.includes(item.name) ? "●" : "○"; @@ -556,7 +585,7 @@ function selectFromList(items, { applied = [] } = {}) { const defaultNum = defaultIdx >= 0 ? defaultIdx + 1 : null; const question = defaultNum ? ` Choose preset [${defaultNum}]: ` : " Choose preset: "; const rl = readline.createInterface({ input: process.stdin, output: process.stderr }); - rl.question(question, (answer) => { + rl.question(question, (answer: string) => { rl.close(); if (!process.stdin.isTTY) { if (typeof process.stdin.pause === "function") process.stdin.pause(); @@ -597,7 +626,7 @@ const PERMISSIVE_POLICY_PATH = path.join( "openclaw-sandbox-permissive.yaml", ); -function resolvePermissivePolicyPath(sandboxName) { +function resolvePermissivePolicyPath(sandboxName: string): string { // Use agent-specific permissive policy if the sandbox has an agent with one. try { const sandbox = registry.getSandbox(sandboxName); @@ -615,7 +644,7 @@ function resolvePermissivePolicyPath(sandboxName) { return PERMISSIVE_POLICY_PATH; } -function applyPermissivePolicy(sandboxName) { +function applyPermissivePolicy(sandboxName: string): void { const isRfc1123Label = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/.test(sandboxName); if (!sandboxName || sandboxName.length > 63 || !isRfc1123Label) { throw new Error( diff --git a/src/lib/preflight.test.ts b/src/lib/preflight.test.ts index e385b1bfa55..d623f3e3208 100644 --- a/src/lib/preflight.test.ts +++ b/src/lib/preflight.test.ts @@ -15,6 +15,14 @@ import { probeContainerDns, } from "../../dist/lib/preflight"; +function requireMemoryInfo(result: ReturnType) { + expect(result).not.toBeNull(); + if (!result) { + throw new Error("Expected memory info to be present"); + } + return result; +} + describe("checkPortAvailable", () => { it("falls through to the probe when lsof output is empty", async () => { let probedPort: number | null = null; @@ -101,7 +109,7 @@ describe("checkPortAvailable", () => { const result = await checkPortAvailable(8080, { skipLsof: true, probeImpl: async () => ({ - ok: true as const, + ok: true, warning: "port probe skipped: listen EPERM: operation not permitted 127.0.0.1", }), }); @@ -157,7 +165,7 @@ describe("probePortAvailability", () => { probeImpl: async (port: number) => { called = true; expect(port).toBe(9999); - return { ok: true as const }; + return { ok: true }; }, }); expect(called).toBe(true); @@ -215,11 +223,10 @@ describe("getMemoryInfo", () => { "SwapFree: 4194300 kB", ].join("\n"); - const result = getMemoryInfo({ meminfoContent, platform: "linux" }); - expect(result).not.toBeNull(); - expect(result!.totalRamMB).toBe(Math.floor(8152056 / 1024)); - expect(result!.totalSwapMB).toBe(Math.floor(4194300 / 1024)); - expect(result!.totalMB).toBe(result!.totalRamMB + result!.totalSwapMB); + const result = requireMemoryInfo(getMemoryInfo({ meminfoContent, platform: "linux" })); + expect(result.totalRamMB).toBe(Math.floor(8152056 / 1024)); + expect(result.totalSwapMB).toBe(Math.floor(4194300 / 1024)); + expect(result.totalMB).toBe(result.totalRamMB + result.totalSwapMB); }); it("returns correct values when swap is zero", () => { @@ -230,11 +237,10 @@ describe("getMemoryInfo", () => { "SwapFree: 0 kB", ].join("\n"); - const result = getMemoryInfo({ meminfoContent, platform: "linux" }); - expect(result).not.toBeNull(); - expect(result!.totalRamMB).toBe(Math.floor(8152056 / 1024)); - expect(result!.totalSwapMB).toBe(0); - expect(result!.totalMB).toBe(result!.totalRamMB); + const result = requireMemoryInfo(getMemoryInfo({ meminfoContent, platform: "linux" })); + expect(result.totalRamMB).toBe(Math.floor(8152056 / 1024)); + expect(result.totalSwapMB).toBe(0); + expect(result.totalMB).toBe(result.totalRamMB); }); it("returns null on unsupported platforms", () => { @@ -255,14 +261,15 @@ describe("getMemoryInfo", () => { }); it("handles malformed /proc/meminfo gracefully", () => { - const result = getMemoryInfo({ - meminfoContent: "garbage data\nno fields here", - platform: "linux", - }); - expect(result).not.toBeNull(); - expect(result!.totalRamMB).toBe(0); - expect(result!.totalSwapMB).toBe(0); - expect(result!.totalMB).toBe(0); + const result = requireMemoryInfo( + getMemoryInfo({ + meminfoContent: "garbage data\nno fields here", + platform: "linux", + }), + ); + expect(result.totalRamMB).toBe(0); + expect(result.totalSwapMB).toBe(0); + expect(result.totalMB).toBe(0); }); }); @@ -303,7 +310,8 @@ describe("assessHost", () => { CgroupVersion: "2", }), readFileImpl: () => '{"default-cgroupns-mode":"private"}', - commandExistsImpl: (name: string) => name === "docker" || name === "apt-get" || name === "systemctl", + commandExistsImpl: (name: string) => + name === "docker" || name === "apt-get" || name === "systemctl", runCaptureImpl: (command: string) => { if (command === "command -v apt-get") return "/usr/bin/apt-get"; if (command === "command -v systemctl") return "/usr/bin/systemctl"; @@ -432,7 +440,9 @@ describe("planHostRemediation", () => { notes: [], }); - const action = actions.find((entry: { id: string }) => entry.id === "unsupported_runtime_warning"); + const action = actions.find( + (entry: { id: string }) => entry.id === "unsupported_runtime_warning", + ); expect(action).toBeTruthy(); expect(action?.blocking).toBe(false); }); diff --git a/src/lib/preflight.ts b/src/lib/preflight.ts index cda9aa5b2b7..1b232e6bc38 100644 --- a/src/lib/preflight.ts +++ b/src/lib/preflight.ts @@ -74,12 +74,7 @@ export interface EnsureSwapOpts { getMemoryInfoImpl?: (opts: GetMemoryInfoOpts) => MemoryInfo | null; } -export type ContainerRuntime = - | "docker" - | "docker-desktop" - | "colima" - | "podman" - | "unknown"; +export type ContainerRuntime = "docker" | "docker-desktop" | "colima" | "podman" | "unknown"; export type PackageManager = "apt" | "dnf" | "yum" | "brew" | "pacman" | "unknown"; @@ -188,16 +183,13 @@ function parseDockerInfoSummary(info = ""): string | undefined { function readDockerDefaultCgroupnsMode( readFileImpl: (filePath: string, encoding: BufferEncoding) => string, ): "host" | "private" | "unknown" { - const paths = [ - "/etc/docker/daemon.json", - "/home/rootless/.config/docker/daemon.json", - ]; + const paths = ["/etc/docker/daemon.json", "/home/rootless/.config/docker/daemon.json"]; for (const filePath of paths) { try { const raw = readFileImpl(filePath, "utf-8"); - const parsed = JSON.parse(raw) as { - ["default-cgroupns-mode"]?: unknown; - }; + const parsed: { + ["default-cgroupns-mode"]?: string; + } = JSON.parse(raw); const mode = parsed["default-cgroupns-mode"]; if (mode === "host" || mode === "private") return mode; } catch { @@ -232,7 +224,9 @@ function detectPackageManager( } function parseSystemctlState(value = ""): boolean | null { - const normalized = String(value || "").trim().toLowerCase(); + const normalized = String(value || "") + .trim() + .toLowerCase(); if (!normalized) return null; if (normalized === "active" || normalized === "enabled") return true; if ( @@ -443,7 +437,8 @@ export function planHostRemediation(assessment: HostAssessment): RemediationActi id: "headless_remote_hint", title: "Review remote/headless UI settings", kind: "info", - reason: "Headless Linux hosts often need explicit remote UI handling if you want browser access.", + reason: + "Headless Linux hosts often need explicit remote UI handling if you want browser access.", commands: ["Set `CHAT_UI_URL` when remote browser access matters."], blocking: false, }); @@ -603,7 +598,10 @@ export function getMemoryInfo(opts?: GetMemoryInfoOpts): MemoryInfo | null { if (platform === "darwin") { try { - const memBytes = parseInt(runCapture(["sysctl", "-n", "hw.memsize"], { ignoreError: true }), 10); + const memBytes = parseInt( + runCapture(["sysctl", "-n", "hw.memsize"], { ignoreError: true }), + 10, + ); if (!memBytes || isNaN(memBytes)) return null; const totalRamMB = Math.floor(memBytes / 1024 / 1024); // macOS does not use traditional swap files in the same way @@ -652,7 +650,7 @@ function getExistingSwapResult(mem: MemoryInfo): SwapResult | null { try { runCapture(["sudo", "swapon", "/swapfile"], { ignoreError: false }); return { ok: true, totalMB: mem.totalMB + 4096, swapCreated: true }; - } catch (err: unknown) { + } catch (err) { const message = err instanceof Error ? err.message : String(err); return { ok: false, @@ -705,9 +703,12 @@ function cleanupPartialSwap(): void { function createSwapfile(mem: MemoryInfo): SwapResult { try { - runCapture(["sudo", "dd", "if=/dev/zero", "of=/swapfile", "bs=1M", "count=4096", "status=none"], { - ignoreError: false, - }); + runCapture( + ["sudo", "dd", "if=/dev/zero", "of=/swapfile", "bs=1M", "count=4096", "status=none"], + { + ignoreError: false, + }, + ); runCapture(["sudo", "chmod", "600", "/swapfile"], { ignoreError: false }); runCapture(["sudo", "mkswap", "/swapfile"], { ignoreError: false }); runCapture(["sudo", "swapon", "/swapfile"], { ignoreError: false }); @@ -719,7 +720,7 @@ function createSwapfile(mem: MemoryInfo): SwapResult { writeManagedSwapMarker(); return { ok: true, totalMB: mem.totalMB + 4096, swapCreated: true }; - } catch (err: unknown) { + } catch (err) { cleanupPartialSwap(); const message = err instanceof Error ? err.message : String(err); return { @@ -740,9 +741,16 @@ function createSwapfile(mem: MemoryInfo): SwapResult { * image push. */ export function ensureSwap(minTotalMB?: number, opts: EnsureSwapOpts = {}): SwapResult { - const o = { - platform: process.platform as NodeJS.Platform, - memoryInfo: null as MemoryInfo | null, + const o: { + platform: NodeJS.Platform; + memoryInfo: MemoryInfo | null; + swapfileExists: boolean; + dryRun: boolean; + interactive: boolean; + getMemoryInfoImpl: (opts: GetMemoryInfoOpts) => MemoryInfo | null; + } = { + platform: process.platform, + memoryInfo: null, swapfileExists: fs.existsSync("/swapfile"), dryRun: false, interactive: process.stdout.isTTY && !process.env.NEMOCLAW_NON_INTERACTIVE, diff --git a/src/lib/provider-models.ts b/src/lib/provider-models.ts index 04324164fc8..64e33875605 100644 --- a/src/lib/provider-models.ts +++ b/src/lib/provider-models.ts @@ -20,9 +20,22 @@ export interface ProviderModelOptions { authMode?: "bearer" | "query-param"; } -function parseModelIds(body: string, itemKeys: string[] = ["id"]): string[] { - const parsed = JSON.parse(body) as { data?: Array | null> }; - if (!Array.isArray(parsed?.data)) { +type ModelCatalogItem = { + id?: string | null; + name?: string | null; +}; + +type ModelCatalogResponse = { + data?: Array; +}; + +function parseJson(text: string): T { + return JSON.parse(text); +} + +function parseModelIds(body: string, itemKeys: Array = ["id"]): string[] { + const parsed = parseJson(body); + if (!Array.isArray(parsed.data)) { throw new Error("Unexpected model catalog response: expected a top-level data array"); } return parsed.data @@ -41,7 +54,7 @@ function parseModelIds(body: string, itemKeys: string[] = ["id"]): string[] { function toModelCatalogFetchResult( result: CurlProbeResult, - itemKeys: string[] = ["id"], + itemKeys: Array = ["id"], ): ModelCatalogFetchResult { if (!result.ok) { return { @@ -126,7 +139,10 @@ export function fetchOpenAiLikeModels( const useQueryParam = options.authMode === "query-param"; const normalizedKey = apiKey ? normalizeCredentialValue(apiKey) : ""; const baseUrl = `${String(endpointUrl).replace(/\/+$/, "")}/models`; - const url = useQueryParam && normalizedKey ? `${baseUrl}?key=${encodeURIComponent(normalizedKey)}` : baseUrl; + const url = + useQueryParam && normalizedKey + ? `${baseUrl}?key=${encodeURIComponent(normalizedKey)}` + : baseUrl; try { const result = runCurlProbeImpl([ "-sS", diff --git a/src/lib/registry.ts b/src/lib/registry.ts index 445e20f17df..7975dd30054 100644 --- a/src/lib/registry.ts +++ b/src/lib/registry.ts @@ -6,6 +6,12 @@ import path from "node:path"; import { ensureConfigDir, readConfigFile, writeConfigFile } from "./config-io"; +type ErrnoLike = Error | { code?: string | number } | null; + +function isErrnoException(error: ErrnoLike): error is NodeJS.ErrnoException { + return error !== null && typeof error === "object" && "code" in error; +} + export interface SandboxEntry { name: string; createdAt?: string; @@ -67,8 +73,12 @@ export function acquireLock(): void { } return; } catch (error) { - const err = error as NodeJS.ErrnoException; - if (err.code !== "EEXIST") throw err; + if ( + !(typeof error === "object" && error !== null && isErrnoException(error)) || + error.code !== "EEXIST" + ) { + throw error; + } let ownerChecked = false; try { const ownerPid = Number.parseInt(fs.readFileSync(LOCK_OWNER, "utf-8").trim(), 10); @@ -79,7 +89,10 @@ export function acquireLock(): void { process.kill(ownerPid, 0); alive = true; } catch (killErr) { - alive = (killErr as NodeJS.ErrnoException).code === "EPERM"; + alive = + typeof killErr === "object" && killErr !== null && isErrnoException(killErr) + ? killErr.code === "EPERM" + : false; } if (!alive) { const recheck = Number.parseInt(fs.readFileSync(LOCK_OWNER, "utf-8").trim(), 10); @@ -113,12 +126,22 @@ export function releaseLock(): void { try { fs.unlinkSync(LOCK_OWNER); } catch (error) { - if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + if ( + !(typeof error === "object" && error !== null && isErrnoException(error)) || + error.code !== "ENOENT" + ) { + throw error; + } } try { fs.rmSync(LOCK_DIR, { recursive: true, force: true }); } catch (error) { - if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + if ( + !(typeof error === "object" && error !== null && isErrnoException(error)) || + error.code !== "ENOENT" + ) { + throw error; + } } } @@ -166,8 +189,7 @@ export function registerSandbox(entry: SandboxEntry): void { policies: entry.policies || [], policyTier: entry.policyTier || null, agent: entry.agent || null, - dangerouslySkipPermissions: - entry.dangerouslySkipPermissions === true ? true : undefined, + dangerouslySkipPermissions: entry.dangerouslySkipPermissions === true ? true : undefined, agentVersion: entry.agentVersion || null, imageTag: entry.imageTag || null, providerCredentialHashes: entry.providerCredentialHashes || undefined, diff --git a/src/lib/runner.ts b/src/lib/runner.ts index 6ade01a3abf..2beb5385f82 100644 --- a/src/lib/runner.ts +++ b/src/lib/runner.ts @@ -1,7 +1,13 @@ -// @ts-nocheck // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import type { + ExecSyncOptionsWithStringEncoding, + SpawnSyncOptions, + SpawnSyncOptionsWithStringEncoding, + SpawnSyncReturns, +} from "node:child_process"; + const { execSync, spawnSync } = require("child_process"); const path = require("path"); const { detectDockerHost } = require("./platform"); @@ -9,12 +15,29 @@ const { detectDockerHost } = require("./platform"); const ROOT = path.resolve(__dirname, "..", ".."); const SCRIPTS = path.join(ROOT, "scripts"); +type RunnerScalar = string | number | boolean | null | undefined; + +type RunnerOptions = SpawnSyncOptions & { + ignoreError?: boolean; + suppressOutput?: boolean; +}; + +type CaptureOptions = Omit & { + ignoreError?: boolean; +}; + +type ArrayCaptureOptions = Omit & { + ignoreError?: boolean; +}; + +type SpawnResult = SpawnSyncReturns; + const dockerHost = detectDockerHost(); if (dockerHost) { process.env.DOCKER_HOST = dockerHost.dockerHost; } -function logOpenshellRuntimeHint(file, renderedCommand = "") { +function logOpenshellRuntimeHint(file: string, renderedCommand = ""): void { if ( file === "openshell" || file?.endsWith("/openshell") || @@ -29,7 +52,13 @@ function logOpenshellRuntimeHint(file, renderedCommand = "") { * Spawn a command, streaming stdout/stderr (redacted) to the terminal. * Exits the process on failure unless opts.ignoreError is true. */ -function spawnAndHandle(file, args, opts = {}, stdio, renderedCommand) { +function spawnAndHandle( + file: string, + args: readonly string[], + opts: RunnerOptions = {}, + stdio: RunnerOptions["stdio"], + renderedCommand: string, +): SpawnResult { const result = spawnSync(file, args, { ...opts, stdio, @@ -66,19 +95,20 @@ function spawnAndHandle(file, args, opts = {}, stdio, renderedCommand) { * When an argv array is passed, the shell option is forbidden to prevent * callers from accidentally re-enabling shell interpretation. */ -function run(cmd, opts = {}) { +function run(cmd: string | readonly string[], opts: RunnerOptions = {}): SpawnResult { if (Array.isArray(cmd)) { return runArrayCmd(cmd, opts); } + const shellCmd = String(cmd); const stdio = opts.stdio ?? ["ignore", "pipe", "pipe"]; - return spawnAndHandle("bash", ["-c", cmd], opts, stdio, cmd); + return spawnAndHandle("bash", ["-c", shellCmd], opts, stdio, shellCmd); } /** * Internal: execute an argv array via spawnSync with no shell. * Shared by run() and kept separate for clarity. */ -function runArrayCmd(cmd, opts = {}) { +function runArrayCmd(cmd: readonly string[], opts: RunnerOptions = {}): SpawnResult { if (cmd.length === 0) { throw new Error("run: argv array must not be empty"); } @@ -123,7 +153,7 @@ function runArrayCmd(cmd, opts = {}) { * Run a shell command interactively (stdin inherited) while capturing and redacting stdout/stderr. * Exits the process on failure unless opts.ignoreError is true. */ -function runInteractive(cmd, opts = {}) { +function runInteractive(cmd: string, opts: RunnerOptions = {}): SpawnResult { const stdio = opts.stdio ?? ["inherit", "pipe", "pipe"]; return spawnAndHandle("bash", ["-c", cmd], opts, stdio, cmd); } @@ -132,7 +162,11 @@ function runInteractive(cmd, opts = {}) { * Run a program directly with argv-style arguments, bypassing shell parsing. * Exits the process on failure unless opts.ignoreError is true. */ -function runFile(file, args = [], opts = {}) { +function runFile( + file: string, + args: readonly (string | number | boolean)[] = [], + opts: RunnerOptions = {}, +): SpawnResult { if (opts.shell) { throw new Error("runFile does not allow opts.shell=true"); } @@ -153,12 +187,13 @@ function runFile(file, args = [], opts = {}) { * When an argv array is passed, the shell option is forbidden to prevent * callers from accidentally re-enabling shell interpretation. */ -function runCapture(cmd, opts = {}) { +function runCapture(cmd: string | readonly string[], opts: CaptureOptions = {}): string { if (Array.isArray(cmd)) { return runArrayCapture(cmd, opts); } + const shellCmd = String(cmd); try { - return execSync(cmd, { + return execSync(shellCmd, { ...opts, encoding: "utf-8", cwd: ROOT, @@ -175,14 +210,14 @@ function runCapture(cmd, opts = {}) { * Internal: capture stdout from an argv array via spawnSync with no shell. * Shared by runCapture() and kept separate for clarity. */ -function runArrayCapture(cmd, opts = {}) { +function runArrayCapture(cmd: readonly string[], opts: ArrayCaptureOptions = {}): string { if (cmd.length === 0) { throw new Error("runCapture: argv array must not be empty"); } const exe = cmd[0]; const args = cmd.slice(1); - const { ignoreError, env: extraEnv, stdio: _stdio, encoding: _encoding, ...spawnOpts } = opts; + const { ignoreError, env: extraEnv, stdio: _stdio, ...spawnOpts } = opts; // Guard: re-enabling shell interpretation defeats the purpose of argv arrays. if (spawnOpts.shell) { @@ -224,7 +259,7 @@ const { redact, redactError, writeRedactedResult } = require("./redact"); * Shell-quote a value for safe interpolation into bash -c strings. * Wraps in single quotes and escapes embedded single quotes. */ -function shellQuote(value) { +function shellQuote(value: RunnerScalar): string { return `'${String(value).replace(/'/g, `'\\''`)}'`; } @@ -232,7 +267,7 @@ function shellQuote(value) { * Validate a name (sandbox, instance, container) against RFC 1123 label rules. * Rejects shell metacharacters, path traversal, and empty/overlength names. */ -function validateName(name, label = "name") { +function validateName(name: string, label = "name"): string { if (!name || typeof name !== "string") { throw new Error(`${label} is required`); } diff --git a/src/lib/runtime-recovery.ts b/src/lib/runtime-recovery.ts index a09a3742e1f..ec10755c6a4 100644 --- a/src/lib/runtime-recovery.ts +++ b/src/lib/runtime-recovery.ts @@ -11,7 +11,7 @@ import { loadSession } from "./onboard-session"; // eslint-disable-next-line no-control-regex const ANSI_RE = /\x1b\[[0-9;]*m/g; -function stripAnsi(text: unknown): string { +function stripAnsi(text: string | null | undefined): string { return String(text || "").replace(ANSI_RE, ""); } diff --git a/src/lib/sandbox-build-context.ts b/src/lib/sandbox-build-context.ts index 965b37c2292..f86718059cc 100644 --- a/src/lib/sandbox-build-context.ts +++ b/src/lib/sandbox-build-context.ts @@ -15,11 +15,14 @@ export interface BuildContextStats { totalBytes: number; } -function createBuildContextDir(tmpDir = os.tmpdir()): string { +function createBuildContextDir(tmpDir: string = os.tmpdir()): string { return fs.mkdtempSync(path.join(tmpDir, "nemoclaw-build-")); } -function stageLegacySandboxBuildContext(rootDir: string, tmpDir = os.tmpdir()): StagedBuildContext { +function stageLegacySandboxBuildContext( + rootDir: string, + tmpDir: string = os.tmpdir(), +): StagedBuildContext { const buildCtx = createBuildContextDir(tmpDir); fs.copyFileSync(path.join(rootDir, "Dockerfile"), path.join(buildCtx, "Dockerfile")); fs.cpSync(path.join(rootDir, "nemoclaw"), path.join(buildCtx, "nemoclaw"), { recursive: true }); @@ -37,7 +40,7 @@ function stageLegacySandboxBuildContext(rootDir: string, tmpDir = os.tmpdir()): function stageOptimizedSandboxBuildContext( rootDir: string, - tmpDir = os.tmpdir(), + tmpDir: string = os.tmpdir(), ): StagedBuildContext { const buildCtx = createBuildContextDir(tmpDir); const stagedDockerfile = path.join(buildCtx, "Dockerfile"); diff --git a/src/lib/sandbox-config.ts b/src/lib/sandbox-config.ts index 6520b57f243..0944bdff9ab 100644 --- a/src/lib/sandbox-config.ts +++ b/src/lib/sandbox-config.ts @@ -26,6 +26,10 @@ type ConfigObject = import("./credential-filter").ConfigObject; type ConfigValue = import("./credential-filter").ConfigValue; const { runOpenshellCommand, captureOpenshellCommand } = require("./openshell"); +function parseJson(text: string): T { + return JSON.parse(text); +} + const K3S_CONTAINER = "openshell-cluster-nemoclaw"; // --------------------------------------------------------------------------- @@ -136,16 +140,16 @@ function setDotpath(obj: ConfigObject, dotpath: string, value: ConfigValue): voi * Return true when every segment in a dotpath is an own property on the * current config object, which keeps config set constrained to recognized keys. */ -function isRecognizedConfigPath(obj: unknown, dotpath: string): boolean { +function isRecognizedConfigPath(obj: ConfigValue, dotpath: string): boolean { if (!dotpath || typeof dotpath !== "string") return false; const keys = dotpath.split("."); if (keys.some((key) => !key)) return false; - let current: unknown = obj; + let current: ConfigValue = obj; for (const key of keys) { - if (current == null || typeof current !== "object" || Array.isArray(current)) return false; - if (!Object.prototype.hasOwnProperty.call(current as Record, key)) return false; - current = (current as Record)[key]; + if (!isConfigObject(current)) return false; + if (!Object.prototype.hasOwnProperty.call(current, key)) return false; + current = current[key]; } return true; } @@ -178,7 +182,7 @@ function serializeConfig(config: ConfigObject, format: string): string { */ function parseCliConfigValue(rawValue: string): ConfigValue { try { - const parsed: unknown = JSON.parse(rawValue); + const parsed = parseJson(rawValue); return isConfigValue(parsed) ? parsed : rawValue; } catch { return rawValue; @@ -211,7 +215,7 @@ function readSandboxConfig(sandboxName: string, target: AgentConfigTarget): Conf try { return parseConfig(raw, target.format); - } catch (err: unknown) { + } catch (err) { const message = err instanceof Error ? err.message : String(err); console.error(` Failed to parse ${target.agentName} config: ${message}`); process.exit(1); @@ -327,7 +331,7 @@ function configSet(sandboxName: string, opts: ConfigSetOpts = {}): void { if (typeof parsedValue === "string") { try { validateUrlValue(parsedValue.trim()); - } catch (err: unknown) { + } catch (err) { const message = err instanceof Error ? err.message : String(err); console.error(` URL validation failed: ${message}`); process.exit(1); @@ -342,7 +346,9 @@ function configSet(sandboxName: string, opts: ConfigSetOpts = {}): void { } if (!isRecognizedConfigPath(config, opts.key)) { - console.error(` Key validation failed: "${opts.key}" is not a recognized ${target.agentName} config path.`); + console.error( + ` Key validation failed: "${opts.key}" is not a recognized ${target.agentName} config path.`, + ); process.exit(1); } diff --git a/src/lib/sandbox-create-stream.test.ts b/src/lib/sandbox-create-stream.test.ts index 1be39b87672..6165fa43093 100644 --- a/src/lib/sandbox-create-stream.test.ts +++ b/src/lib/sandbox-create-stream.test.ts @@ -5,13 +5,17 @@ import { EventEmitter } from "node:events"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { streamSandboxCreate } from "./sandbox-create-stream"; - -class FakeReadable extends EventEmitter { - destroy() {} +import { + type StreamableChildProcess, + type StreamableReadable, + streamSandboxCreate, +} from "./sandbox-create-stream"; + +class FakeReadable extends EventEmitter implements StreamableReadable { + destroy(): void {} } -class FakeChild extends EventEmitter { +class FakeChild extends EventEmitter implements StreamableChildProcess { stdout = new FakeReadable(); stderr = new FakeReadable(); kill = vi.fn(); @@ -28,7 +32,7 @@ describe("sandbox-create-stream", () => { const logLine = vi.fn(); const promise = streamSandboxCreate("echo create", process.env, { logLine, - spawnImpl: () => child as never, + spawnImpl: () => child, }); expect(logLine).toHaveBeenCalledWith(" Building sandbox image..."); @@ -41,14 +45,16 @@ describe("sandbox-create-stream", () => { const logLine = vi.fn(); const promise = streamSandboxCreate("echo create", process.env, { logLine, - spawnImpl: () => child as never, + spawnImpl: () => child, heartbeatIntervalMs: 1_000, silentPhaseMs: 10_000, }); child.stdout.emit( "data", - Buffer.from(" Building image sandbox\n Pushing image layers\nCreated sandbox: demo\n✓ Ready\n"), + Buffer.from( + " Building image sandbox\n Pushing image layers\nCreated sandbox: demo\n✓ Ready\n", + ), ); child.emit("close", 0); @@ -68,7 +74,7 @@ describe("sandbox-create-stream", () => { const child = new FakeChild(); let checks = 0; const promise = streamSandboxCreate("echo create", process.env, { - spawnImpl: () => child as never, + spawnImpl: () => child, readyCheck: () => { checks += 1; return checks >= 2; @@ -95,7 +101,7 @@ describe("sandbox-create-stream", () => { it("flushes the final partial line before resolving", async () => { const child = new FakeChild(); const promise = streamSandboxCreate("echo create", process.env, { - spawnImpl: () => child as never, + spawnImpl: () => child, logLine: vi.fn(), }); @@ -113,7 +119,7 @@ describe("sandbox-create-stream", () => { const child = new FakeChild(); const logLine = vi.fn(); const promise = streamSandboxCreate("echo create", process.env, { - spawnImpl: () => child as never, + spawnImpl: () => child, readyCheck: () => true, // sandbox is already Ready pollIntervalMs: 60_000, // large interval so the poll doesn't fire first heartbeatIntervalMs: 1_000, @@ -135,7 +141,7 @@ describe("sandbox-create-stream", () => { it("returns non-zero when readyCheck is false at close time", async () => { const child = new FakeChild(); const promise = streamSandboxCreate("echo create", process.env, { - spawnImpl: () => child as never, + spawnImpl: () => child, readyCheck: () => false, // sandbox is NOT ready pollIntervalMs: 60_000, heartbeatIntervalMs: 1_000, @@ -156,7 +162,7 @@ describe("sandbox-create-stream", () => { it("reports spawn errors cleanly", async () => { const child = new FakeChild(); const promise = streamSandboxCreate("echo create", process.env, { - spawnImpl: () => child as never, + spawnImpl: () => child, logLine: vi.fn(), }); diff --git a/src/lib/sandbox-create-stream.ts b/src/lib/sandbox-create-stream.ts index 380650150e6..e4494ba12f7 100644 --- a/src/lib/sandbox-create-stream.ts +++ b/src/lib/sandbox-create-stream.ts @@ -38,10 +38,12 @@ export interface StreamableReadable { destroy?(): void; } -export interface StreamableChildProcess - extends Pick { +export interface StreamableChildProcess { stdout: StreamableReadable | null; stderr: StreamableReadable | null; + kill?(signal?: NodeJS.Signals | number): boolean; + removeAllListeners?(event?: string | symbol): void; + unref?(): void; on(event: "error", listener: (error: Error & { code?: string }) => void): this; on(event: "close", listener: (code: number | null) => void): this; } @@ -51,11 +53,11 @@ export function streamSandboxCreate( env: NodeJS.ProcessEnv = process.env, options: StreamSandboxCreateOptions = {}, ): Promise { - const child = (options.spawnImpl ?? spawn)("bash", ["-lc", command], { + const child: StreamableChildProcess = (options.spawnImpl ?? spawn)("bash", ["-lc", command], { cwd: ROOT, env, stdio: ["ignore", "pipe", "pipe"], - }) as StreamableChildProcess; + }); const logLine = options.logLine ?? console.log; const lines: string[] = []; @@ -251,7 +253,9 @@ export function streamSandboxCreate( resolvePromise = resolve; child.on("error", (error) => { const code = error?.code; - const detail = code ? `spawn failed: ${error.message} (${code})` : `spawn failed: ${error.message}`; + const detail = code + ? `spawn failed: ${error.message} (${code})` + : `spawn failed: ${error.message}`; lines.push(detail); finish(1); }); diff --git a/src/lib/sandbox-session-state.test.ts b/src/lib/sandbox-session-state.test.ts index 56860a91d33..7a4fb0b85b1 100644 --- a/src/lib/sandbox-session-state.test.ts +++ b/src/lib/sandbox-session-state.test.ts @@ -17,8 +17,8 @@ import { describe("parseForwardList", () => { it("returns empty array for empty/null input", () => { expect(parseForwardList("")).toEqual([]); - expect(parseForwardList(null as unknown as string)).toEqual([]); - expect(parseForwardList(undefined as unknown as string)).toEqual([]); + expect(parseForwardList(null)).toEqual([]); + expect(parseForwardList(undefined)).toEqual([]); }); it("skips header row", () => { @@ -70,7 +70,7 @@ sandbox-1 127.0.0.1 11434 101 stopped`; describe("parseSshProcesses", () => { it("returns empty array for empty input", () => { expect(parseSshProcesses("", "my-sandbox")).toEqual([]); - expect(parseSshProcesses(null as unknown as string, "my-sandbox")).toEqual([]); + expect(parseSshProcesses(null, "my-sandbox")).toEqual([]); }); it("returns empty array for empty sandbox name", () => { diff --git a/src/lib/sandbox-session-state.ts b/src/lib/sandbox-session-state.ts index 63db9700fb1..df1de801ca3 100644 --- a/src/lib/sandbox-session-state.ts +++ b/src/lib/sandbox-session-state.ts @@ -64,11 +64,14 @@ export interface ForwardEntry { * The first line may be a header row — we skip lines where "SANDBOX" appears * literally in the first column. */ -export function parseForwardList(output: string): ForwardEntry[] { +export function parseForwardList(output: string | null | undefined): ForwardEntry[] { if (!output || typeof output !== "string") return []; const entries: ForwardEntry[] = []; - const lines = output.split("\n").map((l) => l.trim()).filter(Boolean); + const lines = output + .split("\n") + .map((l) => l.trim()) + .filter(Boolean); for (const line of lines) { // Skip header row @@ -97,7 +100,10 @@ export function parseForwardList(output: string): ForwardEntry[] { * Input format: one line per process — ` ` * (compatible with both `pgrep -a` on Linux and `ps -axo pid,command`) */ -export function parseSshProcesses(pgrepOutput: string, sandboxName: string): SandboxSession[] { +export function parseSshProcesses( + pgrepOutput: string | null | undefined, + sandboxName: string, +): SandboxSession[] { if (!pgrepOutput || typeof pgrepOutput !== "string") return []; if (!sandboxName) return []; @@ -134,15 +140,16 @@ function escapeRegExp(value: string): string { * Active forwards (status includes "running") indicate an active connection. */ export function hasActiveForwards(entries: ForwardEntry[], sandboxName: string): boolean { - return entries.some( - (e) => e.sandboxName === sandboxName && e.status.includes("running"), - ); + return entries.some((e) => e.sandboxName === sandboxName && e.status.includes("running")); } /** * Get forward entries for a specific sandbox. */ -export function getForwardsForSandbox(entries: ForwardEntry[], sandboxName: string): ForwardEntry[] { +export function getForwardsForSandbox( + entries: ForwardEntry[], + sandboxName: string, +): ForwardEntry[] { return entries.filter((e) => e.sandboxName === sandboxName); } @@ -174,8 +181,8 @@ export function classifySessionState( ): SessionClassification { const sources: string[] = []; - const activeForwards = getForwardsForSandbox(forwardEntries, sandboxName).filter( - (e) => e.status.includes("running"), + const activeForwards = getForwardsForSandbox(forwardEntries, sandboxName).filter((e) => + e.status.includes("running"), ); if (activeForwards.length > 0) { sources.push("forward"); diff --git a/src/lib/sandbox-state.ts b/src/lib/sandbox-state.ts index e1d5331bad1..7c0a4153e10 100644 --- a/src/lib/sandbox-state.ts +++ b/src/lib/sandbox-state.ts @@ -31,14 +31,14 @@ import { resolveOpenshell } from "./resolve-openshell.js"; import { captureOpenshellCommand } from "./openshell.js"; import { sanitizeConfigFile, isSensitiveFile } from "./credential-filter.js"; -const REBUILD_BACKUPS_DIR = path.join( - process.env.HOME || "/tmp", - ".nemoclaw", - "rebuild-backups", -); +const REBUILD_BACKUPS_DIR = path.join(process.env.HOME || "/tmp", ".nemoclaw", "rebuild-backups"); const MANIFEST_VERSION = 1; +function parseJson(text: string): T { + return JSON.parse(text); +} + // ── Types ────────────────────────────────────────────────────────── export interface RebuildManifest { @@ -104,6 +104,50 @@ export interface SafeExtractResult { error?: string; } +type UnknownRecord = { [key: string]: unknown }; + +function isRecord(value: unknown): value is UnknownRecord { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isStringArray(value: unknown): value is string[] { + return Array.isArray(value) && value.every((entry) => typeof entry === "string"); +} + +function isInstanceBackup(value: unknown): value is InstanceBackup { + return ( + isRecord(value) && + typeof value.instanceId === "string" && + typeof value.agentType === "string" && + typeof value.dataDir === "string" && + isStringArray(value.stateDirs) && + isStringArray(value.backedUpDirs) + ); +} + +function isRebuildManifest(value: unknown): value is RebuildManifest { + return ( + isRecord(value) && + typeof value.version === "number" && + typeof value.sandboxName === "string" && + typeof value.timestamp === "string" && + typeof value.agentType === "string" && + (value.agentVersion === null || typeof value.agentVersion === "string") && + (value.expectedVersion === null || typeof value.expectedVersion === "string") && + isStringArray(value.stateDirs) && + typeof value.writableDir === "string" && + typeof value.backupPath === "string" && + (value.blueprintDigest === undefined || + value.blueprintDigest === null || + typeof value.blueprintDigest === "string") && + (value.policyPresets === undefined || isStringArray(value.policyPresets)) && + (value.instances === undefined || + (Array.isArray(value.instances) && + value.instances.every((entry) => isInstanceBackup(entry)))) && + (value.name === undefined || typeof value.name === "string") + ); +} + // ── Safe tar extraction ────────────────────────────────────────── /** @@ -130,10 +174,7 @@ function isWithinRoot(candidatePath: string, rootPath: string): boolean { * List tar entries and validate every path is within targetDir. * Rejects absolute paths, path traversal (..), and null bytes. */ -export function validateTarEntries( - tarBuffer: Buffer, - targetDir: string, -): TarValidationResult { +export function validateTarEntries(tarBuffer: Buffer, targetDir: string): TarValidationResult { const result = spawnSync("tar", ["-tf", "-"], { input: tarBuffer, encoding: "utf-8", @@ -145,7 +186,9 @@ export function validateTarEntries( return { safe: false, entries: [], - violations: [`tar listing failed (exit ${result.status}): ${(result.stderr || "").substring(0, 200)}`], + violations: [ + `tar listing failed (exit ${result.status}): ${(result.stderr || "").substring(0, 200)}`, + ], }; } @@ -189,10 +232,7 @@ export function validateTarEntries( * like "escapes" relative to the extraction temp dir on the host, but * are intra-sandbox once the backup is restored. See issue #2268. */ -function auditExtractedSymlinks( - dirPath: string, - allowedRoots: string[], -): string[] { +function auditExtractedSymlinks(dirPath: string, allowedRoots: string[]): string[] { const violations: string[] = []; if (!existsSync(dirPath)) return violations; @@ -204,11 +244,11 @@ function auditExtractedSymlinks( if (stat.isSymbolicLink()) { const linkTarget = readlinkSync(fullPath); const resolvedTarget = path.resolve(path.dirname(fullPath), linkTarget); - const inAnyAllowedRoot = allowedRoots.some((root) => - isWithinRoot(resolvedTarget, root), - ); + const inAnyAllowedRoot = allowedRoots.some((root) => isWithinRoot(resolvedTarget, root)); if (!inAnyAllowedRoot) { - violations.push(`symlink escape: ${fullPath} -> ${linkTarget} (resolves to ${resolvedTarget})`); + violations.push( + `symlink escape: ${fullPath} -> ${linkTarget} (resolves to ${resolvedTarget})`, + ); } } else if (stat.isDirectory()) { walk(fullPath); @@ -241,7 +281,10 @@ export function rejectHardLinks(tarBuffer: Buffer): string[] { } const violations: string[] = []; - const lines = (result.stdout || "").trim().split("\n").filter((l) => l.length > 0); + const lines = (result.stdout || "") + .trim() + .split("\n") + .filter((l) => l.length > 0); for (const line of lines) { // Both GNU tar and bsdtar prefix hard-link entries with 'h' in verbose mode @@ -258,10 +301,7 @@ export function rejectHardLinks(tarBuffer: Buffer): string[] { * SECURITY: Validate tar contents, extract with safety flags, then * audit for symlink escapes. Nukes the extraction on any violation. */ -export function safeTarExtract( - tarBuffer: Buffer, - targetDir: string, -): SafeExtractResult { +export function safeTarExtract(tarBuffer: Buffer, targetDir: string): SafeExtractResult { // Phase 1a: Validate entry paths before extraction const validation = validateTarEntries(tarBuffer, targetDir); if (!validation.safe) { @@ -281,11 +321,11 @@ export function safeTarExtract( } // Phase 2: Extract with --no-same-owner to prevent ownership manipulation - const extractResult = spawnSync( - "tar", - ["-xf", "-", "--no-same-owner", "-C", targetDir], - { input: tarBuffer, stdio: ["pipe", "pipe", "pipe"], timeout: 60000 }, - ); + const extractResult = spawnSync("tar", ["-xf", "-", "--no-same-owner", "-C", targetDir], { + input: tarBuffer, + stdio: ["pipe", "pipe", "pipe"], + timeout: 60000, + }); if (extractResult.status !== 0) { return { @@ -323,11 +363,9 @@ function getSshConfig(sandboxName: string): string | null { const openshellBinary = resolveOpenshell(); if (!openshellBinary) return null; - const result = captureOpenshellCommand( - openshellBinary, - ["sandbox", "ssh-config", sandboxName], - { ignoreError: true }, - ); + const result = captureOpenshellCommand(openshellBinary, ["sandbox", "ssh-config", sandboxName], { + ignoreError: true, + }); if (result.status !== 0) return null; return result.output; } @@ -340,11 +378,16 @@ function writeTempSshConfig(sshConfig: string): string { function sshArgs(configFile: string, sandboxName: string): string[] { return [ - "-F", configFile, - "-o", "StrictHostKeyChecking=no", - "-o", "UserKnownHostsFile=/dev/null", - "-o", "ConnectTimeout=10", - "-o", "LogLevel=ERROR", + "-F", + configFile, + "-o", + "StrictHostKeyChecking=no", + "-o", + "UserKnownHostsFile=/dev/null", + "-o", + "ConnectTimeout=10", + "-o", + "LogLevel=ERROR", `openshell-${sandboxName}`, ]; } @@ -377,7 +420,11 @@ function sanitizeBackupDirectory(dirPath: string): void { walk(fullPath); } else if (entry.isFile()) { if (isSensitiveFile(entry.name)) { - try { require("node:fs").unlinkSync(fullPath); } catch { /* best effort */ } + try { + require("node:fs").unlinkSync(fullPath); + } catch { + /* best effort */ + } } else if (entry.name.endsWith(".json")) { sanitizeConfigFile(fullPath); } else if (entry.name === ".env" || entry.name.endsWith(".env")) { @@ -397,7 +444,9 @@ function sanitizeBackupDirectory(dirPath: string): void { .join("\n"); writeFileSync(fullPath, filtered); chmodSync(fullPath, 0o600); - } catch { /* best effort */ } + } catch { + /* best effort */ + } } } } @@ -439,16 +488,15 @@ export function validateSnapshotName(name: string): string | null { * Back up all state directories from a running sandbox. * Uses the agent manifest to determine which directories contain state. */ -export function backupSandboxState( - sandboxName: string, - options: BackupOptions = {}, -): BackupResult { +export function backupSandboxState(sandboxName: string, options: BackupOptions = {}): BackupResult { const sb = registry.getSandbox(sandboxName); const agentName = sb?.agent || "openclaw"; const agent = loadAgent(agentName); const writableDir = agent.configPaths.writableDir; const stateDirs = agent.stateDirs; - _log(`backupSandboxState: agent=${agentName}, writableDir=${writableDir}, stateDirs=[${stateDirs.join(",")}]`); + _log( + `backupSandboxState: agent=${agentName}, writableDir=${writableDir}, stateDirs=[${stateDirs.join(",")}]`, + ); // Validate user-supplied name and check for conflicts BEFORE creating any // files on disk. @@ -485,9 +533,7 @@ export function backupSandboxState( // Capture applied policy presets from the registry so they can be // re-applied after rebuild. Presets live in the gateway policy engine, // not on the sandbox filesystem, so they are lost on destroy/recreate. - const policyPresets: string[] = sb?.policies && sb.policies.length > 0 - ? [...sb.policies] - : []; + const policyPresets: string[] = sb?.policies && sb.policies.length > 0 ? [...sb.policies] : []; _log(`policyPresets from registry: [${policyPresets.join(",")}]`); const manifest: RebuildManifest = { @@ -534,25 +580,29 @@ export function backupSandboxState( const existCheckCmd = stateDirs .map((d) => `[ -d "${writableDir}/${d}" ] && echo "${d}"`) .join("; "); - const workspaceGlobCmd = - `for d in ${writableDir}/workspace-*/; do [ -d "$d" ] && basename "$d"; done 2>/dev/null`; - const fullCheckCmd = - `{ ${existCheckCmd}; ${workspaceGlobCmd}; } 2>/dev/null | awk '!seen[$0]++'`; + const workspaceGlobCmd = `for d in ${writableDir}/workspace-*/; do [ -d "$d" ] && basename "$d"; done 2>/dev/null`; + const fullCheckCmd = `{ ${existCheckCmd}; ${workspaceGlobCmd}; } 2>/dev/null | awk '!seen[$0]++'`; _log(`Checking existing dirs via SSH: ${fullCheckCmd.substring(0, 100)}...`); - const existResult = spawnSync( - "ssh", - [...sshArgs(configFile, sandboxName), fullCheckCmd], - { encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], timeout: 30000 }, + const existResult = spawnSync("ssh", [...sshArgs(configFile, sandboxName), fullCheckCmd], { + encoding: "utf-8", + stdio: ["ignore", "pipe", "pipe"], + timeout: 30000, + }); + _log( + `Dir check: exit=${existResult.status}, stdout=${(existResult.stdout || "").trim().substring(0, 200)}, stderr=${(existResult.stderr || "").trim().substring(0, 200)}`, ); - _log(`Dir check: exit=${existResult.status}, stdout=${(existResult.stdout || "").trim().substring(0, 200)}, stderr=${(existResult.stderr || "").trim().substring(0, 200)}`); const existingDirs = (existResult.stdout || "") .trim() .split("\n") .filter((d) => d.length > 0); - _log(`Existing dirs in sandbox: [${existingDirs.join(",")}] (${existingDirs.length}/${stateDirs.length})`); + _log( + `Existing dirs in sandbox: [${existingDirs.join(",")}] (${existingDirs.length}/${stateDirs.length})`, + ); if (existResult.status !== 0) { - _log(`FAILED: SSH dir check exited ${existResult.status} — cannot determine which dirs exist`); + _log( + `FAILED: SSH dir check exited ${existResult.status} — cannot determine which dirs exist`, + ); return { success: false, manifest, backedUpDirs, failedDirs: [...stateDirs] }; } @@ -565,12 +615,14 @@ export function backupSandboxState( // Download via SSH+tar const tarCmd = `tar -cf - -C ${writableDir} ${existingDirs.join(" ")}`; _log(`Downloading via SSH+tar: ${tarCmd}`); - const result = spawnSync( - "ssh", - [...sshArgs(configFile, sandboxName), tarCmd], - { stdio: ["ignore", "pipe", "pipe"], timeout: 120000, maxBuffer: 256 * 1024 * 1024 }, + const result = spawnSync("ssh", [...sshArgs(configFile, sandboxName), tarCmd], { + stdio: ["ignore", "pipe", "pipe"], + timeout: 120000, + maxBuffer: 256 * 1024 * 1024, + }); + _log( + `SSH+tar download: exit=${result.status}, stdout=${result.stdout ? result.stdout.length + " bytes" : "null"}, stderr=${(result.stderr?.toString() || "").substring(0, 200)}`, ); - _log(`SSH+tar download: exit=${result.status}, stdout=${result.stdout ? result.stdout.length + " bytes" : "null"}, stderr=${(result.stderr?.toString() || "").substring(0, 200)}`); if (result.status === 0 && result.stdout && result.stdout.length > 0) { // SECURITY: Validate tar entries, extract safely, audit symlinks @@ -585,7 +637,11 @@ export function backupSandboxState( failedDirs.push(...existingDirs); } } finally { - try { require("node:fs").unlinkSync(configFile); } catch { /* ignore */ } + try { + require("node:fs").unlinkSync(configFile); + } catch { + /* ignore */ + } } // SECURITY: Strip credentials from the local backup @@ -601,7 +657,9 @@ export function backupSandboxState( ); if (discoveredWorkspaces.length > 0) { manifest.stateDirs = [...stateDirs, ...discoveredWorkspaces]; - _log(`Manifest stateDirs extended with multi-agent workspaces: [${discoveredWorkspaces.join(",")}]`); + _log( + `Manifest stateDirs extended with multi-agent workspaces: [${discoveredWorkspaces.join(",")}]`, + ); } writeManifest(backupPath, manifest); @@ -620,10 +678,7 @@ export function backupSandboxState( /** * Restore state directories into a sandbox from a prior backup. */ -export function restoreSandboxState( - sandboxName: string, - backupPath: string, -): RestoreResult { +export function restoreSandboxState(sandboxName: string, backupPath: string): RestoreResult { _log(`restoreSandboxState: sandbox=${sandboxName}, backupPath=${backupPath}`); const manifest = readManifest(backupPath); if (!manifest) { @@ -636,10 +691,10 @@ export function restoreSandboxState( const failedDirs: string[] = []; // Find which backed-up directories actually exist locally - const localDirs = manifest.stateDirs.filter((d) => - existsSync(path.join(backupPath, d)), + const localDirs = manifest.stateDirs.filter((d) => existsSync(path.join(backupPath, d))); + _log( + `Local backup dirs: [${localDirs.join(",")}] (${localDirs.length}/${manifest.stateDirs.length})`, ); - _log(`Local backup dirs: [${localDirs.join(",")}] (${localDirs.length}/${manifest.stateDirs.length})`); if (localDirs.length === 0) { _log("No dirs to restore"); @@ -656,11 +711,11 @@ export function restoreSandboxState( const configFile = writeTempSshConfig(sshConfig); try { // Upload via tar pipe - const tarResult = spawnSync( - "tar", - ["-cf", "-", "-C", backupPath, ...localDirs], - { stdio: ["ignore", "pipe", "pipe"], timeout: 60000, maxBuffer: 256 * 1024 * 1024 }, - ); + const tarResult = spawnSync("tar", ["-cf", "-", "-C", backupPath, ...localDirs], { + stdio: ["ignore", "pipe", "pipe"], + timeout: 60000, + maxBuffer: 256 * 1024 * 1024, + }); if (tarResult.status !== 0 || !tarResult.stdout) { return { success: false, restoredDirs, failedDirs: [...localDirs] }; @@ -668,25 +723,24 @@ export function restoreSandboxState( // Remove existing state dirs before extracting so stale files from // later snapshots don't persist after restoring an earlier one. - const rmCmd = localDirs - .map((d) => `rm -rf "${writableDir}/${d}"`) - .join(" && "); + const rmCmd = localDirs.map((d) => `rm -rf "${writableDir}/${d}"`).join(" && "); _log(`Cleaning target dirs before restore: ${rmCmd}`); - const rmResult = spawnSync( - "ssh", - [...sshArgs(configFile, sandboxName), rmCmd], - { stdio: ["ignore", "pipe", "pipe"], timeout: 30000 }, - ); + const rmResult = spawnSync("ssh", [...sshArgs(configFile, sandboxName), rmCmd], { + stdio: ["ignore", "pipe", "pipe"], + timeout: 30000, + }); if (rmResult.status !== 0) { - _log(`WARNING: pre-restore cleanup failed (exit ${rmResult.status}): ${(rmResult.stderr?.toString() || "").substring(0, 200)}`); + _log( + `WARNING: pre-restore cleanup failed (exit ${rmResult.status}): ${(rmResult.stderr?.toString() || "").substring(0, 200)}`, + ); } const extractCmd = `tar -xf - -C ${writableDir}`; - const sshResult = spawnSync( - "ssh", - [...sshArgs(configFile, sandboxName), extractCmd], - { input: tarResult.stdout, stdio: ["pipe", "pipe", "pipe"], timeout: 120000 }, - ); + const sshResult = spawnSync("ssh", [...sshArgs(configFile, sandboxName), extractCmd], { + input: tarResult.stdout, + stdio: ["pipe", "pipe", "pipe"], + timeout: 120000, + }); if (sshResult.status === 0) { restoredDirs.push(...localDirs); @@ -696,19 +750,26 @@ export function restoreSandboxState( const openshellBinary = resolveOpenshell(); if (openshellBinary) { _log(`Fixing ownership: chown -R sandbox:sandbox ${writableDir}`); - const chownResult = spawnSync(openshellBinary, [ - "sandbox", "exec", sandboxName, "--", - "chown", "-R", "sandbox:sandbox", writableDir, - ], { stdio: ["ignore", "pipe", "pipe"], timeout: 30000 }); + const chownResult = spawnSync( + openshellBinary, + ["sandbox", "exec", sandboxName, "--", "chown", "-R", "sandbox:sandbox", writableDir], + { stdio: ["ignore", "pipe", "pipe"], timeout: 30000 }, + ); if (chownResult.status !== 0) { - _log(`WARNING: chown failed (exit ${chownResult.status}) — agent may not be able to read restored state`); + _log( + `WARNING: chown failed (exit ${chownResult.status}) — agent may not be able to read restored state`, + ); } } } else { failedDirs.push(...localDirs); } } finally { - try { require("node:fs").unlinkSync(configFile); } catch { /* ignore */ } + try { + require("node:fs").unlinkSync(configFile); + } catch { + /* ignore */ + } } return { @@ -730,7 +791,10 @@ function readManifest(backupPath: string): RebuildManifest | null { const manifestPath = path.join(backupPath, "rebuild-manifest.json"); if (!existsSync(manifestPath)) return null; try { - return JSON.parse(readFileSync(manifestPath, "utf-8")) as RebuildManifest; + const parsed = parseJson(readFileSync(manifestPath, "utf-8")); + return isRebuildManifest(parsed) + ? { ...parsed, blueprintDigest: parsed.blueprintDigest ?? null } + : null; } catch { return null; } @@ -750,9 +814,7 @@ export function listBackups(sandboxName: string): SnapshotEntry[] { const dir = path.join(REBUILD_BACKUPS_DIR, sandboxName); if (!existsSync(dir)) return []; - const rawEntries = readdirSync(dir, { withFileTypes: true }).filter((e) => - e.isDirectory(), - ); + const rawEntries = readdirSync(dir, { withFileTypes: true }).filter((e) => e.isDirectory()); const manifests: RebuildManifest[] = []; for (const entry of rawEntries) { @@ -791,10 +853,7 @@ export interface SnapshotMatchResult { * 2. exact user-assigned name match * 3. exact timestamp match */ -export function findBackup( - sandboxName: string, - selector: string, -): SnapshotMatchResult { +export function findBackup(sandboxName: string, selector: string): SnapshotMatchResult { const backups = listBackups(sandboxName); const versionMatch = VERSION_SELECTOR_RE.exec(selector); diff --git a/src/lib/services.ts b/src/lib/services.ts index 6e82bc6764d..34ff9db24d6 100644 --- a/src/lib/services.ts +++ b/src/lib/services.ts @@ -103,8 +103,8 @@ function removePid(pidDir: string, name: string): void { // Service lifecycle // --------------------------------------------------------------------------- -const SERVICE_NAMES = ["cloudflared"] as const; -type ServiceName = (typeof SERVICE_NAMES)[number]; +type ServiceName = "cloudflared"; +const SERVICE_NAMES: readonly ServiceName[] = ["cloudflared"]; function startService( pidDir: string, diff --git a/src/lib/shields.ts b/src/lib/shields.ts index 7b7bab1270a..79b920064fb 100644 --- a/src/lib/shields.ts +++ b/src/lib/shields.ts @@ -37,19 +37,45 @@ const STATE_DIR = path.join(process.env.HOME ?? "/tmp", ".nemoclaw", "state"); const K3S_CONTAINER = "openshell-cluster-nemoclaw"; function kubectlExec(sandboxName: string, cmd: string[]): void { - execFileSync("docker", [ - "exec", K3S_CONTAINER, - "kubectl", "exec", "-n", "openshell", sandboxName, "-c", "agent", "--", - ...cmd, - ], { stdio: ["ignore", "pipe", "pipe"], timeout: 15000 }); + execFileSync( + "docker", + [ + "exec", + K3S_CONTAINER, + "kubectl", + "exec", + "-n", + "openshell", + sandboxName, + "-c", + "agent", + "--", + ...cmd, + ], + { stdio: ["ignore", "pipe", "pipe"], timeout: 15000 }, + ); } function kubectlExecCapture(sandboxName: string, cmd: string[]): string { - return execFileSync("docker", [ - "exec", K3S_CONTAINER, - "kubectl", "exec", "-n", "openshell", sandboxName, "-c", "agent", "--", - ...cmd, - ], { stdio: ["ignore", "pipe", "pipe"], timeout: 15000 }).toString().trim(); + return execFileSync( + "docker", + [ + "exec", + K3S_CONTAINER, + "kubectl", + "exec", + "-n", + "openshell", + sandboxName, + "-c", + "agent", + "--", + ...cmd, + ], + { stdio: ["ignore", "pipe", "pipe"], timeout: 15000 }, + ) + .toString() + .trim(); } // Re-export for tests and external consumers @@ -79,7 +105,8 @@ function loadShieldsState(sandboxName: string): ShieldsState { const filePath = stateFilePath(sandboxName); if (!fs.existsSync(filePath)) return {}; try { - return JSON.parse(fs.readFileSync(filePath, "utf-8")) as ShieldsState; + const parsed = JSON.parse(fs.readFileSync(filePath, "utf-8")); + return isShieldsState(parsed) ? parsed : {}; } catch { return {}; } @@ -104,6 +131,58 @@ interface TimerMarker { restoreAt: string; } +type UnknownRecord = { [key: string]: unknown }; + +function isObjectRecord(value: unknown): value is UnknownRecord { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isOptionalBoolean(value: unknown): value is boolean | undefined { + return value === undefined || typeof value === "boolean"; +} + +function isOptionalNumber(value: unknown): value is number | undefined { + return value === undefined || (typeof value === "number" && Number.isFinite(value)); +} + +function isOptionalString(value: unknown): value is string | undefined { + return value === undefined || typeof value === "string"; +} + +function isOptionalNullableString(value: unknown): value is string | null | undefined { + return value === undefined || value === null || typeof value === "string"; +} + +function isOptionalNullableNumber(value: unknown): value is number | null | undefined { + return ( + value === undefined || value === null || (typeof value === "number" && Number.isFinite(value)) + ); +} + +function isShieldsState(value: unknown): value is ShieldsState { + return ( + isObjectRecord(value) && + isOptionalBoolean(value.shieldsDown) && + isOptionalNullableString(value.shieldsDownAt) && + isOptionalNullableNumber(value.shieldsDownTimeout) && + isOptionalNullableString(value.shieldsDownReason) && + isOptionalNullableString(value.shieldsDownPolicy) && + isOptionalNullableString(value.shieldsPolicySnapshotPath) && + isOptionalBoolean(value.permanent) && + isOptionalString(value.updatedAt) + ); +} + +function isTimerMarker(value: unknown): value is TimerMarker { + return ( + isObjectRecord(value) && + typeof value.pid === "number" && + typeof value.sandboxName === "string" && + typeof value.snapshotPath === "string" && + typeof value.restoreAt === "string" + ); +} + function timerMarkerPath(sandboxName: string): string { return path.join(STATE_DIR, `shields-timer-${sandboxName}.json`); } @@ -112,7 +191,8 @@ function readTimerMarker(sandboxName: string): TimerMarker | null { const p = timerMarkerPath(sandboxName); if (!fs.existsSync(p)) return null; try { - return JSON.parse(fs.readFileSync(p, "utf-8")) as TimerMarker; + const parsed = JSON.parse(fs.readFileSync(p, "utf-8")); + return isTimerMarker(parsed) ? parsed : null; } catch { return null; } @@ -147,15 +227,40 @@ function killTimer(sandboxName: string): void { // read_only) + chown/chmod below. // --------------------------------------------------------------------------- -function unlockAgentConfig(sandboxName: string, target: { configPath: string; configDir: string }): void { +function unlockAgentConfig( + sandboxName: string, + target: { configPath: string; configDir: string }, +): void { const errors: string[] = []; - try { kubectlExec(sandboxName, ["chattr", "-i", target.configPath]); } catch { errors.push("chattr -i"); } - try { kubectlExec(sandboxName, ["chown", "sandbox:sandbox", target.configPath]); } catch { errors.push("chown config file"); } - try { kubectlExec(sandboxName, ["chmod", "600", target.configPath]); } catch { errors.push("chmod 600 config file"); } - try { kubectlExec(sandboxName, ["chown", "sandbox:sandbox", target.configDir]); } catch { errors.push("chown config dir"); } - try { kubectlExec(sandboxName, ["chmod", "700", target.configDir]); } catch { errors.push("chmod 700 config dir"); } + try { + kubectlExec(sandboxName, ["chattr", "-i", target.configPath]); + } catch { + errors.push("chattr -i"); + } + try { + kubectlExec(sandboxName, ["chown", "sandbox:sandbox", target.configPath]); + } catch { + errors.push("chown config file"); + } + try { + kubectlExec(sandboxName, ["chmod", "600", target.configPath]); + } catch { + errors.push("chmod 600 config file"); + } + try { + kubectlExec(sandboxName, ["chown", "sandbox:sandbox", target.configDir]); + } catch { + errors.push("chown config dir"); + } + try { + kubectlExec(sandboxName, ["chmod", "700", target.configDir]); + } catch { + errors.push("chmod 700 config dir"); + } if (errors.length > 0) { - console.error(` Warning: Some unlock operations failed: ${errors.join(", ")}. Config may remain read-only.`); + console.error( + ` Warning: Some unlock operations failed: ${errors.join(", ")}. Config may remain read-only.`, + ); } } @@ -179,28 +284,44 @@ function unlockAgentConfig(sandboxName: string, target: { configPath: string; co // runtime environment supports it. // --------------------------------------------------------------------------- -function lockAgentConfig(sandboxName: string, target: { configPath: string; configDir: string }): void { +function lockAgentConfig( + sandboxName: string, + target: { configPath: string; configDir: string }, +): void { const errors: string[] = []; - try { kubectlExec(sandboxName, ["chmod", "444", target.configPath]); } - catch { errors.push("chmod 444 config file"); } + try { + kubectlExec(sandboxName, ["chmod", "444", target.configPath]); + } catch { + errors.push("chmod 444 config file"); + } - try { kubectlExec(sandboxName, ["chown", "root:root", target.configPath]); } - catch { errors.push("chown root:root config file"); } + try { + kubectlExec(sandboxName, ["chown", "root:root", target.configPath]); + } catch { + errors.push("chown root:root config file"); + } - try { kubectlExec(sandboxName, ["chmod", "755", target.configDir]); } - catch { errors.push("chmod 755 config dir"); } + try { + kubectlExec(sandboxName, ["chmod", "755", target.configDir]); + } catch { + errors.push("chmod 755 config dir"); + } - try { kubectlExec(sandboxName, ["chown", "root:root", target.configDir]); } - catch { errors.push("chown root:root config dir"); } + try { + kubectlExec(sandboxName, ["chown", "root:root", target.configDir]); + } catch { + errors.push("chown root:root config dir"); + } // Best-effort: the config file was never chattr +i'd by the entrypoint // (only the directory and symlinks were). kubectl exec may also lack // CAP_LINUX_IMMUTABLE. Track the result so verification doesn't require // something that was never there. let chattrSucceeded = true; - try { kubectlExec(sandboxName, ["chattr", "+i", target.configPath]); } - catch { + try { + kubectlExec(sandboxName, ["chattr", "+i", target.configPath]); + } catch { chattrSucceeded = false; } @@ -217,7 +338,7 @@ function lockAgentConfig(sandboxName: string, target: { configPath: string; conf const [mode, owner] = perms.split(" "); if (!/^4[0-4][0-4]$/.test(mode)) issues.push(`file mode=${mode} (expected 444)`); if (owner !== "root:root") issues.push(`file owner=${owner} (expected root:root)`); - } catch (err: unknown) { + } catch (err) { const msg = err instanceof Error ? err.message : String(err); issues.push(`file stat failed: ${msg}`); } @@ -227,7 +348,7 @@ function lockAgentConfig(sandboxName: string, target: { configPath: string; conf const [dirMode, dirOwner] = dirPerms.split(" "); if (dirMode !== "755") issues.push(`dir mode=${dirMode} (expected 755)`); if (dirOwner !== "root:root") issues.push(`dir owner=${dirOwner} (expected root:root)`); - } catch (err: unknown) { + } catch (err) { const msg = err instanceof Error ? err.message : String(err); issues.push(`dir stat failed: ${msg}`); } @@ -355,10 +476,14 @@ function shieldsDown(sandboxName: string, opts: ShieldsDownOpts = {}): void { const actualScript = fs.existsSync(timerScriptJs) ? timerScriptJs : timerScript; try { - const child = fork(actualScript, [sandboxName, snapshotPath, restoreAt.toISOString(), target.configPath, target.configDir], { - detached: true, - stdio: ["ignore", "ignore", "ignore", "ipc"], - }); + const child = fork( + actualScript, + [sandboxName, snapshotPath, restoreAt.toISOString(), target.configPath, target.configDir], + { + detached: true, + stdio: ["ignore", "ignore", "ignore", "ipc"], + }, + ); child.disconnect(); child.unref(); @@ -374,11 +499,13 @@ function shieldsDown(sandboxName: string, opts: ShieldsDownOpts = {}): void { }), { mode: 0o600 }, ); - } catch (err: unknown) { + } catch (err) { const message = err instanceof Error ? err.message : String(err); console.error(` Cannot start auto-restore timer: ${message}`); console.error(" Rolling back — restoring policy from snapshot..."); - const rollbackResult = run(buildPolicySetCommand(snapshotPath, sandboxName), { ignoreError: true }); + const rollbackResult = run(buildPolicySetCommand(snapshotPath, sandboxName), { + ignoreError: true, + }); let rollbackLocked = false; if (rollbackResult.status === 0) { try { @@ -402,7 +529,9 @@ function shieldsDown(sandboxName: string, opts: ShieldsDownOpts = {}): void { } else { // Leave state as shieldsDown: true — don't lie about protection level console.error(" Shields remain DOWN — manual intervention required."); - console.error(` Re-lock manually via kubectl exec, then run: nemoclaw ${sandboxName} shields up`); + console.error( + ` Re-lock manually via kubectl exec, then run: nemoclaw ${sandboxName} shields up`, + ); } process.exit(1); } @@ -472,11 +601,13 @@ function shieldsUp(sandboxName: string): void { console.log(` Locking ${target.agentName} config (${target.configPath})...`); try { lockAgentConfig(sandboxName, target); - } catch (err: unknown) { + } catch (err) { const message = err instanceof Error ? err.message : String(err); console.error(` ERROR: ${message}`); console.error(" Shields remain DOWN — manual intervention required."); - console.error(` Re-lock manually via kubectl exec, then run: nemoclaw ${sandboxName} shields up`); + console.error( + ` Re-lock manually via kubectl exec, then run: nemoclaw ${sandboxName} shields up`, + ); process.exit(1); } @@ -511,7 +642,9 @@ function shieldsUp(sandboxName: string): void { const mins = Math.floor(durationSeconds / 60); const secs = durationSeconds % 60; console.log(` Shields UP for ${sandboxName}`); - console.log(` Duration: ${mins}m ${secs}s | Reason: ${state.shieldsDownReason ?? "not specified"}`); + console.log( + ` Duration: ${mins}m ${secs}s | Reason: ${state.shieldsDownReason ?? "not specified"}`, + ); } // --------------------------------------------------------------------------- @@ -525,7 +658,9 @@ function shieldsStatus(sandboxName: string): void { if (!state.shieldsDown) { console.log(" Shields: UP"); - console.log(` Policy: default${state.shieldsPolicySnapshotPath ? " (last snapshot preserved)" : ""}`); + console.log( + ` Policy: default${state.shieldsPolicySnapshotPath ? " (last snapshot preserved)" : ""}`, + ); if (state.shieldsDownAt) { console.log(` Last lowered: ${state.shieldsDownAt}`); } @@ -535,9 +670,7 @@ function shieldsStatus(sandboxName: string): void { const downSince = state.shieldsDownAt ? new Date(state.shieldsDownAt) : null; const elapsed = downSince ? Math.floor((Date.now() - downSince.getTime()) / 1000) : 0; const remaining = - state.shieldsDownTimeout != null - ? Math.max(0, state.shieldsDownTimeout - elapsed) - : null; + state.shieldsDownTimeout != null ? Math.max(0, state.shieldsDownTimeout - elapsed) : null; console.log(` Shields: DOWN${state.permanent ? " (permanent)" : ""}`); console.log(` Since: ${state.shieldsDownAt ?? "unknown"}`); diff --git a/src/lib/skill-install.ts b/src/lib/skill-install.ts index c6a044009d2..91090285990 100644 --- a/src/lib/skill-install.ts +++ b/src/lib/skill-install.ts @@ -16,9 +16,17 @@ import YAML from "yaml"; // ── Frontmatter parsing ────────────────────────────────────────── +type FrontmatterScalar = string | number | boolean | null | undefined; +type FrontmatterValue = FrontmatterScalar | FrontmatterRecord | FrontmatterValue[]; +type FrontmatterRecord = { [key: string]: FrontmatterValue }; + +function isRecord(value: FrontmatterValue): value is FrontmatterRecord { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + export interface SkillFrontmatter { name: string; - [key: string]: unknown; + [key: string]: FrontmatterValue; } /** @@ -44,20 +52,19 @@ export function parseFrontmatter(content: string): SkillFrontmatter { const fmRaw = lines.slice(1, closingIdx).join("\n"); - let parsed: unknown; + let parsed: FrontmatterValue; try { parsed = YAML.parse(fmRaw); - } catch (err: unknown) { + } catch (err) { const msg = err instanceof Error ? err.message : String(err); throw new Error(`SKILL.md frontmatter is not valid YAML: ${msg}`); } - if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + if (!isRecord(parsed)) { throw new Error("SKILL.md frontmatter must be a YAML mapping (key: value pairs)"); } - const fm = parsed as Record; - const nameValue = typeof fm.name === "string" ? fm.name.trim() : ""; + const nameValue = typeof parsed.name === "string" ? parsed.name.trim() : ""; if (!nameValue) { throw new Error("SKILL.md frontmatter is missing required 'name' field"); } @@ -158,11 +165,16 @@ export function sshExec( const result = spawnSync( "ssh", [ - "-F", ctx.configFile, - "-o", "StrictHostKeyChecking=no", - "-o", "UserKnownHostsFile=/dev/null", - "-o", "ConnectTimeout=10", - "-o", "LogLevel=ERROR", + "-F", + ctx.configFile, + "-o", + "StrictHostKeyChecking=no", + "-o", + "UserKnownHostsFile=/dev/null", + "-o", + "ConnectTimeout=10", + "-o", + "LogLevel=ERROR", `openshell-${ctx.sandboxName}`, command, ], @@ -254,9 +266,7 @@ export function uploadDirectory( const failed: string[] = []; for (const rel of files) { const localFile = path.join(localDir, rel); - const remoteSubdir = rel.includes("/") - ? `${remoteDir}/${path.dirname(rel)}` - : remoteDir; + const remoteSubdir = rel.includes("/") ? `${remoteDir}/${path.dirname(rel)}` : remoteDir; const result = uploadFile(ctx, localFile, remoteSubdir, path.basename(rel)); if (!result || result.status !== 0) { failed.push(rel); @@ -299,11 +309,9 @@ export function postInstall( // mirrorDir contains $HOME which must expand, so we use double // quotes (not shellQuote). Safe because validateRelativePath // restricts filenames to [A-Za-z0-9._-/] before we reach here. - const result = runSsh( - ctx, - `mkdir -p "${mirrorSubdir}" && cat > "${mirrorFile}"`, - { input: content }, - ); + const result = runSsh(ctx, `mkdir -p "${mirrorSubdir}" && cat > "${mirrorFile}"`, { + input: content, + }); if (!result || result.status !== 0) { mirrorFailed = true; } diff --git a/src/lib/stale-dist-check.ts b/src/lib/stale-dist-check.ts index 169f1ce0110..f72f0a6b6a3 100644 --- a/src/lib/stale-dist-check.ts +++ b/src/lib/stale-dist-check.ts @@ -19,7 +19,8 @@ export function maxMtime(root: string, accept: (name: string) => boolean): numbe let newest = 0; const stack: string[] = [root]; while (stack.length) { - const dir = stack.pop() as string; + const dir = stack.pop(); + if (!dir) continue; let entries; try { entries = fs.readdirSync(dir, { withFileTypes: true }); @@ -46,9 +47,7 @@ export function maxMtime(root: string, accept: (name: string) => boolean): numbe } /** Return `{ srcMtime, distMtime }` when compiled dist/ is older than src/ by more than the grace window; return null otherwise or when either directory is missing. */ -export function checkStaleDist( - repoRoot: string, -): { srcMtime: number; distMtime: number } | null { +export function checkStaleDist(repoRoot: string): { srcMtime: number; distMtime: number } | null { const srcDir = path.join(repoRoot, "src"); const distDir = path.join(repoRoot, "dist"); if (!fs.existsSync(srcDir) || !fs.existsSync(distDir)) return null; @@ -64,7 +63,7 @@ export function checkStaleDist( /** Print a stale-dist warning to `stream` if dist/ is out of date. Returns true when a warning was emitted, false otherwise. Never throws — fails open on any error (filesystem or stream write). */ export function warnIfStale( repoRoot: string, - stream: { write(chunk: string): unknown } = process.stderr, + stream: { write(chunk: string): void | boolean } = process.stderr, ): boolean { try { const result = checkStaleDist(repoRoot); diff --git a/src/lib/tiers.ts b/src/lib/tiers.ts index fa54a02ada3..5e0fefc0cdc 100644 --- a/src/lib/tiers.ts +++ b/src/lib/tiers.ts @@ -41,13 +41,15 @@ interface ResolveTierPresetOptions { selected?: string[] | null; } -type UnknownRecord = { [key: string]: unknown }; +type TierYamlScalar = string | number | boolean | null | undefined; +type TierYamlValue = TierYamlScalar | TierYamlRecord | TierYamlValue[]; +type TierYamlRecord = { [key: string]: TierYamlValue }; -function isRecord(value: unknown): value is UnknownRecord { +function isRecord(value: TierYamlValue): value is TierYamlRecord { return typeof value === "object" && value !== null && !Array.isArray(value); } -function readString(record: UnknownRecord, key: string): string | null { +function readString(record: TierYamlRecord, key: string): string | null { const value = record[key]; return typeof value === "string" ? value : null; } @@ -56,7 +58,7 @@ function isTierAccess(value: string): value is TierAccess { return ALLOWED_ACCESS.has(value); } -function parseTierPreset(value: unknown, index: number, tierName: string): TierPreset { +function parseTierPreset(value: TierYamlValue, index: number, tierName: string): TierPreset { if (!isRecord(value)) { throw new Error(`tiers.yaml: tier '${tierName}' preset ${String(index)} is not an object`); } @@ -76,7 +78,7 @@ function parseTierPreset(value: unknown, index: number, tierName: string): TierP return { name, access }; } -function parseTierDefinition(value: unknown, index: number): TierDefinition { +function parseTierDefinition(value: TierYamlValue, index: number): TierDefinition { if (!isRecord(value)) { throw new Error(`tiers.yaml: tier ${String(index)} is not an object`); } diff --git a/src/lib/uninstall-command.test.ts b/src/lib/uninstall-command.test.ts index b351410da69..4646c976960 100644 --- a/src/lib/uninstall-command.test.ts +++ b/src/lib/uninstall-command.test.ts @@ -11,6 +11,10 @@ import { runUninstallCommand, } from "../../dist/lib/uninstall-command"; +function exitWithCode(code: number): never { + throw new Error(`exit:${code}`); +} + describe("uninstall command", () => { it("builds a version-pinned uninstall URL", () => { expect(buildVersionedUninstallUrl("0.1.0")).toBe( @@ -27,14 +31,9 @@ describe("uninstall command", () => { }); it("maps spawn signals to shell-style exit codes", () => { - expect(() => - exitWithSpawnResult( - { status: null, signal: "SIGTERM" }, - ((code: number) => { - throw new Error(`exit:${code}`); - }) as never, - ), - ).toThrow("exit:143"); + expect(() => exitWithSpawnResult({ status: null, signal: "SIGTERM" }, exitWithCode)).toThrow( + "exit:143", + ); }); it("runs the local uninstall script when present", () => { @@ -50,16 +49,18 @@ describe("uninstall command", () => { existsSyncImpl: (candidate) => candidate === path.join("/repo", "uninstall.sh"), log: () => {}, error: () => {}, - exit: ((code: number) => { - throw new Error(`exit:${code}`); - }) as never, + exit: exitWithCode, }), ).toThrow("exit:0"); - expect(spawnSyncImpl).toHaveBeenCalledWith("bash", [path.join("/repo", "uninstall.sh"), "--yes"], { - stdio: "inherit", - cwd: "/repo", - env: process.env, - }); + expect(spawnSyncImpl).toHaveBeenCalledWith( + "bash", + [path.join("/repo", "uninstall.sh"), "--yes"], + { + stdio: "inherit", + cwd: "/repo", + env: process.env, + }, + ); }); it("does not download or run a remote uninstall script when no local copy exists", () => { @@ -78,9 +79,7 @@ describe("uninstall command", () => { error: (message) => { errors.push(message ?? ""); }, - exit: ((code: number) => { - throw new Error(`exit:${code}`); - }) as never, + exit: exitWithCode, }), ).toThrow("exit:1"); expect(spawnSyncImpl).not.toHaveBeenCalled(); diff --git a/src/lib/uninstall-command.ts b/src/lib/uninstall-command.ts index 6bd355cae6c..3f6f5c406cd 100644 --- a/src/lib/uninstall-command.ts +++ b/src/lib/uninstall-command.ts @@ -4,10 +4,13 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import type { SpawnSyncReturns } from "node:child_process"; +import type { SpawnSyncOptions, SpawnSyncReturns } from "node:child_process"; export function buildVersionedUninstallUrl(version: string): string { - const stableVersion = String(version || "").trim().replace(/^v/, "").replace(/-.*/, ""); + const stableVersion = String(version || "") + .trim() + .replace(/^v/, "") + .replace(/-.*/, ""); return `https://raw.githubusercontent.com/NVIDIA/NemoClaw/refs/tags/v${stableVersion}/uninstall.sh`; } @@ -48,7 +51,7 @@ export interface RunUninstallCommandDeps { spawnSyncImpl: ( file: string, args: string[], - options?: Record, + options?: SpawnSyncOptions, ) => Pick, "status" | "signal">; existsSyncImpl?: (path: string) => boolean; log?: (message?: string) => void; diff --git a/src/lib/url-utils.ts b/src/lib/url-utils.ts index 4f34013c05e..3b0e0d498b7 100644 --- a/src/lib/url-utils.ts +++ b/src/lib/url-utils.ts @@ -22,7 +22,10 @@ export function stripEndpointSuffix(pathname = "", suffixes: string[] = []): str export type EndpointFlavor = "anthropic" | "openai"; -export function normalizeProviderBaseUrl(value: unknown, flavor: EndpointFlavor): string { +export function normalizeProviderBaseUrl( + value: string | URL | null | undefined, + flavor: EndpointFlavor, +): string { const raw = String(value || "").trim(); if (!raw) return ""; diff --git a/src/lib/usage-notice.ts b/src/lib/usage-notice.ts index 53a0997b5a7..fe04600110f 100644 --- a/src/lib/usage-notice.ts +++ b/src/lib/usage-notice.ts @@ -9,7 +9,14 @@ import noticeConfig from "../../bin/lib/usage-notice.json"; export const NOTICE_ACCEPT_FLAG = "--yes-i-accept-third-party-software"; export const NOTICE_ACCEPT_ENV = "NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE"; -export const NOTICE_CONFIG_FILE = path.join(__dirname, "..", "..", "bin", "lib", "usage-notice.json"); +export const NOTICE_CONFIG_FILE = path.join( + __dirname, + "..", + "..", + "bin", + "lib", + "usage-notice.json", +); const OSC8_OPEN = "\u001B]8;;"; const OSC8_CLOSE = "\u001B]8;;\u001B\\"; @@ -39,20 +46,73 @@ type EnsureUsageNoticeConsentOptions = { writeLine?: WriteLineFn; }; +type NoticeConfigSource = { + version?: string; + title?: string; + referenceUrl?: string; + body?: string[]; + links?: NoticeLink[]; + interactivePrompt?: string; +}; + +function parseJson(text: string): T { + return JSON.parse(text); +} + +function readStringProperty(value: object | null, key: string): string | undefined { + if (!value) { + return undefined; + } + const property = Reflect.get(value, key); + return typeof property === "string" ? property : undefined; +} + +function readStringArrayProperty(value: object | null, key: string): string[] | undefined { + if (!value) { + return undefined; + } + const property = Reflect.get(value, key); + return Array.isArray(property) + ? property.filter((entry): entry is string => typeof entry === "string") + : undefined; +} + +function readLinksProperty(value: object | null, key: string): NoticeLink[] | undefined { + if (!value) { + return undefined; + } + const property = Reflect.get(value, key); + if (!Array.isArray(property)) { + return undefined; + } + return property.map((entry) => ({ + label: readStringProperty(typeof entry === "object" && entry !== null ? entry : null, "label"), + url: readStringProperty(typeof entry === "object" && entry !== null ? entry : null, "url"), + })); +} + export function getUsageNoticeStateFile(): string { return path.join(process.env.HOME || os.homedir(), ".nemoclaw", "usage-notice.json"); } export function loadUsageNoticeConfig(): NoticeConfig { - return noticeConfig as NoticeConfig; + const rawConfig: NoticeConfigSource = noticeConfig; + return { + version: rawConfig.version || "", + title: rawConfig.title || "", + referenceUrl: rawConfig.referenceUrl, + body: rawConfig.body, + links: rawConfig.links, + interactivePrompt: rawConfig.interactivePrompt || "", + }; } export function hasAcceptedUsageNotice(version: string): boolean { try { - const saved = JSON.parse(fs.readFileSync(getUsageNoticeStateFile(), "utf8")) as { - acceptedVersion?: string; - }; - return saved?.acceptedVersion === version; + const saved = parseJson<{ acceptedVersion?: string }>( + fs.readFileSync(getUsageNoticeStateFile(), "utf8"), + ); + return saved.acceptedVersion === version; } catch { return false; } diff --git a/src/lib/version.ts b/src/lib/version.ts index 5dd9ca00fc9..d6ff45808fa 100644 --- a/src/lib/version.ts +++ b/src/lib/version.ts @@ -5,6 +5,16 @@ import { execFileSync } from "node:child_process"; import { existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; +type PackageInfo = { version?: string }; + +function parseJson(text: string): T { + return JSON.parse(text); +} + +function isPackageInfo(value: PackageInfo | null): value is { version: string } { + return typeof value?.version === "string"; +} + export interface VersionOptions { /** Override the repo root directory. */ rootDir?: string; @@ -41,6 +51,9 @@ export function getVersion(opts: VersionOptions = {}): string { // 3. Fallback to package.json const raw = readFileSync(join(root, "package.json"), "utf-8"); - const pkg = JSON.parse(raw) as { version: string }; + const pkg = parseJson(raw); + if (!isPackageInfo(pkg)) { + throw new Error(`package.json at ${root} is missing a string version field`); + } return pkg.version; } diff --git a/src/nemoclaw.ts b/src/nemoclaw.ts index 1c7af5b4d87..ba665056808 100644 --- a/src/nemoclaw.ts +++ b/src/nemoclaw.ts @@ -1,4 +1,3 @@ -// @ts-nocheck // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 @@ -31,7 +30,12 @@ const { validateName, } = require("./lib/runner"); const { resolveOpenshell } = require("./lib/resolve-openshell"); -const { startGatewayForRecovery, pruneKnownHostsEntries } = require("./lib/onboard"); +const { + startGatewayForRecovery, + pruneKnownHostsEntries, + ensureOllamaAuthProxy, + isNonInteractive, +} = require("./lib/onboard"); const { getCredential, deleteCredential, @@ -39,6 +43,7 @@ const { prompt: askPrompt, } = require("./lib/credentials"); const registry = require("./lib/registry"); +import type { SandboxEntry } from "./lib/registry"; const nim = require("./lib/nim"); const policies = require("./lib/policies"); const shields = require("./lib/shields"); @@ -47,6 +52,7 @@ const { parseGatewayInference } = require("./lib/inference-config"); const { probeProviderHealth } = require("./lib/inference-health"); const { getVersion } = require("./lib/version"); const onboardSession = require("./lib/onboard-session"); +import type { Session } from "./lib/onboard-session"; const { parseLiveSandboxNames } = require("./lib/runtime-recovery"); const { NOTICE_ACCEPT_ENV, NOTICE_ACCEPT_FLAG } = require("./lib/usage-notice"); const { runDebugCommand } = require("./lib/debug-command"); @@ -66,7 +72,6 @@ const agentRuntime = require("../bin/lib/agent-runtime"); const sandboxVersion = require("./lib/sandbox-version"); const sandboxState = require("./lib/sandbox-state"); const { parseRestoreArgs } = sandboxState; -const { ensureOllamaAuthProxy } = require("./lib/onboard"); const skillInstall = require("./lib/skill-install"); const { sleepSeconds } = require("./lib/wait"); const { parseSandboxPhase } = require("./lib/gateway-state"); @@ -83,7 +88,6 @@ import { knownChannelNames, persistChannelTokens, } from "./lib/sandbox-channels"; -import { isNonInteractive } from "./lib/onboard"; // ── Global commands ────────────────────────────────────────────── @@ -110,12 +114,39 @@ const GLOBAL_COMMANDS = new Set([ "-v", ]); +type CommandArgs = string[]; +type RunnerOptions = { + env?: NodeJS.ProcessEnv; + stdio?: import("node:child_process").StdioOptions; + ignoreError?: boolean; + timeout?: number; +}; + +type SpawnLikeResult = { + status: number | null; + stdout?: string; + stderr?: string; + output?: string; +}; + +type SandboxCommandResult = { + status: number; + stdout: string; + stderr: string; +}; + +type RecoveredSandboxMetadata = Partial< + Pick +> & { + policyPresets?: string[] | null; +}; + const REMOTE_UNINSTALL_URL = buildVersionedUninstallUrl(getVersion()); -let OPENSHELL_BIN = null; +let OPENSHELL_BIN: string | null = null; const NEMOCLAW_GATEWAY_NAME = "nemoclaw"; const DASHBOARD_FORWARD_PORT = String(DASHBOARD_PORT); -function getOpenshellBinary() { +function getOpenshellBinary(): string { if (!OPENSHELL_BIN) { OPENSHELL_BIN = resolveOpenshell(); } @@ -126,24 +157,26 @@ function getOpenshellBinary() { return OPENSHELL_BIN; } -function runOpenshell(args, opts = {}) { +function runOpenshell(args: CommandArgs, opts: RunnerOptions = {}) { return runOpenshellCommand(getOpenshellBinary(), args, { cwd: ROOT, env: opts.env, stdio: opts.stdio, ignoreError: opts.ignoreError, + timeout: opts.timeout, errorLine: console.error, - exit: (code) => process.exit(code), + exit: (code: number) => process.exit(code), }); } -function captureOpenshell(args, opts = {}) { +function captureOpenshell(args: CommandArgs, opts: RunnerOptions = {}) { return captureOpenshellCommand(getOpenshellBinary(), args, { cwd: ROOT, env: opts.env, ignoreError: opts.ignoreError, + timeout: opts.timeout, errorLine: console.error, - exit: (code) => process.exit(code), + exit: (code: number) => process.exit(code), }); } @@ -164,13 +197,13 @@ function hasNoLiveSandboxes() { return parseLiveSandboxNames(liveList.output).size === 0; } -function isMissingSandboxDeleteResult(output = "") { +function isMissingSandboxDeleteResult(output = ""): boolean { return /\bNotFound\b|\bNot Found\b|sandbox not found|sandbox .* not found|sandbox .* not present|sandbox does not exist|no such sandbox/i.test( stripAnsi(output), ); } -function getSandboxDeleteOutcome(deleteResult) { +function getSandboxDeleteOutcome(deleteResult: SpawnLikeResult) { const output = `${deleteResult.stdout || ""}${deleteResult.stderr || ""}`.trim(); return { output, @@ -190,7 +223,7 @@ function getInstalledOpenshellVersionOrNull() { * Run a command inside the sandbox via SSH and return { status, stdout, stderr }. * Returns null if SSH config cannot be obtained. */ -function executeSandboxCommand(sandboxName, command) { +function executeSandboxCommand(sandboxName: string, command: string): SandboxCommandResult | null { const sshConfigResult = captureOpenshell(["sandbox", "ssh-config", sandboxName], { ignoreError: true, }); @@ -239,7 +272,7 @@ function executeSandboxCommand(sandboxName, command) { * since the gateway runs as a separate user and pgrep may not see it. * Returns true (running), false (stopped), or null (cannot determine). */ -function isSandboxGatewayRunning(sandboxName) { +function isSandboxGatewayRunning(sandboxName: string): boolean | null { const agent = agentRuntime.getSessionAgent(sandboxName); // For OpenClaw (agent === null), probe /health instead of / — the root // returns 401 with device auth enabled, which curl -sf treats as failure @@ -283,16 +316,17 @@ function buildDashboardRecoverDeps() { recoverDashboardChain, buildChain, makeDeps: () => ({ - executeSandboxCommand: (name, script) => executeSandboxCommand(name, script), + executeSandboxCommand: (name: string, script: string) => executeSandboxCommand(name, script), captureForwardList: () => { const fwdResult = captureOpenshell(["forward", "list"], { ignoreError: true }); return fwdResult ? fwdResult.output : null; }, - downloadSandboxConfig: (name) => { + downloadSandboxConfig: (name: string) => { try { - const { startGatewayForRecovery } = require("./lib/onboard"); // Use the same download-and-parse pattern as fetchGatewayAuthTokenFromSandbox - const tmpDir = require("fs").mkdtempSync(require("path").join(require("os").tmpdir(), "nemoclaw-health-")); + const tmpDir = require("fs").mkdtempSync( + require("path").join(require("os").tmpdir(), "nemoclaw-health-"), + ); try { const destDir = `${tmpDir}${require("path").sep}`; const dlResult = runOpenshell( @@ -300,18 +334,26 @@ function buildDashboardRecoverDeps() { { ignoreError: true, stdio: ["ignore", "ignore", "ignore"] }, ); if (dlResult.status !== 0) return null; - const files = require("fs").readdirSync(tmpDir, { recursive: true }); - const jsonFile = files.find((f) => String(f).endsWith("openclaw.json")); + const files: string[] = require("fs").readdirSync(tmpDir, { recursive: true }); + const jsonFile = files.find((f: string) => f.endsWith("openclaw.json")); if (!jsonFile) return null; - return JSON.parse(require("fs").readFileSync(require("path").join(tmpDir, String(jsonFile)), "utf-8")); + return JSON.parse( + require("fs").readFileSync(require("path").join(tmpDir, jsonFile), "utf-8"), + ); } finally { - try { require("fs").rmSync(tmpDir, { recursive: true, force: true }); } catch {} + try { + require("fs").rmSync(tmpDir, { recursive: true, force: true }); + } catch {} } } catch { return null; } }, - restartGateway: (name, port, agent) => { + restartGateway: ( + name: string, + port: number, + agent: ReturnType, + ) => { const agentScript = agentRuntime.buildRecoveryScript(agent, port); const script = agentScript || @@ -330,11 +372,18 @@ function buildDashboardRecoverDeps() { ].join(" "); const result = executeSandboxCommand(name, script); if (!result) return false; - return result.status === 0 && (result.stdout.includes("GATEWAY_PID=") || result.stdout.includes("ALREADY_RUNNING")); + return ( + result.status === 0 && + (result.stdout.includes("GATEWAY_PID=") || result.stdout.includes("ALREADY_RUNNING")) + ); }, - stopForward: (port) => runOpenshell(["forward", "stop", String(port)], { ignoreError: true }), - startForward: (target, name) => runOpenshell(["forward", "start", "--background", target, name], { ignoreError: true }), - getSessionAgent: (name) => agentRuntime.getSessionAgent(name), + stopForward: (port: number) => + runOpenshell(["forward", "stop", String(port)], { ignoreError: true }), + startForward: (target: string, name: string) => + runOpenshell(["forward", "start", "--background", target, name], { + ignoreError: true, + }), + getSessionAgent: (name: string) => agentRuntime.getSessionAgent(name), }), }; } @@ -346,7 +395,10 @@ function buildDashboardRecoverDeps() { * * Delegates to recoverDashboardChain() for link-aware recovery. */ -function checkAndRecoverSandboxProcesses(sandboxName, { quiet = false } = {}) { +function checkAndRecoverSandboxProcesses( + sandboxName: string, + { quiet = false }: { quiet?: boolean } = {}, +) { const running = isSandboxGatewayRunning(sandboxName); if (running === null) { return { checked: false, wasRunning: null, recovered: false }; @@ -396,7 +448,10 @@ function checkAndRecoverSandboxProcesses(sandboxName, { quiet = false } = {}) { return { checked: true, wasRunning: false, recovered: false }; } -function buildRecoveredSandboxEntry(name, metadata = {}) { +function buildRecoveredSandboxEntry( + name: string, + metadata: RecoveredSandboxMetadata = {}, +): SandboxEntry { return { name, model: metadata.model || null, @@ -412,7 +467,7 @@ function buildRecoveredSandboxEntry(name, metadata = {}) { }; } -function upsertRecoveredSandbox(name, metadata = {}) { +function upsertRecoveredSandbox(name: string, metadata: RecoveredSandboxMetadata = {}) { let validName; try { validName = validateName(name, "sandbox name"); @@ -429,10 +484,15 @@ function upsertRecoveredSandbox(name, metadata = {}) { return true; } -function shouldRecoverRegistryEntries(current, session, requestedSandboxName) { - const hasSessionSandbox = Boolean(session?.sandboxName); +function shouldRecoverRegistryEntries( + current: { sandboxes: Array<{ name: string }>; defaultSandbox?: string | null }, + session: Session | null, + requestedSandboxName: string | null, +) { + const sessionSandboxName = session?.sandboxName ?? null; + const hasSessionSandbox = Boolean(sessionSandboxName); const missingSessionSandbox = - hasSessionSandbox && !current.sandboxes.some((sandbox) => sandbox.name === session.sandboxName); + hasSessionSandbox && !current.sandboxes.some((sandbox) => sandbox.name === sessionSandboxName); const missingRequestedSandbox = Boolean(requestedSandboxName) && !current.sandboxes.some((sandbox) => sandbox.name === requestedSandboxName); @@ -446,8 +506,14 @@ function shouldRecoverRegistryEntries(current, session, requestedSandboxName) { }; } -function seedRecoveryMetadata(current, session, requestedSandboxName) { - const metadataByName = new Map(current.sandboxes.map((sandbox) => [sandbox.name, sandbox])); +function seedRecoveryMetadata( + current: { sandboxes: SandboxEntry[] }, + session: Session | null, + requestedSandboxName: string | null, +) { + const metadataByName = new Map( + current.sandboxes.map((sandbox: SandboxEntry) => [sandbox.name, sandbox]), + ); let recoveredFromSession = false; if (!session?.sandboxName) { @@ -464,7 +530,7 @@ function seedRecoveryMetadata(current, session, requestedSandboxName) { }), ); const sessionSandboxMissing = !current.sandboxes.some( - (sandbox) => sandbox.name === session.sandboxName, + (sandbox: { name: string }) => sandbox.name === session.sandboxName, ); const shouldRecoverSessionSandbox = current.sandboxes.length === 0 || @@ -479,7 +545,9 @@ function seedRecoveryMetadata(current, session, requestedSandboxName) { return { metadataByName, recoveredFromSession }; } -async function recoverRegistryFromLiveGateway(metadataByName) { +async function recoverRegistryFromLiveGateway( + metadataByName: Map, +) { if (!resolveOpenshell()) { return 0; } @@ -494,9 +562,9 @@ async function recoverRegistryFromLiveGateway(metadataByName) { let recoveredFromGateway = 0; const liveList = captureOpenshell(["sandbox", "list"], { ignoreError: true }); - const liveNames = Array.from(parseLiveSandboxNames(liveList.output)); + const liveNames = Array.from(parseLiveSandboxNames(liveList.output)); for (const name of liveNames) { - const metadata = metadataByName.get(name) || {}; + const metadata = metadataByName.get(name) || undefined; if (upsertRecoveredSandbox(name, metadata)) { recoveredFromGateway += 1; } @@ -504,20 +572,26 @@ async function recoverRegistryFromLiveGateway(metadataByName) { return recoveredFromGateway; } -function applyRecoveredDefault(currentDefaultSandbox, requestedSandboxName, session) { +function applyRecoveredDefault( + currentDefaultSandbox: string | null, + requestedSandboxName: string | null, + session: Session | null, +) { const recovered = registry.listSandboxes(); const preferredDefault = requestedSandboxName || (!currentDefaultSandbox ? session?.sandboxName || null : null); if ( preferredDefault && - recovered.sandboxes.some((sandbox) => sandbox.name === preferredDefault) + recovered.sandboxes.some((sandbox: { name: string }) => sandbox.name === preferredDefault) ) { registry.setDefault(preferredDefault); } return registry.listSandboxes(); } -async function recoverRegistryEntries({ requestedSandboxName = null } = {}) { +async function recoverRegistryEntries({ + requestedSandboxName = null, +}: { requestedSandboxName?: string | null } = {}) { const current = registry.listSandboxes(); const session = onboardSession.loadSession(); const recoveryCheck = shouldRecoverRegistryEntries(current, session, requestedSandboxName); @@ -539,13 +613,13 @@ async function recoverRegistryEntries({ requestedSandboxName = null } = {}) { }; } -function hasNamedGateway(output = "") { +function hasNamedGateway(output = ""): boolean { return stripAnsi(output).includes("Gateway: nemoclaw"); } -function getActiveGatewayName(output = "") { +function getActiveGatewayName(output = ""): string | null { const match = stripAnsi(output).match(/^\s*Gateway:\s+(.+?)\s*$/m); - return match ? match[1].trim() : ""; + return match ? match[1].trim() : null; } function getNamedGatewayLifecycleState() { @@ -635,7 +709,7 @@ async function recoverNamedGatewayRuntime() { } /** Query sandbox presence and return its output with the live enforced policy. */ -function getSandboxGatewayState(sandboxName) { +function getSandboxGatewayState(sandboxName: string) { const result = captureOpenshell(["sandbox", "get", sandboxName]); let output = result.output; if (result.status === 0) { @@ -650,7 +724,7 @@ function getSandboxGatewayState(sandboxName) { if (livePolicy.status === 0 && livePolicy.output.trim()) { const rawLines = String(output).split("\n"); const cleanLines = stripAnsi(String(output)).split("\n"); - const policyLineIdx = cleanLines.findIndex((l) => l.trim() === "Policy:"); + const policyLineIdx = cleanLines.findIndex((l: string) => l.trim() === "Policy:"); if (policyLineIdx !== -1) { // Keep everything before Policy (Sandbox info with colors), // plus the original colored "Policy:" header line. @@ -671,7 +745,7 @@ function getSandboxGatewayState(sandboxName) { // Add 2-space indent to match the original sandbox get output format. const indented = trimmedYaml .split("\n") - .map((l) => (l ? " " + l : l)) + .map((l: string) => (l ? " " + l : l)) .join("\n"); output = before + "\n\n" + indented + "\n"; } @@ -701,7 +775,10 @@ function getSandboxGatewayState(sandboxName) { * re-queries, or returns a `wrong_gateway_active` state so callers can surface * actionable guidance instead of destroying the registry entry. */ -function reconcileMissingAgainstNamedGateway(sandboxName, missingLookup) { +function reconcileMissingAgainstNamedGateway( + sandboxName: string, + missingLookup: ReturnType, +) { const lifecycle = getNamedGatewayLifecycleState(); if (lifecycle.state === "connected_other") { runOpenshell(["gateway", "select", "nemoclaw"], { ignoreError: true }); @@ -735,7 +812,11 @@ function reconcileMissingAgainstNamedGateway(sandboxName, missingLookup) { * OpenShell gateway is currently active. Emphasizes that the sandbox has NOT * been removed and how to switch gateways before retrying. (#2276) */ -function printWrongGatewayActiveGuidance(sandboxName, activeGateway, writer = console.error) { +function printWrongGatewayActiveGuidance( + sandboxName: string, + activeGateway: string | null | undefined, + writer: (message: string) => void = console.error, +) { const other = activeGateway && activeGateway !== "nemoclaw" ? activeGateway : "another gateway"; writer( ` Sandbox '${sandboxName}' is registered against the NemoClaw gateway, but the currently active OpenShell gateway is '${other}'. Your sandbox has NOT been removed.`, @@ -799,7 +880,7 @@ function printGatewayLifecycleHint(output = "", sandboxName = "", writer = conso } // eslint-disable-next-line complexity -async function getReconciledSandboxGatewayState(sandboxName) { +async function getReconciledSandboxGatewayState(sandboxName: string) { let lookup = getSandboxGatewayState(sandboxName); if (lookup.state === "present") { return lookup; @@ -857,7 +938,10 @@ async function getReconciledSandboxGatewayState(sandboxName) { return lookup; } -async function ensureLiveSandboxOrExit(sandboxName, { allowNonReadyPhase = false } = {}) { +async function ensureLiveSandboxOrExit( + sandboxName: string, + { allowNonReadyPhase = false }: { allowNonReadyPhase?: boolean } = {}, +) { const lookup = await getReconciledSandboxGatewayState(sandboxName); if (lookup.state === "present") { const phase = parseSandboxPhase(lookup.output || ""); @@ -891,7 +975,7 @@ async function ensureLiveSandboxOrExit(sandboxName, { allowNonReadyPhase = false registry.removeSandbox(sandboxName); const session = onboardSession.loadSession(); if (session && session.sandboxName === sandboxName) { - onboardSession.updateSession((s) => { + onboardSession.updateSession((s: Session) => { s.sandboxName = null; return s; }); @@ -904,7 +988,11 @@ async function ensureLiveSandboxOrExit(sandboxName, { allowNonReadyPhase = false process.exit(1); } if (lookup.state === "wrong_gateway_active") { - printWrongGatewayActiveGuidance(sandboxName, lookup.activeGateway, console.error); + const activeGateway = + "activeGateway" in lookup && typeof lookup.activeGateway === "string" + ? lookup.activeGateway + : undefined; + printWrongGatewayActiveGuidance(sandboxName, activeGateway, console.error); process.exit(1); } if (lookup.state === "identity_drift") { @@ -988,7 +1076,7 @@ function printOldLogsCompatibilityGuidance(installedVersion = null) { ); } -function exitWithSpawnResult(result) { +function exitWithSpawnResult(result: SpawnLikeResult & { signal?: NodeJS.Signals | null }) { if (result.status !== null) { process.exit(result.status); } @@ -1014,7 +1102,7 @@ function printDangerouslySkipPermissionsWarning() { // ── Commands ───────────────────────────────────────────────────── -function buildOnboardCommandDeps(args) { +function buildOnboardCommandDeps(args: string[]) { const { onboard: runOnboard } = require("./lib/onboard"); const { listAgents } = require("./lib/agent-defs"); return { @@ -1026,29 +1114,29 @@ function buildOnboardCommandDeps(args) { listAgents, log: console.log, error: console.error, - exit: (code) => process.exit(code), + exit: (code: number) => process.exit(code), }; } -async function onboard(args) { +async function onboard(args: string[]): Promise { await runOnboardCommand(buildOnboardCommandDeps(args)); } -async function setup(args = []) { +async function setup(args: string[] = []): Promise { await runDeprecatedOnboardAliasCommand({ ...buildOnboardCommandDeps(args), kind: "setup", }); } -async function setupSpark(args = []) { +async function setupSpark(args: string[] = []): Promise { await runDeprecatedOnboardAliasCommand({ ...buildOnboardCommandDeps(args), kind: "setup-spark", }); } -async function deploy(instanceName) { +async function deploy(instanceName: string): Promise { await executeDeploy({ instanceName, env: process.env, @@ -1058,13 +1146,19 @@ async function deploy(instanceName) { shellQuote, run, runInteractive, - execFileSync: (file, args, opts = {}) => - String(execFileSync(file, args, { encoding: "utf-8", ...opts })), + execFileSync: ( + file: string, + args: string[], + opts: Omit< + import("node:child_process").ExecFileSyncOptionsWithStringEncoding, + "encoding" + > = {}, + ) => String(execFileSync(file, args, { encoding: "utf-8", ...opts })), spawnSync, log: console.log, error: console.error, - stdoutWrite: (message) => process.stdout.write(message), - exit: (code) => process.exit(code), + stdoutWrite: (message: string) => process.stdout.write(message), + exit: (code: number) => process.exit(code), }); } @@ -1084,7 +1178,7 @@ function stop() { }); } -async function tunnel(args) { +async function tunnel(args: string[]): Promise { const sub = args[0]; switch (sub) { case "start": @@ -1099,12 +1193,12 @@ async function tunnel(args) { } } -function debug(args) { +function debug(args: string[]) { const { runDebug } = require("./lib/debug"); const getDefaultSandbox = (): string | undefined => { const { defaultSandbox, sandboxes } = registry.listSandboxes(); if (!defaultSandbox) return undefined; - if (!sandboxes.find((s) => s.name === defaultSandbox)) { + if (!sandboxes.find((s: { name: string }) => s.name === defaultSandbox)) { console.error( `${_RD}Warning:${R} default sandbox '${defaultSandbox}' is no longer in the registry.`, ); @@ -1130,11 +1224,11 @@ function debug(args) { runDebug, log: console.log, error: console.error, - exit: (code) => process.exit(code), + exit: (code: number) => process.exit(code), }); } -function uninstall(args) { +function uninstall(args: string[]) { runUninstallCommand({ args, rootDir: ROOT, @@ -1144,11 +1238,11 @@ function uninstall(args) { spawnSyncImpl: spawnSync, log: console.log, error: console.error, - exit: (code) => process.exit(code), + exit: (code: number) => process.exit(code), }); } -async function credentialsCommand(args) { +async function credentialsCommand(args: string[]): Promise { const sub = args[0]; if (!sub || sub === "help" || sub === "--help" || sub === "-h") { console.log(""); @@ -1230,7 +1324,7 @@ async function credentialsCommand(args) { * Inspect gateway logs for known Telegram conflict signatures without blocking * the broader status command when the probe cannot run. */ -function checkMessagingBridgeHealth(sandboxName, channels) { +function checkMessagingBridgeHealth(sandboxName: string, channels: string[]) { // Only Telegram currently emits a recognizable conflict signature in the // gateway log. Discord/Slack have similar single-consumer constraints but // log differently; we can extend the regex when those patterns are known. @@ -1258,7 +1352,7 @@ function makeConflictProbe() { // get` collapses into "absent", and a transient gateway failure would // persist messagingChannels: [] and permanently suppress future retries. let gatewayAlive: boolean | null = null; - const isGatewayAlive = () => { + const isGatewayAlive = (): boolean => { if (gatewayAlive === null) { const result = captureOpenshell(["sandbox", "list"], { ignoreError: true }); gatewayAlive = result.status === 0; @@ -1266,7 +1360,7 @@ function makeConflictProbe() { return gatewayAlive; }; return { - providerExists: (name) => { + providerExists: (name: string) => { if (!isGatewayAlive()) return "error"; const result = captureOpenshell(["provider", "get", name], { ignoreError: true }); return result.status === 0 ? "present" : "absent"; @@ -1278,10 +1372,7 @@ function backfillAndFindOverlaps() { // Non-critical path: status must remain usable even if the gateway probe or // registry write throws, so any failure yields an empty overlap list. try { - const { - backfillMessagingChannels, - findAllOverlaps, - } = require("./lib/messaging-conflict"); + const { backfillMessagingChannels, findAllOverlaps } = require("./lib/messaging-conflict"); backfillMessagingChannels(registry, makeConflictProbe()); return findAllOverlaps(registry); } catch { @@ -1292,12 +1383,21 @@ function backfillAndFindOverlaps() { /** * Read a short tail of the gateway log for degraded messaging diagnostics. */ -function readGatewayLog(sandboxName) { +function readGatewayLog(sandboxName: string) { const { spawnSync } = require("child_process"); try { const result = spawnSync( getOpenshellBinary(), - ["sandbox", "exec", "-n", sandboxName, "--", "sh", "-c", "tail -n 10 /tmp/gateway.log 2>/dev/null"], + [ + "sandbox", + "exec", + "-n", + sandboxName, + "--", + "sh", + "-c", + "tail -n 10 /tmp/gateway.log 2>/dev/null", + ], { encoding: "utf-8", timeout: 3000, stdio: ["ignore", "pipe", "pipe"] }, ); const output = (result.stdout || "").trim(); @@ -1321,7 +1421,7 @@ function showStatus() { }); } -async function listSandboxes() { +async function listSandboxes(): Promise { const opsBinList = resolveOpenshell(); const sessionDeps = opsBinList ? createSessionDeps(opsBinList) : null; @@ -1341,7 +1441,7 @@ async function listSandboxes() { parseGatewayInference(captureOpenshell(["inference", "get"], { ignoreError: true }).output), loadLastSession: () => onboardSession.loadSession(), getActiveSessionCount: sessionDeps - ? (name) => { + ? (name: string) => { try { const sshOutput = getCachedSshOutput(); if (sshOutput === null) return null; @@ -1358,7 +1458,10 @@ async function listSandboxes() { // ── Sandbox-scoped actions ─────────────────────────────────────── -async function sandboxConnect(sandboxName, { dangerouslySkipPermissions = false } = {}) { +async function sandboxConnect( + sandboxName: string, + { dangerouslySkipPermissions = false }: { dangerouslySkipPermissions?: boolean } = {}, +) { const { isSandboxReady, parseSandboxStatus } = require("./lib/onboard"); await ensureLiveSandboxOrExit(sandboxName, { allowNonReadyPhase: true }); @@ -1437,7 +1540,9 @@ async function sandboxConnect(sandboxName, { dangerouslySkipPermissions = false if (rawTimeout !== undefined) { const parsed = parseInt(rawTimeout, 10); if (Number.isNaN(parsed) || parsed <= 0) { - console.warn(` Warning: invalid NEMOCLAW_CONNECT_TIMEOUT="${rawTimeout}", using default 120s`); + console.warn( + ` Warning: invalid NEMOCLAW_CONNECT_TIMEOUT="${rawTimeout}", using default 120s`, + ); } else { timeout = parsed; } @@ -1507,7 +1612,9 @@ async function sandboxConnect(sandboxName, { dangerouslySkipPermissions = false console.error(""); console.error(` Timed out after ${timeout}s waiting for sandbox '${sandboxName}'.`); console.error(` Check: openshell sandbox list`); - console.error(` Override timeout: NEMOCLAW_CONNECT_TIMEOUT=300 nemoclaw ${sandboxName} connect`); + console.error( + ` Override timeout: NEMOCLAW_CONNECT_TIMEOUT=300 nemoclaw ${sandboxName} connect`, + ); process.exit(1); } console.log(`\r Status: ${"Ready".padEnd(20)} (${elapsedSec()}s elapsed)`); @@ -1530,7 +1637,9 @@ async function sandboxConnect(sandboxName, { dangerouslySkipPermissions = false console.log( ` ${D}Inside the sandbox, run \`${agentCmd}\` to start chatting with the agent.${R}`, ); - console.log(` ${D}Type \`/exit\` to leave the chat, then \`exit\` to return to the host shell.${R}`); + console.log( + ` ${D}Type \`/exit\` to leave the chat, then \`exit\` to return to the host shell.${R}`, + ); console.log(""); } const result = spawnSync(getOpenshellBinary(), ["sandbox", "connect", sandboxName], { @@ -1542,7 +1651,7 @@ async function sandboxConnect(sandboxName, { dangerouslySkipPermissions = false } // eslint-disable-next-line complexity -async function sandboxStatus(sandboxName) { +async function sandboxStatus(sandboxName: string) { const sb = registry.getSandbox(sandboxName); const live = parseGatewayInference( captureOpenshell(["inference", "get"], { ignoreError: true }).output, @@ -1560,13 +1669,9 @@ async function sandboxStatus(sandboxName) { if (!inferenceHealth.probed) { console.log(` Inference: ${D}not probed${R} (${inferenceHealth.detail})`); } else if (inferenceHealth.ok) { - console.log( - ` Inference: ${G}healthy${R} (${inferenceHealth.endpoint})`, - ); + console.log(` Inference: ${G}healthy${R} (${inferenceHealth.endpoint})`); } else { - console.log( - ` Inference: ${_RD}unreachable${R} (${inferenceHealth.endpoint})`, - ); + console.log(` Inference: ${_RD}unreachable${R} (${inferenceHealth.endpoint})`); console.log(` ${inferenceHealth.detail}`); } } @@ -1577,10 +1682,15 @@ async function sandboxStatus(sandboxName) { try { const opsBinStatus = resolveOpenshell(); if (opsBinStatus) { - const sessionResult = getActiveSandboxSessions(sandboxName, createSessionDeps(opsBinStatus)); + const sessionResult = getActiveSandboxSessions( + sandboxName, + createSessionDeps(opsBinStatus), + ); if (sessionResult.detected) { const count = sessionResult.sessions.length; - console.log(` Connected: ${count > 0 ? `${G}yes${R} (${count} session${count > 1 ? "s" : ""})` : "no"}`); + console.log( + ` Connected: ${count > 0 ? `${G}yes${R} (${count} session${count > 1 ? "s" : ""})` : "no"}`, + ); } } } catch { @@ -1613,9 +1723,9 @@ async function sandboxStatus(sandboxName) { const lookup = await getReconciledSandboxGatewayState(sandboxName); if (lookup.state === "present") { console.log(""); - if (lookup.recoveredGateway) { + if ("recoveredGateway" in lookup && lookup.recoveredGateway) { console.log( - ` Recovered NemoClaw gateway runtime via ${lookup.recoveryVia || "gateway reattach"}.`, + ` Recovered NemoClaw gateway runtime via ${("recoveryVia" in lookup ? lookup.recoveryVia : null) || "gateway reattach"}.`, ); console.log(""); } @@ -1633,8 +1743,12 @@ async function sandboxStatus(sandboxName) { ); } } else if (lookup.state === "wrong_gateway_active") { + const activeGateway = + "activeGateway" in lookup && typeof lookup.activeGateway === "string" + ? lookup.activeGateway + : undefined; console.log(""); - printWrongGatewayActiveGuidance(sandboxName, lookup.activeGateway, console.log); + printWrongGatewayActiveGuidance(sandboxName, activeGateway, console.log); } else if (lookup.state === "missing") { // Belt-and-suspenders: only destroy registry state if the nemoclaw gateway // is demonstrably the healthy active gateway. Guards against regressions @@ -1651,7 +1765,7 @@ async function sandboxStatus(sandboxName) { registry.removeSandbox(sandboxName); const session = onboardSession.loadSession(); if (session && session.sandboxName === sandboxName) { - onboardSession.updateSession((s) => { + onboardSession.updateSession((s: Session) => { s.sandboxName = null; return s; }); @@ -1747,7 +1861,7 @@ async function sandboxStatus(sandboxName) { console.log(""); } -function sandboxLogs(sandboxName, follow) { +function sandboxLogs(sandboxName: string, follow: boolean) { const args = buildSandboxLogsArgs(sandboxName, follow); const result = runOpenshell(args, { @@ -1760,7 +1874,7 @@ function sandboxLogs(sandboxName, follow) { exitWithSpawnResult(result); } -function buildSandboxLogsArgs(sandboxName, follow) { +function buildSandboxLogsArgs(sandboxName: string, follow: boolean): string[] { const args = ["sandbox", "exec", "-n", sandboxName, "--", "tail", "-n", "200"]; if (follow) { args.push("-f"); @@ -1769,10 +1883,12 @@ function buildSandboxLogsArgs(sandboxName, follow) { return args; } -async function sandboxPolicyAdd(sandboxName, args = []) { +async function sandboxPolicyAdd(sandboxName: string, args: string[] = []): Promise { const dryRun = args.includes("--dry-run"); const skipConfirm = - args.includes("--yes") || args.includes("--force") || process.env.NEMOCLAW_NON_INTERACTIVE === "1"; + args.includes("--yes") || + args.includes("--force") || + process.env.NEMOCLAW_NON_INTERACTIVE === "1"; const allPresets = policies.listPresets(); const applied = policies.getAppliedPresets(sandboxName); @@ -1780,10 +1896,12 @@ async function sandboxPolicyAdd(sandboxName, args = []) { let answer = null; if (presetArg) { const normalized = presetArg.trim().toLowerCase(); - const preset = allPresets.find((item) => item.name === normalized); + const preset = allPresets.find((item: { name: string }) => item.name === normalized); if (!preset) { console.error(` Unknown preset '${presetArg}'.`); - console.error(` Valid presets: ${allPresets.map((item) => item.name).join(", ")}`); + console.error( + ` Valid presets: ${allPresets.map((item: { name: string }) => item.name).join(", ")}`, + ); process.exit(1); } if (applied.includes(preset.name)) { @@ -1822,7 +1940,7 @@ async function sandboxPolicyAdd(sandboxName, args = []) { policies.applyPreset(sandboxName, answer); } -function sandboxPolicyList(sandboxName) { +function sandboxPolicyList(sandboxName: string) { const allPresets = policies.listPresets(); const registryPresets = policies.getAppliedPresets(sandboxName); @@ -1832,7 +1950,7 @@ function sandboxPolicyList(sandboxName) { console.log(""); console.log(` Policy presets for sandbox '${sandboxName}':`); - allPresets.forEach((p) => { + allPresets.forEach((p: { name: string; description: string }) => { const inRegistry = registryPresets.includes(p.name); const inGateway = gatewayPresets ? gatewayPresets.includes(p.name) : null; @@ -1865,7 +1983,7 @@ function sandboxPolicyList(sandboxName) { // ── Messaging channels ─────────────────────────────────────────── -function sandboxChannelsList(sandboxName) { +function sandboxChannelsList(sandboxName: string) { console.log(""); console.log(` Known messaging channels for sandbox '${sandboxName}':`); for (const [name, channel] of Object.entries(KNOWN_CHANNELS)) { @@ -1874,12 +1992,10 @@ function sandboxChannelsList(sandboxName) { console.log(""); } -async function promptAndRebuild(sandboxName, actionDesc) { +async function promptAndRebuild(sandboxName: string, actionDesc: string): Promise { if (isNonInteractive()) { console.log(""); - console.log( - ` Change queued. Run 'nemoclaw ${sandboxName} rebuild' to apply (${actionDesc}).`, - ); + console.log(` Change queued. Run 'nemoclaw ${sandboxName} rebuild' to apply (${actionDesc}).`); return; } const answer = (await askPrompt(` Rebuild '${sandboxName}' now to apply? [Y/n]: `)) @@ -1894,7 +2010,7 @@ async function promptAndRebuild(sandboxName, actionDesc) { await sandboxRebuild(sandboxName, ["--yes"]); } -async function sandboxChannelsAdd(sandboxName, args = []) { +async function sandboxChannelsAdd(sandboxName: string, args: string[] = []): Promise { const dryRun = args.includes("--dry-run"); const channelArg = args.find((arg) => !arg.startsWith("-")); if (!channelArg) { @@ -1916,7 +2032,7 @@ async function sandboxChannelsAdd(sandboxName, args = []) { } const tokenKeys = getChannelTokenKeys(channel); - const acquired = {}; + const acquired: Record = {}; for (const envKey of tokenKeys) { const isPrimary = envKey === channel.envKey; const help = isPrimary ? channel.help : channel.appTokenHelp; @@ -1948,7 +2064,7 @@ async function sandboxChannelsAdd(sandboxName, args = []) { await promptAndRebuild(sandboxName, `add '${channelArg}'`); } -async function sandboxChannelsRemove(sandboxName, args = []) { +async function sandboxChannelsRemove(sandboxName: string, args: string[] = []): Promise { const dryRun = args.includes("--dry-run"); const channelArg = args.find((arg) => !arg.startsWith("-")); if (!channelArg) { @@ -1974,7 +2090,11 @@ async function sandboxChannelsRemove(sandboxName, args = []) { await promptAndRebuild(sandboxName, `remove '${channelArg}'`); } -async function sandboxChannelsSetEnabled(sandboxName, args, disabled) { +async function sandboxChannelsSetEnabled( + sandboxName: string, + args: string[], + disabled: boolean, +): Promise { const verb = disabled ? "stop" : "start"; const dryRun = args.includes("--dry-run"); const channelArg = args.find((arg) => !arg.startsWith("-")); @@ -2001,9 +2121,7 @@ async function sandboxChannelsSetEnabled(sandboxName, args, disabled) { } if (dryRun) { - console.log( - ` --dry-run: would ${verb} channel '${normalized}' for '${sandboxName}'.`, - ); + console.log(` --dry-run: would ${verb} channel '${normalized}' for '${sandboxName}'.`); return; } @@ -2016,11 +2134,11 @@ async function sandboxChannelsSetEnabled(sandboxName, args, disabled) { await promptAndRebuild(sandboxName, `${verb} '${normalized}'`); } -async function sandboxChannelsStop(sandboxName, args = []) { +async function sandboxChannelsStop(sandboxName: string, args: string[] = []): Promise { await sandboxChannelsSetEnabled(sandboxName, args, true); } -async function sandboxChannelsStart(sandboxName, args = []) { +async function sandboxChannelsStart(sandboxName: string, args: string[] = []): Promise { await sandboxChannelsSetEnabled(sandboxName, args, false); } @@ -2028,7 +2146,7 @@ async function sandboxChannelsStart(sandboxName, args = []) { * Install or update a local skill directory into a live sandbox and perform * any agent-specific post-install refresh needed for the new content to load. */ -async function sandboxSkillInstall(sandboxName, args = []) { +async function sandboxSkillInstall(sandboxName: string, args: string[] = []): Promise { const sub = args[0]; if (!sub || sub === "help" || sub === "--help" || sub === "-h") { console.log(""); @@ -2093,7 +2211,8 @@ async function sandboxSkillInstall(sandboxName, args = []) { const content = fs.readFileSync(skillMdPath, "utf-8"); frontmatter = skillInstall.parseFrontmatter(content); } catch (err) { - console.error(` ${err.message}`); + const errorMessage = err instanceof Error ? err.message : String(err); + console.error(` ${errorMessage}`); process.exit(1); } @@ -2178,10 +2297,12 @@ async function sandboxSkillInstall(sandboxName, args = []) { } } -async function sandboxPolicyRemove(sandboxName, args = []) { +async function sandboxPolicyRemove(sandboxName: string, args: string[] = []): Promise { const dryRun = args.includes("--dry-run"); const skipConfirm = - args.includes("--yes") || args.includes("--force") || process.env.NEMOCLAW_NON_INTERACTIVE === "1"; + args.includes("--yes") || + args.includes("--force") || + process.env.NEMOCLAW_NON_INTERACTIVE === "1"; const allPresets = policies.listPresets(); const applied = policies.getAppliedPresets(sandboxName); @@ -2189,10 +2310,12 @@ async function sandboxPolicyRemove(sandboxName, args = []) { let answer = null; if (presetArg) { const normalized = presetArg.trim().toLowerCase(); - const preset = allPresets.find((item) => item.name === normalized); + const preset = allPresets.find((item: { name: string }) => item.name === normalized); if (!preset) { console.error(` Unknown preset '${presetArg}'.`); - console.error(` Valid presets: ${allPresets.map((item) => item.name).join(", ")}`); + console.error( + ` Valid presets: ${allPresets.map((item: { name: string }) => item.name).join(", ")}`, + ); process.exit(1); } if (!applied.includes(preset.name)) { @@ -2233,7 +2356,10 @@ async function sandboxPolicyRemove(sandboxName, args = []) { } } -function cleanupSandboxServices(sandboxName, { stopHostServices = false } = {}) { +function cleanupSandboxServices( + sandboxName: string, + { stopHostServices = false }: { stopHostServices?: boolean } = {}, +) { if (stopHostServices) { const { stopAll } = require("./lib/services"); stopAll({ sandboxName }); @@ -2254,18 +2380,20 @@ function cleanupSandboxServices(sandboxName, { stopHostServices = false } = {}) * Remove the host-side Docker image that was built for a sandbox during onboard. * Must be called before registry.removeSandbox() since the imageTag is stored there. */ -function removeSandboxImage(sandboxName) { +function removeSandboxImage(sandboxName: string) { const sb = registry.getSandbox(sandboxName); if (!sb?.imageTag) return; const result = run(["docker", "rmi", sb.imageTag], { ignoreError: true }); if (result.status === 0) { console.log(` Removed Docker image ${sb.imageTag}`); } else { - console.warn(` ${YW}⚠${R} Failed to remove Docker image ${sb.imageTag}; run 'nemoclaw gc' to clean up.`); + console.warn( + ` ${YW}⚠${R} Failed to remove Docker image ${sb.imageTag}; run 'nemoclaw gc' to clean up.`, + ); } } -async function sandboxDestroy(sandboxName, args = []) { +async function sandboxDestroy(sandboxName: string, args: string[] = []): Promise { const skipConfirm = args.includes("--yes") || args.includes("--force"); // Active session detection — enrich the confirmation prompt if sessions are active @@ -2286,8 +2414,12 @@ async function sandboxDestroy(sandboxName, args = []) { console.log(` ${YW}Destroy sandbox '${sandboxName}'?${R}`); if (activeSessionCount > 0) { const plural = activeSessionCount > 1 ? "sessions" : "session"; - console.log(` ${YW}⚠ Active SSH ${plural} detected (${activeSessionCount} connection${activeSessionCount > 1 ? "s" : ""})${R}`); - console.log(` Destroying will terminate ${activeSessionCount === 1 ? "the" : "all"} active ${plural} with a Broken pipe error.`); + console.log( + ` ${YW}⚠ Active SSH ${plural} detected (${activeSessionCount} connection${activeSessionCount > 1 ? "s" : ""})${R}`, + ); + console.log( + ` Destroying will terminate ${activeSessionCount === 1 ? "the" : "all"} active ${plural} with a Broken pipe error.`, + ); } console.log(" This will permanently delete the sandbox and all workspace files inside it."); console.log(" This cannot be undone."); @@ -2335,7 +2467,7 @@ async function sandboxDestroy(sandboxName, args = []) { const removed = registry.removeSandbox(sandboxName); const session = onboardSession.loadSession(); if (session && session.sandboxName === sandboxName) { - onboardSession.updateSession((s) => { + onboardSession.updateSession((s: Session) => { s.sandboxName = null; return s; }); @@ -2356,24 +2488,28 @@ async function sandboxDestroy(sandboxName, args = []) { // ── Rebuild ────────────────────────────────────────────────────── -function _rebuildLog(msg) { +function _rebuildLog(msg: string) { console.error(` ${D}[rebuild ${new Date().toISOString()}] ${msg}${R}`); } -async function sandboxRebuild(sandboxName, args = [], opts = {}) { +async function sandboxRebuild( + sandboxName: string, + args: string[] = [], + opts: { throwOnError?: boolean } = {}, +): Promise { const verbose = args.includes("--verbose") || args.includes("-v") || process.env.NEMOCLAW_REBUILD_VERBOSE === "1"; - const log = verbose ? _rebuildLog : () => {}; + const log: (msg: string) => void = verbose ? _rebuildLog : () => {}; const skipConfirm = args.includes("--yes") || args.includes("--force"); // When called from upgradeSandboxes in a loop, throwOnError prevents // process.exit from aborting the entire batch on the first failure. const bail = opts.throwOnError - ? (msg, code = 1) => { + ? (msg: string, code = 1) => { throw new Error(msg); } - : (_msg, code = 1) => process.exit(code); + : (_msg: string, code = 1) => process.exit(code); // Active session detection — enrich the confirmation prompt if sessions are active let rebuildActiveSessionCount = 0; @@ -2422,8 +2558,12 @@ async function sandboxRebuild(sandboxName, args = [], opts = {}) { if (!skipConfirm) { if (rebuildActiveSessionCount > 0) { const plural = rebuildActiveSessionCount > 1 ? "sessions" : "session"; - console.log(` ${YW}⚠ Active SSH ${plural} detected (${rebuildActiveSessionCount} connection${rebuildActiveSessionCount > 1 ? "s" : ""})${R}`); - console.log(` Rebuilding will terminate ${rebuildActiveSessionCount === 1 ? "the" : "all"} active ${plural} with a Broken pipe error.`); + console.log( + ` ${YW}⚠ Active SSH ${plural} detected (${rebuildActiveSessionCount} connection${rebuildActiveSessionCount > 1 ? "s" : ""})${R}`, + ); + console.log( + ` Rebuilding will terminate ${rebuildActiveSessionCount === 1 ? "the" : "all"} active ${plural} with a Broken pipe error.`, + ); console.log(""); } console.log(" This will:"); @@ -2449,22 +2589,28 @@ async function sandboxRebuild(sandboxName, args = [], opts = {}) { // wrong (e.g. hermes session while rebuilding openclaw). Skip the // credential preflight; the agent sync from the registry (#2201) // and onboard itself will handle provider selection. - log(`Preflight warning: session belongs to '${session.sandboxName}', not '${sandboxName}' — skipping credential preflight`); + log( + `Preflight warning: session belongs to '${session.sandboxName}', not '${sandboxName}' — skipping credential preflight`, + ); console.log( ` ${D}Note: onboard session belongs to '${session.sandboxName}', not '${sandboxName}'. ` + - `Skipping credential preflight.${R}`, + `Skipping credential preflight.${R}`, ); } else { rebuildCredentialEnv = session?.credentialEnv || null; } if (rebuildCredentialEnv) { const credentialValue = getCredential(rebuildCredentialEnv); - log(`Preflight credential check: ${rebuildCredentialEnv} → ${credentialValue ? "present" : "MISSING"}`); + log( + `Preflight credential check: ${rebuildCredentialEnv} → ${credentialValue ? "present" : "MISSING"}`, + ); if (!credentialValue) { console.error(""); console.error(` ${_RD}Rebuild preflight failed:${R} provider credential not found.`); console.error(` The non-interactive recreate step requires ${rebuildCredentialEnv},`); - console.error(" but it is not set in the environment or saved in ~/.nemoclaw/credentials.json."); + console.error( + " but it is not set in the environment or saved in ~/.nemoclaw/credentials.json.", + ); console.error(""); console.error(" To fix, do one of:"); console.error(` export ${rebuildCredentialEnv}=`); @@ -2478,7 +2624,9 @@ async function sandboxRebuild(sandboxName, args = [], opts = {}) { // No credentialEnv in session — local inference (Ollama/vLLM) or // session was lost. Either way, skip the credential preflight; // onboard will handle it. - log("Preflight credential check: no credentialEnv in session (local inference or missing session)"); + log( + "Preflight credential check: no credentialEnv in session (local inference or missing session)", + ); } // Step 1: Ensure sandbox is live for backup @@ -2550,7 +2698,7 @@ async function sandboxRebuild(sandboxName, args = [], opts = {}) { removeSandboxImage(sandboxName); registry.removeSandbox(sandboxName); log( - `Registry after remove: ${JSON.stringify(registry.listSandboxes().sandboxes.map((s) => s.name))}`, + `Registry after remove: ${JSON.stringify(registry.listSandboxes().sandboxes.map((s: { name: string }) => s.name))}`, ); console.log(` ${G}\u2713${R} Old sandbox deleted`); @@ -2570,7 +2718,7 @@ async function sandboxRebuild(sandboxName, args = [], opts = {}) { // from a previous onboard of a *different* agent type would be picked up // by resolveAgentName() and the wrong Dockerfile would be used. (#2201) const rebuildAgent = sb.agent || null; - onboardSession.updateSession((s) => { + onboardSession.updateSession((s: Session) => { s.sandboxName = sandboxName; s.resumable = true; s.status = "in_progress"; @@ -2591,7 +2739,9 @@ async function sandboxRebuild(sandboxName, args = [], opts = {}) { // same custom image. Without this, the conflict check rejects the resume // because requestedFrom (null) !== recordedFrom (the stored path). (#2301) const storedFromDockerfile = sessionAfter?.metadata?.fromDockerfile || null; - log(`Calling onboard({ resume: true, nonInteractive: true, recreateSandbox: true, fromDockerfile: ${storedFromDockerfile} })`); + log( + `Calling onboard({ resume: true, nonInteractive: true, recreateSandbox: true, fromDockerfile: ${storedFromDockerfile} })`, + ); // Intercept process.exit during onboard so we can attempt rollback // instead of dying with the sandbox destroyed. onboard() has ~87 @@ -2629,8 +2779,10 @@ async function sandboxRebuild(sandboxName, args = [], opts = {}) { log("onboard() returned successfully"); } catch (err) { onboardFailed = true; - if (err?.name !== "RebuildOnboardExit") { - log(`onboard() threw: ${err?.message || err}`); + const message = err instanceof Error ? err.message : String(err); + const name = err instanceof Error ? err.name : ""; + if (name !== "RebuildOnboardExit") { + log(`onboard() threw: ${message}`); } } finally { process.exit = _savedExit; @@ -2642,13 +2794,19 @@ async function sandboxRebuild(sandboxName, args = [], opts = {}) { // threw from the overridden process.exit instead of actually // exiting. Without this the onboard lock file stays on disk and // blocks the next onboard/rebuild invocation. - try { onboardSession.releaseOnboardLock(); } catch { /* best effort */ } + try { + onboardSession.releaseOnboardLock(); + } catch { + /* best effort */ + } try { const failedStep = onboardSession.loadSession()?.lastStepStarted; if (failedStep) { onboardSession.markStepFailed(failedStep, "Rebuild recreate failed"); } - } catch { /* best effort */ } + } catch { + /* best effort */ + } console.error(""); console.error(` ${_RD}Recreate failed after sandbox was destroyed.${R}`); @@ -2661,7 +2819,10 @@ async function sandboxRebuild(sandboxName, args = [], opts = {}) { console.error(` 3. Then restore your workspace state:`); console.error(` nemoclaw ${sandboxName} snapshot restore "${backup.manifest.timestamp}"`); console.error(""); - bail(`Recreate failed (sandbox destroyed). Backup: ${backup.manifest.backupPath}`, onboardExitCode); + bail( + `Recreate failed (sandbox destroyed). Backup: ${backup.manifest.backupPath}`, + onboardExitCode, + ); return; } @@ -2702,7 +2863,8 @@ async function sandboxRebuild(sandboxName, args = [], opts = {}) { failedPresets.push(presetName); } } catch (err) { - log(`Failed to apply preset '${presetName}': ${err.message || err}`); + const errorMessage = err instanceof Error ? err.message : String(err); + log(`Failed to apply preset '${presetName}': ${errorMessage}`); failedPresets.push(presetName); } } @@ -2765,7 +2927,7 @@ async function sandboxRebuild(sandboxName, args = [], opts = {}) { // ── Upgrade sandboxes (#1904) ──────────────────────────────────── // Detect sandboxes running stale agent versions and offer to rebuild them. -async function upgradeSandboxes(args = []) { +async function upgradeSandboxes(args: string[] = []): Promise { const checkOnly = args.includes("--check"); const auto = args.includes("--auto"); const skipConfirm = auto || args.includes("--yes"); @@ -2838,8 +3000,8 @@ async function upgradeSandboxes(args = []) { return; } - const rebuildable = stale.filter((s) => s.running); - const stopped = stale.filter((s) => !s.running); + const rebuildable = stale.filter((s: { running: boolean }) => s.running); + const stopped = stale.filter((s: { running: boolean }) => !s.running); if (stopped.length > 0) { console.log(` ${D}Skipping ${stopped.length} stopped sandbox(es) — start them first.${R}`); } @@ -2862,7 +3024,8 @@ async function upgradeSandboxes(args = []) { await sandboxRebuild(s.name, ["--yes"], { throwOnError: true }); rebuilt++; } catch (err) { - console.error(` ${YW}\u26a0${R} Failed to rebuild '${s.name}': ${err.message}`); + const errorMessage = err instanceof Error ? err.message : String(err); + console.error(` ${YW}\u26a0${R} Failed to rebuild '${s.name}': ${errorMessage}`); failed++; } } @@ -2877,8 +3040,8 @@ async function upgradeSandboxes(args = []) { // ── Snapshot ───────────────────────────────────────────────────── -function parseSnapshotCreateFlags(flags) { - const opts = { name: null }; +function parseSnapshotCreateFlags(flags: string[]) { + const opts: { name: string | null } = { name: null }; for (let i = 0; i < flags.length; i++) { const flag = flags[i]; if (flag === "--name") { @@ -2895,11 +3058,18 @@ function parseSnapshotCreateFlags(flags) { return opts; } -function formatSnapshotVersion(b) { +function formatSnapshotVersion(b: { snapshotVersion: number }) { return `v${b.snapshotVersion}`; } -function renderSnapshotTable(backups) { +function renderSnapshotTable( + backups: Array<{ + snapshotVersion: number; + name?: string | null; + timestamp: string; + backupPath: string; + }>, +) { const rows = backups.map((b) => ({ version: formatSnapshotVersion(b), name: b.name || "", @@ -2912,7 +3082,7 @@ function renderSnapshotTable(backups) { timestamp: Math.max(9, ...rows.map((r) => r.timestamp.length)), backupPath: Math.max(4, ...rows.map((r) => r.backupPath.length)), }; - const pad = (s, n) => s + " ".repeat(Math.max(0, n - s.length)); + const pad = (s: string, n: number) => s + " ".repeat(Math.max(0, n - s.length)); console.log( ` ${B}${pad("Version", widths.version)} ${pad("Name", widths.name)} ${pad("Timestamp", widths.timestamp)} ${pad("Path", widths.backupPath)}${R}`, ); @@ -2925,7 +3095,7 @@ function renderSnapshotTable(backups) { // Query the running src pod's image reference via `kubectl` inside the // gateway container. Returns null on any failure. -function resolveSrcPodImage(srcName) { +function resolveSrcPodImage(srcName: string): string | null { const gatewayContainer = `openshell-cluster-${NEMOCLAW_GATEWAY_NAME}`; try { const result = spawnSync( @@ -2956,7 +3126,11 @@ function resolveSrcPodImage(srcName) { // Used by `snapshot restore --to ` when dst does not exist yet: reuses // the source's baked image so the user does not have to re-run onboarding. // Returns true on success; on failure, logs and calls process.exit(1). -async function autoCreateSandboxFromSource(srcName, dstName, srcEntry) { +async function autoCreateSandboxFromSource( + srcName: string, + dstName: string, + srcEntry: SandboxEntry | { name: string }, +): Promise { const sandboxCreateStream = require("./lib/sandbox-create-stream"); const { isSandboxReady } = require("./lib/gateway-state"); const basePolicy = path.join(ROOT, "nemoclaw-blueprint", "policies", "openclaw-sandbox.yaml"); @@ -2964,9 +3138,7 @@ async function autoCreateSandboxFromSource(srcName, dstName, srcEntry) { const fromImage = resolveSrcPodImage(srcName); if (!fromImage) { - console.error( - ` Cannot auto-create '${dstName}': could not resolve '${srcName}' pod image.`, - ); + console.error(` Cannot auto-create '${dstName}': could not resolve '${srcName}' pod image.`); console.error(` Create '${dstName}' manually with 'nemoclaw onboard'.`); process.exit(1); } @@ -2987,9 +3159,7 @@ async function autoCreateSandboxFromSource(srcName, dstName, srcEntry) { ].map((p) => shellQuote(p)); const command = `${cmdParts.join(" ")} 2>&1`; - console.log( - ` '${dstName}' does not exist. Creating from '${srcName}' image (${fromImage})...`, - ); + console.log(` '${dstName}' does not exist. Creating from '${srcName}' image (${fromImage})...`); const createResult = await sandboxCreateStream.streamSandboxCreate(command, process.env, { // Use a pre-built image, so skip build+push and jump to pod creation. @@ -3038,7 +3208,7 @@ async function autoCreateSandboxFromSource(srcName, dstName, srcEntry) { console.log(` ${G}\u2713${R} Sandbox '${dstName}' created`); } -async function sandboxSnapshot(sandboxName, subArgs) { +async function sandboxSnapshot(sandboxName: string, subArgs: string[]) { const subcommand = subArgs[0] || "help"; switch (subcommand) { case "create": { @@ -3060,8 +3230,7 @@ async function sandboxSnapshot(sandboxName, subArgs) { // Virtual snapshotVersion is only assigned by listBackups, so re-resolve // the just-created snapshot by its timestamp to get a valid v. const entry = - sandboxState.findBackup(sandboxName, result.manifest.timestamp).match ?? - result.manifest; + sandboxState.findBackup(sandboxName, result.manifest.timestamp).match ?? result.manifest; const v = formatSnapshotVersion(entry); const nameSuffix = entry.name ? ` name=${entry.name}` : ""; console.log( @@ -3192,25 +3361,24 @@ async function sandboxSnapshot(sandboxName, subArgs) { if (resolvedSnapshot && Array.isArray(resolvedSnapshot.policyPresets)) { const snapshotPresets = resolvedSnapshot.policyPresets; const currentPresets = policies.getAppliedPresets(targetSandbox); - const toRemove = currentPresets.filter((p) => !snapshotPresets.includes(p)); - const toAdd = snapshotPresets.filter((p) => !currentPresets.includes(p)); + const toRemove = currentPresets.filter((p: string) => !snapshotPresets.includes(p)); + const toAdd = snapshotPresets.filter((p: string) => !currentPresets.includes(p)); if (toRemove.length > 0 || toAdd.length > 0) { - const summary = []; + const summary: string[] = []; if (toAdd.length > 0) summary.push(`add ${toAdd.join(", ")}`); if (toRemove.length > 0) summary.push(`remove ${toRemove.join(", ")}`); - console.log( - ` Reconciling policy presets on '${targetSandbox}': ${summary.join("; ")}`, - ); + console.log(` Reconciling policy presets on '${targetSandbox}': ${summary.join("; ")}`); - const failed = []; + const failed: string[] = []; for (const preset of toRemove) { try { if (!policies.removePreset(targetSandbox, preset)) { failed.push(`${preset} (remove failed)`); } } catch (err) { - failed.push(`${preset} (remove: ${err.message})`); + const message = err instanceof Error ? err.message : String(err); + failed.push(`${preset} (remove: ${message})`); } } for (const preset of toAdd) { @@ -3219,7 +3387,8 @@ async function sandboxSnapshot(sandboxName, subArgs) { failed.push(`${preset} (apply failed)`); } } catch (err) { - failed.push(`${preset} (apply: ${err.message})`); + const message = err instanceof Error ? err.message : String(err); + failed.push(`${preset} (apply: ${message})`); } } if (failed.length > 0) { @@ -3232,12 +3401,20 @@ async function sandboxSnapshot(sandboxName, subArgs) { default: console.log(` Usage:`); console.log(` nemoclaw ${sandboxName} snapshot create [--name ]`); - console.log(` Create a snapshot (auto-versioned v1, v2, ...)`); + console.log( + ` Create a snapshot (auto-versioned v1, v2, ...)`, + ); console.log(` nemoclaw ${sandboxName} snapshot list List available snapshots`); console.log(` nemoclaw ${sandboxName} snapshot restore [selector] [--to ]`); - console.log(` Restore by version (v1), name, or timestamp.`); - console.log(` Omit selector to restore the most recent.`); - console.log(` Use --to to restore into another sandbox; is auto-created if missing.`); + console.log( + ` Restore by version (v1), name, or timestamp.`, + ); + console.log( + ` Omit selector to restore the most recent.`, + ); + console.log( + ` Use --to to restore into another sandbox; is auto-created if missing.`, + ); break; } } @@ -3293,14 +3470,20 @@ function backupAll() { // ── Garbage collection ────────────────────────────────────────── -async function garbageCollectImages(args = []) { +async function garbageCollectImages(args: string[] = []): Promise { const dryRun = args.includes("--dry-run"); const skipConfirm = args.includes("--yes") || args.includes("--force"); // 1. List all openshell/sandbox-from images on the host const imagesResult = spawnSync( "docker", - ["images", "--filter", "reference=openshell/sandbox-from", "--format", "{{.Repository}}:{{.Tag}}\t{{.Size}}"], + [ + "images", + "--filter", + "reference=openshell/sandbox-from", + "--format", + "{{.Repository}}:{{.Tag}}\t{{.Size}}", + ], { encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] }, ); if (imagesResult.status !== 0) { @@ -3310,9 +3493,9 @@ async function garbageCollectImages(args = []) { const allImages = (imagesResult.stdout || "") .split("\n") - .map((line) => line.trim()) + .map((line: string) => line.trim()) .filter(Boolean) - .map((line) => { + .map((line: string) => { const [tag, size] = line.split("\t"); return { tag, size: size || "unknown" }; }); @@ -3330,7 +3513,9 @@ async function garbageCollectImages(args = []) { } // 3. Cross-reference to find orphans - const orphans = allImages.filter((img) => !registeredTags.has(img.tag)); + const orphans = allImages.filter( + (img: { tag: string; size: string }) => !registeredTags.has(img.tag), + ); if (orphans.length === 0) { console.log(` All ${allImages.length} sandbox image(s) are in use. Nothing to clean up.`); @@ -3371,9 +3556,7 @@ async function garbageCollectImages(args = []) { removed++; } else { const details = `${rmiResult.stderr || rmiResult.stdout || ""}`.trim(); - console.error( - ` ${YW}⚠${R} Failed to remove ${img.tag}${details ? `: ${details}` : ""}`, - ); + console.error(` ${YW}⚠${R} Failed to remove ${img.tag}${details ? `: ${details}` : ""}`); failed++; } } @@ -3580,7 +3763,7 @@ const [cmd, ...args] = process.argv.slice(2); await recoverRegistryEntries({ requestedSandboxName: cmd }); if (!registry.getSandbox(cmd)) { console.error(` Sandbox '${cmd}' does not exist.`); - const allNames = registry.listSandboxes().sandboxes.map((s) => s.name); + const allNames = registry.listSandboxes().sandboxes.map((s: { name: string }) => s.name); if (allNames.length > 0) { console.error(""); console.error(` Registered sandboxes: ${allNames.join(", ")}`); @@ -3635,7 +3818,11 @@ const [cmd, ...args] = process.argv.slice(2); const shieldsFlags = actionArgs.slice(1); switch (shieldsSub) { case "down": { - const opts = { timeout: null, reason: null, policy: "permissive" }; + const opts: { timeout: string | null; reason: string | null; policy: string } = { + timeout: null, + reason: null, + policy: "permissive", + }; for (let i = 0; i < shieldsFlags.length; i++) { if (shieldsFlags[i] === "--timeout") { if (i + 1 >= shieldsFlags.length || shieldsFlags[i + 1].startsWith("--")) { @@ -3651,7 +3838,9 @@ const [cmd, ...args] = process.argv.slice(2); opts.reason = shieldsFlags[++i]; } else if (shieldsFlags[i] === "--policy") { if (i + 1 >= shieldsFlags.length || shieldsFlags[i + 1].startsWith("--")) { - console.error(" --policy requires a value (e.g. permissive, /path/to/policy.yaml)"); + console.error( + " --policy requires a value (e.g. permissive, /path/to/policy.yaml)", + ); process.exit(1); } opts.policy = shieldsFlags[++i]; @@ -3715,7 +3904,10 @@ const [cmd, ...args] = process.argv.slice(2); const configSub = actionArgs[0]; switch (configSub) { case "get": { - const configOpts = { key: null, format: "json" }; + const configOpts: { key: string | null; format: string } = { + key: null, + format: "json", + }; for (let i = 1; i < actionArgs.length; i++) { if (actionArgs[i] === "--key") configOpts.key = actionArgs[++i]; else if (actionArgs[i] === "--format") configOpts.format = actionArgs[++i]; @@ -3724,7 +3916,11 @@ const [cmd, ...args] = process.argv.slice(2); break; } case "set": { - const setOpts = { key: null, value: null, restart: false }; + const setOpts: { key: string | null; value: string | null; restart: boolean } = { + key: null, + value: null, + restart: false, + }; for (let i = 1; i < actionArgs.length; i++) { if (actionArgs[i] === "--key") setOpts.key = actionArgs[++i]; else if (actionArgs[i] === "--value") setOpts.value = actionArgs[++i]; @@ -3734,7 +3930,10 @@ const [cmd, ...args] = process.argv.slice(2); break; } case "rotate-token": { - const tokenOpts = { fromEnv: null, fromStdin: false }; + const tokenOpts: { fromEnv: string | null; fromStdin: boolean } = { + fromEnv: null, + fromStdin: false, + }; for (let i = 1; i < actionArgs.length; i++) { if (actionArgs[i] === "--from-env") tokenOpts.fromEnv = actionArgs[++i]; else if (actionArgs[i] === "--from-stdin") tokenOpts.fromStdin = true; @@ -3766,7 +3965,7 @@ const [cmd, ...args] = process.argv.slice(2); console.error(""); // Check if it looks like a sandbox name with missing action - const allNames = registry.listSandboxes().sandboxes.map((s) => s.name); + const allNames = registry.listSandboxes().sandboxes.map((s: { name: string }) => s.name); if (allNames.length > 0) { console.error(` Registered sandboxes: ${allNames.join(", ")}`); console.error(` Try: nemoclaw connect`); diff --git a/test/check-docs-links.test.ts b/test/check-docs-links.test.ts index 4ea5fdd3a56..03e8486acf6 100644 --- a/test/check-docs-links.test.ts +++ b/test/check-docs-links.test.ts @@ -1,4 +1,3 @@ -// @ts-nocheck // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 @@ -10,7 +9,7 @@ import path from "node:path"; const CHECK_DOCS = path.join(import.meta.dirname, "e2e", "e2e-cloud-experimental", "check-docs.sh"); -function runCheckDocs(filePath) { +function runCheckDocs(filePath: string) { return spawnSync("bash", [CHECK_DOCS, "--only-links", "--local-only", filePath], { encoding: "utf-8", }); diff --git a/test/cli.test.ts b/test/cli.test.ts index 378679bfba2..d5e8eb768f9 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -1,4 +1,3 @@ -// @ts-nocheck // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 @@ -10,11 +9,58 @@ import path from "node:path"; const CLI = path.join(import.meta.dirname, "..", "bin", "nemoclaw.js"); -function run(args) { +type CliRunResult = { + code: number; + out: string; +}; + +type CliErrorShape = { + status?: number; + stdout?: string | Buffer; + stderr?: string | Buffer; +}; + +type CliErrorCandidate = { + status?: unknown; + stdout?: unknown; + stderr?: unknown; +}; + +function isCliErrorCandidate(value: unknown): value is CliErrorCandidate { + return typeof value === "object" && value !== null; +} + +function readBufferOrStringProperty( + value: CliErrorCandidate, + key: "stdout" | "stderr", +): string | Buffer | undefined { + const property = value[key]; + return typeof property === "string" || Buffer.isBuffer(property) ? property : undefined; +} + +function toText(value: string | Buffer | undefined): string { + return typeof value === "string" ? value : Buffer.isBuffer(value) ? value.toString("utf8") : ""; +} + +function readCliErrorOutput(error: CliErrorShape | string | null | undefined): CliRunResult { + if (!error || typeof error === "string") { + return { code: 1, out: String(error || "") }; + } + return { + code: typeof error.status === "number" ? error.status : 1, + out: `${toText(error.stdout)}${toText(error.stderr)}`, + }; +} + +function run(args: string): CliRunResult { return runWithEnv(args); } -function runWithEnv(args, env = {}, timeout = Number(process.env.NEMOCLAW_EXEC_TIMEOUT || 10000)) { +function runWithEnv( + args: string, + env: Record = {}, + timeout: number = Number(process.env.NEMOCLAW_EXEC_TIMEOUT || 10000), +): CliRunResult { try { const out = execSync(`node "${CLI}" ${args}`, { encoding: "utf-8", @@ -29,11 +75,18 @@ function runWithEnv(args, env = {}, timeout = Number(process.env.NEMOCLAW_EXEC_T }); return { code: 0, out }; } catch (err) { - return { code: err.status, out: (err.stdout || "") + (err.stderr || "") }; + if (isCliErrorCandidate(err)) { + return readCliErrorOutput({ + status: typeof err.status === "number" ? err.status : undefined, + stdout: readBufferOrStringProperty(err, "stdout"), + stderr: readBufferOrStringProperty(err, "stderr"), + }); + } + return readCliErrorOutput(String(err)); } } -function readRecordedArgs(markerFile) { +function readRecordedArgs(markerFile: string): string[] { return fs.readFileSync(markerFile, "utf8").trim().split(/\s+/); } @@ -179,7 +232,9 @@ describe("CLI dispatch", () => { expect(r.code).toBe(1); expect(r.out.includes("No resumable onboarding session was found")).toBeTruthy(); expect(r.out.includes("--resume only continues an interrupted onboarding run")).toBeTruthy(); - expect(r.out.includes("To change configuration on an existing sandbox, rebuild it")).toBeTruthy(); + expect( + r.out.includes("To change configuration on an existing sandbox, rebuild it"), + ).toBeTruthy(); expect(r.out.includes("nemoclaw onboard")).toBeTruthy(); }); @@ -345,7 +400,7 @@ describe("CLI dispatch", () => { }, defaultSandbox: "alpha", }), - { mode: 0o600 } + { mode: 0o600 }, ); fs.writeFileSync( path.join(localBin, "openshell"), @@ -356,10 +411,10 @@ describe("CLI dispatch", () => { " echo 'openshell 0.0.16'", " exit 0", "fi", - "printf '%s ' \"$@\" > \"$marker_file\"", + 'printf \'%s \' "$@" > "$marker_file"', "exit 0", ].join("\n"), - { mode: 0o755 } + { mode: 0o755 }, ); const r = runWithEnv("alpha logs --follow", { @@ -923,22 +978,20 @@ describe("CLI dispatch", () => { " echo ' Phase: Ready'", " exit 0", "fi", - "if [ \"$1\" = \"sandbox\" ] && [ \"$2\" = \"list\" ]; then", + 'if [ "$1" = "sandbox" ] && [ "$2" = "list" ]; then', " echo 'alpha Ready 2m ago'", " exit 0", "fi", - "if [ \"$1\" = \"sandbox\" ] && [ \"$2\" = \"connect\" ] && [ \"$3\" = \"alpha\" ]; then", + 'if [ "$1" = "sandbox" ] && [ "$2" = "connect" ] && [ "$3" = "alpha" ]; then', " exit 0", "fi", "exit 0", ].join("\n"), { mode: 0o755 }, ); - fs.writeFileSync( - path.join(localBin, "sleep"), - ["#!/usr/bin/env bash", "exit 0"].join("\n"), - { mode: 0o755 } - ); + fs.writeFileSync(path.join(localBin, "sleep"), ["#!/usr/bin/env bash", "exit 0"].join("\n"), { + mode: 0o755, + }); const r = runWithEnv("alpha connect", { HOME: home, @@ -974,7 +1027,7 @@ describe("CLI dispatch", () => { }, defaultSandbox: "alpha", }), - { mode: 0o600 } + { mode: 0o600 }, ); fs.writeFileSync( path.join(localBin, "openshell"), @@ -982,8 +1035,8 @@ describe("CLI dispatch", () => { "#!/usr/bin/env bash", `marker_file=${JSON.stringify(markerFile)}`, `state_file=${JSON.stringify(stateFile)}`, - "printf '%s\\n' \"$*\" >> \"$marker_file\"", - "if [ \"$1\" = \"sandbox\" ] && [ \"$2\" = \"get\" ] && [ \"$3\" = \"alpha\" ]; then", + 'printf \'%s\\n\' "$*" >> "$marker_file"', + 'if [ "$1" = "sandbox" ] && [ "$2" = "get" ] && [ "$3" = "alpha" ]; then', " echo 'Sandbox:'", " echo", " echo ' Id: abc'", @@ -992,29 +1045,27 @@ describe("CLI dispatch", () => { " echo ' Phase: Pending'", " exit 0", "fi", - "if [ \"$1\" = \"sandbox\" ] && [ \"$2\" = \"list\" ]; then", - " count=$(cat \"$state_file\" 2>/dev/null || echo 0)", + 'if [ "$1" = "sandbox" ] && [ "$2" = "list" ]; then', + ' count=$(cat "$state_file" 2>/dev/null || echo 0)', " count=$((count + 1))", - " echo \"$count\" > \"$state_file\"", - " if [ \"$count\" -eq 1 ]; then", + ' echo "$count" > "$state_file"', + ' if [ "$count" -eq 1 ]; then', " echo 'alpha ContainerCreating 10s ago'", " else", " echo 'alpha Ready 20s ago'", " fi", " exit 0", "fi", - "if [ \"$1\" = \"sandbox\" ] && [ \"$2\" = \"connect\" ] && [ \"$3\" = \"alpha\" ]; then", + 'if [ "$1" = "sandbox" ] && [ "$2" = "connect" ] && [ "$3" = "alpha" ]; then', " exit 0", "fi", "exit 0", ].join("\n"), - { mode: 0o755 } - ); - fs.writeFileSync( - path.join(localBin, "sleep"), - ["#!/usr/bin/env bash", "exit 0"].join("\n"), - { mode: 0o755 } + { mode: 0o755 }, ); + fs.writeFileSync(path.join(localBin, "sleep"), ["#!/usr/bin/env bash", "exit 0"].join("\n"), { + mode: 0o755, + }); const r = runWithEnv("alpha connect", { HOME: home, @@ -1051,15 +1102,15 @@ describe("CLI dispatch", () => { }, defaultSandbox: "alpha", }), - { mode: 0o600 } + { mode: 0o600 }, ); fs.writeFileSync( path.join(localBin, "openshell"), [ "#!/usr/bin/env bash", `marker_file=${JSON.stringify(markerFile)}`, - "printf '%s\\n' \"$*\" >> \"$marker_file\"", - "if [ \"$1\" = \"sandbox\" ] && [ \"$2\" = \"get\" ] && [ \"$3\" = \"alpha\" ]; then", + 'printf \'%s\\n\' "$*" >> "$marker_file"', + 'if [ "$1" = "sandbox" ] && [ "$2" = "get" ] && [ "$3" = "alpha" ]; then', " echo 'Sandbox:'", " echo", " echo ' Id: abc'", @@ -1068,17 +1119,17 @@ describe("CLI dispatch", () => { " echo ' Phase: Failed'", " exit 0", "fi", - "if [ \"$1\" = \"sandbox\" ] && [ \"$2\" = \"list\" ]; then", + 'if [ "$1" = "sandbox" ] && [ "$2" = "list" ]; then', " echo 'alpha Failed 1m ago'", " exit 0", "fi", - "if [ \"$1\" = \"sandbox\" ] && [ \"$2\" = \"connect\" ] && [ \"$3\" = \"alpha\" ]; then", + 'if [ "$1" = "sandbox" ] && [ "$2" = "connect" ] && [ "$3" = "alpha" ]; then', " echo 'should-not-connect' >> \"$marker_file\"", " exit 0", "fi", "exit 0", ].join("\n"), - { mode: 0o755 } + { mode: 0o755 }, ); const r = runWithEnv("alpha connect", { @@ -1752,56 +1803,60 @@ describe("CLI dispatch", () => { expect(result.status).toBe(130); }); - it("keeps registry entries when status hits a gateway-level transport error", () => { - const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-gateway-error-")); - const localBin = path.join(home, "bin"); - const registryDir = path.join(home, ".nemoclaw"); - fs.mkdirSync(localBin, { recursive: true }); - fs.mkdirSync(registryDir, { recursive: true }); - fs.writeFileSync( - path.join(registryDir, "sandboxes.json"), - JSON.stringify({ - sandboxes: { - alpha: { - name: "alpha", - model: "test-model", - provider: "nvidia-prod", - gpuEnabled: false, - policies: [], + it( + "keeps registry entries when status hits a gateway-level transport error", + () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-gateway-error-")); + const localBin = path.join(home, "bin"); + const registryDir = path.join(home, ".nemoclaw"); + fs.mkdirSync(localBin, { recursive: true }); + fs.mkdirSync(registryDir, { recursive: true }); + fs.writeFileSync( + path.join(registryDir, "sandboxes.json"), + JSON.stringify({ + sandboxes: { + alpha: { + name: "alpha", + model: "test-model", + provider: "nvidia-prod", + gpuEnabled: false, + policies: [], + }, }, - }, - defaultSandbox: "alpha", - }), - { mode: 0o600 }, - ); - fs.writeFileSync( - path.join(localBin, "openshell"), - [ - "#!/usr/bin/env bash", - 'if [ "$1" = "sandbox" ] && [ "$2" = "get" ] && [ "$3" = "alpha" ]; then', - " echo 'Error: transport error: handshake verification failed' >&2", - " exit 1", - "fi", - "exit 0", - ].join("\n"), - { mode: 0o755 }, - ); + defaultSandbox: "alpha", + }), + { mode: 0o600 }, + ); + fs.writeFileSync( + path.join(localBin, "openshell"), + [ + "#!/usr/bin/env bash", + 'if [ "$1" = "sandbox" ] && [ "$2" = "get" ] && [ "$3" = "alpha" ]; then', + " echo 'Error: transport error: handshake verification failed' >&2", + " exit 1", + "fi", + "exit 0", + ].join("\n"), + { mode: 0o755 }, + ); - const r = runWithEnv( - "alpha status", - { - HOME: home, - PATH: `${localBin}:${process.env.PATH || ""}`, - }, - Number(process.env.NEMOCLAW_EXEC_TIMEOUT || 10000), - ); + const r = runWithEnv( + "alpha status", + { + HOME: home, + PATH: `${localBin}:${process.env.PATH || ""}`, + }, + Number(process.env.NEMOCLAW_EXEC_TIMEOUT || 10000), + ); - expect(r.code).toBe(0); - expect(r.out.includes("Could not verify sandbox 'alpha'")).toBeTruthy(); - expect(r.out.includes("gateway identity drift after restart")).toBeTruthy(); - const saved = JSON.parse(fs.readFileSync(path.join(registryDir, "sandboxes.json"), "utf8")); - expect(saved.sandboxes.alpha).toBeTruthy(); - }, Number(process.env.NEMOCLAW_TEST_TIMEOUT || 10000)); + expect(r.code).toBe(0); + expect(r.out.includes("Could not verify sandbox 'alpha'")).toBeTruthy(); + expect(r.out.includes("gateway identity drift after restart")).toBeTruthy(); + const saved = JSON.parse(fs.readFileSync(path.join(registryDir, "sandboxes.json"), "utf8")); + expect(saved.sandboxes.alpha).toBeTruthy(); + }, + Number(process.env.NEMOCLAW_TEST_TIMEOUT || 10000), + ); it("recovers status after gateway runtime is reattached", () => { const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-recover-status-")); @@ -1923,19 +1978,19 @@ describe("CLI dispatch", () => { 'while [ "$#" -gt 0 ]; do', ' case "$1" in', ' -o) out="$2"; shift 2 ;;', - ' -w|--connect-timeout|--max-time) shift 2 ;;', - ' -s|-S|-sS|-f) shift ;;', + " -w|--connect-timeout|--max-time) shift 2 ;;", + " -s|-S|-sS|-f) shift ;;", ' http://*|https://*) url="$1"; shift ;;', - ' *) shift ;;', - ' esac', - 'done', + " *) shift ;;", + " esac", + "done", 'if [ -n "$out" ]; then : > "$out"; fi', 'if echo "$url" | grep -q "11434/api/tags"; then', ' printf "000"', - ' exit 7', - 'fi', + " exit 7", + "fi", 'printf "000"', - 'exit 7', + "exit 7", ].join("\n"), { mode: 0o755 }, ); @@ -1952,8 +2007,218 @@ describe("CLI dispatch", () => { expect(r.out).toContain("http://127.0.0.1:11434/api/tags"); }); - it("does not treat a different connected gateway as a healthy nemoclaw gateway", () => { - const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-mixed-gateway-")); + it( + "does not treat a different connected gateway as a healthy nemoclaw gateway", + () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-mixed-gateway-")); + const localBin = path.join(home, "bin"); + const registryDir = path.join(home, ".nemoclaw"); + fs.mkdirSync(localBin, { recursive: true }); + fs.mkdirSync(registryDir, { recursive: true }); + fs.writeFileSync( + path.join(registryDir, "sandboxes.json"), + JSON.stringify({ + sandboxes: { + alpha: { + name: "alpha", + model: "test-model", + provider: "nvidia-prod", + gpuEnabled: false, + policies: [], + }, + }, + defaultSandbox: "alpha", + }), + { mode: 0o600 }, + ); + fs.writeFileSync( + path.join(localBin, "openshell"), + [ + "#!/usr/bin/env bash", + 'if [ "$1" = "sandbox" ] && [ "$2" = "get" ] && [ "$3" = "alpha" ]; then', + " echo 'Error: transport error: Connection refused' >&2", + " exit 1", + "fi", + 'if [ "$1" = "status" ]; then', + " echo 'Server Status'", + " echo", + " echo ' Gateway: openshell'", + " echo ' Status: Connected'", + " exit 0", + "fi", + 'if [ "$1" = "gateway" ] && [ "$2" = "info" ] && [ "$3" = "-g" ] && [ "$4" = "nemoclaw" ]; then', + " echo 'Gateway Info'", + " echo", + " echo ' Gateway: nemoclaw'", + " exit 0", + "fi", + 'if [ "$1" = "gateway" ] && [ "$2" = "select" ] && [ "$3" = "nemoclaw" ]; then', + " exit 0", + "fi", + 'if [ "$1" = "gateway" ] && [ "$2" = "start" ] && [ "$3" = "--name" ] && [ "$4" = "nemoclaw" ]; then', + " exit 0", + "fi", + 'if [ "$1" = "inference" ] && [ "$2" = "get" ]; then', + " exit 0", + "fi", + "exit 0", + ].join("\n"), + { mode: 0o755 }, + ); + + const r = runWithEnv( + "alpha status", + { + HOME: home, + PATH: `${localBin}:${process.env.PATH || ""}`, + }, + Number(process.env.NEMOCLAW_EXEC_TIMEOUT || 10000), + ); + + expect(r.code).toBe(0); + expect(r.out.includes("Recovered NemoClaw gateway runtime")).toBeFalsy(); + expect(r.out.includes("Could not verify sandbox 'alpha'")).toBeTruthy(); + expect(r.out.includes("verify the active gateway")).toBeTruthy(); + }, + Number(process.env.NEMOCLAW_TEST_TIMEOUT || 10000), + ); + + it( + "matches ANSI-decorated gateway transport errors when printing lifecycle hints", + () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-ansi-transport-hint-")); + const localBin = path.join(home, "bin"); + const registryDir = path.join(home, ".nemoclaw"); + fs.mkdirSync(localBin, { recursive: true }); + fs.mkdirSync(registryDir, { recursive: true }); + fs.writeFileSync( + path.join(registryDir, "sandboxes.json"), + JSON.stringify({ + sandboxes: { + alpha: { + name: "alpha", + model: "test-model", + provider: "nvidia-prod", + gpuEnabled: false, + policies: [], + }, + }, + defaultSandbox: "alpha", + }), + { mode: 0o600 }, + ); + fs.writeFileSync( + path.join(localBin, "openshell"), + [ + "#!/usr/bin/env bash", + 'if [ "$1" = "sandbox" ] && [ "$2" = "get" ] && [ "$3" = "alpha" ]; then', + " printf '\\033[31mError: trans\\033[0mport error: Connec\\033[33mtion refused\\033[0m\\n' >&2", + " exit 1", + "fi", + 'if [ "$1" = "status" ]; then', + " echo 'Server Status'", + " echo", + " echo ' Gateway: openshell'", + " echo ' Status: Disconnected'", + " exit 0", + "fi", + 'if [ "$1" = "gateway" ] && [ "$2" = "info" ] && [ "$3" = "-g" ] && [ "$4" = "nemoclaw" ]; then', + " printf 'Gateway Info\\n\\n Gateway: openshell\\n'", + " exit 0", + "fi", + 'if [ "$1" = "gateway" ] && [ "$2" = "select" ] && [ "$3" = "nemoclaw" ]; then', + " exit 0", + "fi", + "exit 0", + ].join("\n"), + { mode: 0o755 }, + ); + + const r = runWithEnv( + "alpha status", + { + HOME: home, + PATH: `${localBin}:${process.env.PATH || ""}`, + }, + Number(process.env.NEMOCLAW_EXEC_TIMEOUT || 10000), + ); + + expect(r.code).toBe(0); + expect(r.out.includes("current gateway/runtime is not reachable")).toBeTruthy(); + }, + Number(process.env.NEMOCLAW_TEST_TIMEOUT || 10000), + ); + + it( + "matches ANSI-decorated gateway auth errors when printing lifecycle hints", + () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-ansi-auth-hint-")); + const localBin = path.join(home, "bin"); + const registryDir = path.join(home, ".nemoclaw"); + fs.mkdirSync(localBin, { recursive: true }); + fs.mkdirSync(registryDir, { recursive: true }); + fs.writeFileSync( + path.join(registryDir, "sandboxes.json"), + JSON.stringify({ + sandboxes: { + alpha: { + name: "alpha", + model: "test-model", + provider: "nvidia-prod", + gpuEnabled: false, + policies: [], + }, + }, + defaultSandbox: "alpha", + }), + { mode: 0o600 }, + ); + fs.writeFileSync( + path.join(localBin, "openshell"), + [ + "#!/usr/bin/env bash", + 'if [ "$1" = "sandbox" ] && [ "$2" = "get" ] && [ "$3" = "alpha" ]; then', + " printf '\\033[31mMissing gateway auth\\033[0m token\\n' >&2", + " exit 1", + "fi", + 'if [ "$1" = "status" ]; then', + " echo 'Server Status'", + " echo", + " echo ' Gateway: openshell'", + " echo ' Status: Disconnected'", + " exit 0", + "fi", + 'if [ "$1" = "gateway" ] && [ "$2" = "info" ] && [ "$3" = "-g" ] && [ "$4" = "nemoclaw" ]; then', + " printf 'Gateway Info\\n\\n Gateway: openshell\\n'", + " exit 0", + "fi", + 'if [ "$1" = "gateway" ] && [ "$2" = "select" ] && [ "$3" = "nemoclaw" ]; then', + " exit 0", + "fi", + "exit 0", + ].join("\n"), + { mode: 0o755 }, + ); + + const r = runWithEnv( + "alpha status", + { + HOME: home, + PATH: `${localBin}:${process.env.PATH || ""}`, + }, + Number(process.env.NEMOCLAW_EXEC_TIMEOUT || 10000), + ); + + expect(r.code).toBe(0); + expect( + r.out.includes("Verify the active gateway and retry after re-establishing the runtime."), + ).toBeTruthy(); + }, + Number(process.env.NEMOCLAW_TEST_TIMEOUT || 10000), + ); + + it("explains unrecoverable gateway trust rotation after restart", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-identity-drift-")); const localBin = path.join(home, "bin"); const registryDir = path.join(home, ".nemoclaw"); fs.mkdirSync(localBin, { recursive: true }); @@ -1979,13 +2244,13 @@ describe("CLI dispatch", () => { [ "#!/usr/bin/env bash", 'if [ "$1" = "sandbox" ] && [ "$2" = "get" ] && [ "$3" = "alpha" ]; then', - " echo 'Error: transport error: Connection refused' >&2", + " echo 'Error: transport error: handshake verification failed' >&2", " exit 1", "fi", 'if [ "$1" = "status" ]; then', " echo 'Server Status'", " echo", - " echo ' Gateway: openshell'", + " echo ' Gateway: nemoclaw'", " echo ' Status: Connected'", " exit 0", "fi", @@ -1995,21 +2260,12 @@ describe("CLI dispatch", () => { " echo ' Gateway: nemoclaw'", " exit 0", "fi", - 'if [ "$1" = "gateway" ] && [ "$2" = "select" ] && [ "$3" = "nemoclaw" ]; then', - " exit 0", - "fi", - 'if [ "$1" = "gateway" ] && [ "$2" = "start" ] && [ "$3" = "--name" ] && [ "$4" = "nemoclaw" ]; then', - " exit 0", - "fi", - 'if [ "$1" = "inference" ] && [ "$2" = "get" ]; then', - " exit 0", - "fi", "exit 0", ].join("\n"), { mode: 0o755 }, ); - const r = runWithEnv( + const statusResult = runWithEnv( "alpha status", { HOME: home, @@ -2017,357 +2273,176 @@ describe("CLI dispatch", () => { }, Number(process.env.NEMOCLAW_EXEC_TIMEOUT || 10000), ); + expect(statusResult.code).toBe(0); + expect(statusResult.out.includes("gateway trust material rotated after restart")).toBeTruthy(); + expect(statusResult.out.includes("cannot be reattached safely")).toBeTruthy(); - expect(r.code).toBe(0); - expect(r.out.includes("Recovered NemoClaw gateway runtime")).toBeFalsy(); - expect(r.out.includes("Could not verify sandbox 'alpha'")).toBeTruthy(); - expect(r.out.includes("verify the active gateway")).toBeTruthy(); - }, Number(process.env.NEMOCLAW_TEST_TIMEOUT || 10000)); + const connectResult = runWithEnv("alpha connect", { + HOME: home, + PATH: `${localBin}:${process.env.PATH || ""}`, + }); + expect(connectResult.code).toBe(1); + // After the auto-recovery attempt (clear stale host keys + retry), the + // fake openshell still returns the handshake error, so recovery fails. + expect(connectResult.out.includes("Could not reconnect")).toBeTruthy(); + expect(connectResult.out.includes("Recreate this sandbox")).toBeTruthy(); + }); - it("matches ANSI-decorated gateway transport errors when printing lifecycle hints", () => { - const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-ansi-transport-hint-")); - const localBin = path.join(home, "bin"); - const registryDir = path.join(home, ".nemoclaw"); - fs.mkdirSync(localBin, { recursive: true }); - fs.mkdirSync(registryDir, { recursive: true }); - fs.writeFileSync( - path.join(registryDir, "sandboxes.json"), - JSON.stringify({ - sandboxes: { - alpha: { - name: "alpha", - model: "test-model", - provider: "nvidia-prod", - gpuEnabled: false, - policies: [], - }, - }, - defaultSandbox: "alpha", - }), - { mode: 0o600 }, - ); - fs.writeFileSync( - path.join(localBin, "openshell"), - [ - "#!/usr/bin/env bash", - 'if [ "$1" = "sandbox" ] && [ "$2" = "get" ] && [ "$3" = "alpha" ]; then', - " printf '\\033[31mError: trans\\033[0mport error: Connec\\033[33mtion refused\\033[0m\\n' >&2", - " exit 1", - "fi", - 'if [ "$1" = "status" ]; then', - " echo 'Server Status'", - " echo", - " echo ' Gateway: openshell'", - " echo ' Status: Disconnected'", - " exit 0", - "fi", - 'if [ "$1" = "gateway" ] && [ "$2" = "info" ] && [ "$3" = "-g" ] && [ "$4" = "nemoclaw" ]; then', - " printf 'Gateway Info\\n\\n Gateway: openshell\\n'", - " exit 0", - "fi", - 'if [ "$1" = "gateway" ] && [ "$2" = "select" ] && [ "$3" = "nemoclaw" ]; then', - " exit 0", - "fi", - "exit 0", - ].join("\n"), - { mode: 0o755 }, - ); - - const r = runWithEnv( - "alpha status", - { - HOME: home, - PATH: `${localBin}:${process.env.PATH || ""}`, - }, - Number(process.env.NEMOCLAW_EXEC_TIMEOUT || 10000), - ); - - expect(r.code).toBe(0); - expect(r.out.includes("current gateway/runtime is not reachable")).toBeTruthy(); - }, Number(process.env.NEMOCLAW_TEST_TIMEOUT || 10000)); - - it("matches ANSI-decorated gateway auth errors when printing lifecycle hints", () => { - const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-ansi-auth-hint-")); - const localBin = path.join(home, "bin"); - const registryDir = path.join(home, ".nemoclaw"); - fs.mkdirSync(localBin, { recursive: true }); - fs.mkdirSync(registryDir, { recursive: true }); - fs.writeFileSync( - path.join(registryDir, "sandboxes.json"), - JSON.stringify({ - sandboxes: { - alpha: { - name: "alpha", - model: "test-model", - provider: "nvidia-prod", - gpuEnabled: false, - policies: [], + it( + "explains when gateway metadata exists but the restarted API is still refusing connections", + () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-gateway-unreachable-")); + const localBin = path.join(home, "bin"); + const registryDir = path.join(home, ".nemoclaw"); + fs.mkdirSync(localBin, { recursive: true }); + fs.mkdirSync(registryDir, { recursive: true }); + fs.writeFileSync( + path.join(registryDir, "sandboxes.json"), + JSON.stringify({ + sandboxes: { + alpha: { + name: "alpha", + model: "test-model", + provider: "nvidia-prod", + gpuEnabled: false, + policies: [], + }, }, - }, - defaultSandbox: "alpha", - }), - { mode: 0o600 }, - ); - fs.writeFileSync( - path.join(localBin, "openshell"), - [ - "#!/usr/bin/env bash", - 'if [ "$1" = "sandbox" ] && [ "$2" = "get" ] && [ "$3" = "alpha" ]; then', - " printf '\\033[31mMissing gateway auth\\033[0m token\\n' >&2", - " exit 1", - "fi", - 'if [ "$1" = "status" ]; then', - " echo 'Server Status'", - " echo", - " echo ' Gateway: openshell'", - " echo ' Status: Disconnected'", - " exit 0", - "fi", - 'if [ "$1" = "gateway" ] && [ "$2" = "info" ] && [ "$3" = "-g" ] && [ "$4" = "nemoclaw" ]; then', - " printf 'Gateway Info\\n\\n Gateway: openshell\\n'", - " exit 0", - "fi", - 'if [ "$1" = "gateway" ] && [ "$2" = "select" ] && [ "$3" = "nemoclaw" ]; then', - " exit 0", - "fi", - "exit 0", - ].join("\n"), - { mode: 0o755 }, - ); - - const r = runWithEnv( - "alpha status", - { - HOME: home, - PATH: `${localBin}:${process.env.PATH || ""}`, - }, - Number(process.env.NEMOCLAW_EXEC_TIMEOUT || 10000), - ); - - expect(r.code).toBe(0); - expect( - r.out.includes("Verify the active gateway and retry after re-establishing the runtime."), - ).toBeTruthy(); - }, Number(process.env.NEMOCLAW_TEST_TIMEOUT || 10000)); + defaultSandbox: "alpha", + }), + { mode: 0o600 }, + ); + fs.writeFileSync( + path.join(localBin, "openshell"), + [ + "#!/usr/bin/env bash", + 'if [ "$1" = "sandbox" ] && [ "$2" = "get" ] && [ "$3" = "alpha" ]; then', + " echo 'Error: transport error: Connection refused' >&2", + " exit 1", + "fi", + 'if [ "$1" = "status" ]; then', + " echo 'Server Status'", + " echo", + " echo ' Gateway: nemoclaw'", + " echo ' Server: https://127.0.0.1:8080'", + " echo 'Error: client error (Connect)' >&2", + " echo 'Connection refused (os error 111)' >&2", + " exit 1", + "fi", + 'if [ "$1" = "gateway" ] && [ "$2" = "info" ] && [ "$3" = "-g" ] && [ "$4" = "nemoclaw" ]; then', + " echo 'Gateway Info'", + " echo", + " echo ' Gateway: nemoclaw'", + " exit 0", + "fi", + 'if [ "$1" = "gateway" ] && [ "$2" = "select" ] && [ "$3" = "nemoclaw" ]; then', + " exit 0", + "fi", + 'if [ "$1" = "gateway" ] && [ "$2" = "start" ] && [ "$3" = "--name" ] && [ "$4" = "nemoclaw" ]; then', + " exit 0", + "fi", + "exit 0", + ].join("\n"), + { mode: 0o755 }, + ); - it("explains unrecoverable gateway trust rotation after restart", () => { - const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-identity-drift-")); - const localBin = path.join(home, "bin"); - const registryDir = path.join(home, ".nemoclaw"); - fs.mkdirSync(localBin, { recursive: true }); - fs.mkdirSync(registryDir, { recursive: true }); - fs.writeFileSync( - path.join(registryDir, "sandboxes.json"), - JSON.stringify({ - sandboxes: { - alpha: { - name: "alpha", - model: "test-model", - provider: "nvidia-prod", - gpuEnabled: false, - policies: [], - }, + const statusResult = runWithEnv( + "alpha status", + { + HOME: home, + PATH: `${localBin}:${process.env.PATH || ""}`, }, - defaultSandbox: "alpha", - }), - { mode: 0o600 }, - ); - fs.writeFileSync( - path.join(localBin, "openshell"), - [ - "#!/usr/bin/env bash", - 'if [ "$1" = "sandbox" ] && [ "$2" = "get" ] && [ "$3" = "alpha" ]; then', - " echo 'Error: transport error: handshake verification failed' >&2", - " exit 1", - "fi", - 'if [ "$1" = "status" ]; then', - " echo 'Server Status'", - " echo", - " echo ' Gateway: nemoclaw'", - " echo ' Status: Connected'", - " exit 0", - "fi", - 'if [ "$1" = "gateway" ] && [ "$2" = "info" ] && [ "$3" = "-g" ] && [ "$4" = "nemoclaw" ]; then', - " echo 'Gateway Info'", - " echo", - " echo ' Gateway: nemoclaw'", - " exit 0", - "fi", - "exit 0", - ].join("\n"), - { mode: 0o755 }, - ); - - const statusResult = runWithEnv( - "alpha status", - { + Number(process.env.NEMOCLAW_EXEC_TIMEOUT || 10000), + ); + expect(statusResult.code).toBe(0); + expect( + statusResult.out.includes("gateway is still refusing connections after restart"), + ).toBeTruthy(); + expect( + statusResult.out.includes("Retry `openshell gateway start --name nemoclaw`"), + ).toBeTruthy(); + + const connectResult = runWithEnv("alpha connect", { HOME: home, PATH: `${localBin}:${process.env.PATH || ""}`, - }, - Number(process.env.NEMOCLAW_EXEC_TIMEOUT || 10000), - ); - expect(statusResult.code).toBe(0); - expect(statusResult.out.includes("gateway trust material rotated after restart")).toBeTruthy(); - expect(statusResult.out.includes("cannot be reattached safely")).toBeTruthy(); - - const connectResult = runWithEnv("alpha connect", { - HOME: home, - PATH: `${localBin}:${process.env.PATH || ""}`, - }); - expect(connectResult.code).toBe(1); - // After the auto-recovery attempt (clear stale host keys + retry), the - // fake openshell still returns the handshake error, so recovery fails. - expect(connectResult.out.includes("Could not reconnect")).toBeTruthy(); - expect(connectResult.out.includes("Recreate this sandbox")).toBeTruthy(); - }); + }); + expect(connectResult.code).toBe(1); + expect( + connectResult.out.includes("gateway is still refusing connections after restart"), + ).toBeTruthy(); + expect(connectResult.out.includes("If the gateway never becomes healthy")).toBeTruthy(); + }, + Number(process.env.NEMOCLAW_TEST_TIMEOUT || 10000), + ); - it("explains when gateway metadata exists but the restarted API is still refusing connections", () => { - const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-gateway-unreachable-")); - const localBin = path.join(home, "bin"); - const registryDir = path.join(home, ".nemoclaw"); - fs.mkdirSync(localBin, { recursive: true }); - fs.mkdirSync(registryDir, { recursive: true }); - fs.writeFileSync( - path.join(registryDir, "sandboxes.json"), - JSON.stringify({ - sandboxes: { - alpha: { - name: "alpha", - model: "test-model", - provider: "nvidia-prod", - gpuEnabled: false, - policies: [], + it( + "explains when the named gateway is no longer configured after restart or rebuild", + () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-gateway-missing-")); + const localBin = path.join(home, "bin"); + const registryDir = path.join(home, ".nemoclaw"); + fs.mkdirSync(localBin, { recursive: true }); + fs.mkdirSync(registryDir, { recursive: true }); + fs.writeFileSync( + path.join(registryDir, "sandboxes.json"), + JSON.stringify({ + sandboxes: { + alpha: { + name: "alpha", + model: "test-model", + provider: "nvidia-prod", + gpuEnabled: false, + policies: [], + }, }, - }, - defaultSandbox: "alpha", - }), - { mode: 0o600 }, - ); - fs.writeFileSync( - path.join(localBin, "openshell"), - [ - "#!/usr/bin/env bash", - 'if [ "$1" = "sandbox" ] && [ "$2" = "get" ] && [ "$3" = "alpha" ]; then', - " echo 'Error: transport error: Connection refused' >&2", - " exit 1", - "fi", - 'if [ "$1" = "status" ]; then', - " echo 'Server Status'", - " echo", - " echo ' Gateway: nemoclaw'", - " echo ' Server: https://127.0.0.1:8080'", - " echo 'Error: client error (Connect)' >&2", - " echo 'Connection refused (os error 111)' >&2", - " exit 1", - "fi", - 'if [ "$1" = "gateway" ] && [ "$2" = "info" ] && [ "$3" = "-g" ] && [ "$4" = "nemoclaw" ]; then', - " echo 'Gateway Info'", - " echo", - " echo ' Gateway: nemoclaw'", - " exit 0", - "fi", - 'if [ "$1" = "gateway" ] && [ "$2" = "select" ] && [ "$3" = "nemoclaw" ]; then', - " exit 0", - "fi", - 'if [ "$1" = "gateway" ] && [ "$2" = "start" ] && [ "$3" = "--name" ] && [ "$4" = "nemoclaw" ]; then', - " exit 0", - "fi", - "exit 0", - ].join("\n"), - { mode: 0o755 }, - ); - - const statusResult = runWithEnv( - "alpha status", - { - HOME: home, - PATH: `${localBin}:${process.env.PATH || ""}`, - }, - Number(process.env.NEMOCLAW_EXEC_TIMEOUT || 10000), - ); - expect(statusResult.code).toBe(0); - expect( - statusResult.out.includes("gateway is still refusing connections after restart"), - ).toBeTruthy(); - expect( - statusResult.out.includes("Retry `openshell gateway start --name nemoclaw`"), - ).toBeTruthy(); - - const connectResult = runWithEnv("alpha connect", { - HOME: home, - PATH: `${localBin}:${process.env.PATH || ""}`, - }); - expect(connectResult.code).toBe(1); - expect( - connectResult.out.includes("gateway is still refusing connections after restart"), - ).toBeTruthy(); - expect(connectResult.out.includes("If the gateway never becomes healthy")).toBeTruthy(); - }, Number(process.env.NEMOCLAW_TEST_TIMEOUT || 10000)); + defaultSandbox: "alpha", + }), + { mode: 0o600 }, + ); + fs.writeFileSync( + path.join(localBin, "openshell"), + [ + "#!/usr/bin/env bash", + 'if [ "$1" = "sandbox" ] && [ "$2" = "get" ] && [ "$3" = "alpha" ]; then', + " echo 'Error: transport error: Connection refused' >&2", + " exit 1", + "fi", + 'if [ "$1" = "status" ]; then', + " echo 'Gateway Status'", + " echo", + " echo ' Status: No gateway configured.'", + " exit 0", + "fi", + 'if [ "$1" = "gateway" ] && [ "$2" = "info" ] && [ "$3" = "-g" ] && [ "$4" = "nemoclaw" ]; then', + " exit 1", + "fi", + 'if [ "$1" = "gateway" ] && [ "$2" = "select" ] && [ "$3" = "nemoclaw" ]; then', + " exit 0", + "fi", + 'if [ "$1" = "gateway" ] && [ "$2" = "start" ] && [ "$3" = "--name" ] && [ "$4" = "nemoclaw" ]; then', + " exit 1", + "fi", + "exit 0", + ].join("\n"), + { mode: 0o755 }, + ); - it("explains when the named gateway is no longer configured after restart or rebuild", () => { - const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-gateway-missing-")); - const localBin = path.join(home, "bin"); - const registryDir = path.join(home, ".nemoclaw"); - fs.mkdirSync(localBin, { recursive: true }); - fs.mkdirSync(registryDir, { recursive: true }); - fs.writeFileSync( - path.join(registryDir, "sandboxes.json"), - JSON.stringify({ - sandboxes: { - alpha: { - name: "alpha", - model: "test-model", - provider: "nvidia-prod", - gpuEnabled: false, - policies: [], - }, + const statusResult = runWithEnv( + "alpha status", + { + HOME: home, + PATH: `${localBin}:${process.env.PATH || ""}`, }, - defaultSandbox: "alpha", - }), - { mode: 0o600 }, - ); - fs.writeFileSync( - path.join(localBin, "openshell"), - [ - "#!/usr/bin/env bash", - 'if [ "$1" = "sandbox" ] && [ "$2" = "get" ] && [ "$3" = "alpha" ]; then', - " echo 'Error: transport error: Connection refused' >&2", - " exit 1", - "fi", - 'if [ "$1" = "status" ]; then', - " echo 'Gateway Status'", - " echo", - " echo ' Status: No gateway configured.'", - " exit 0", - "fi", - 'if [ "$1" = "gateway" ] && [ "$2" = "info" ] && [ "$3" = "-g" ] && [ "$4" = "nemoclaw" ]; then', - " exit 1", - "fi", - 'if [ "$1" = "gateway" ] && [ "$2" = "select" ] && [ "$3" = "nemoclaw" ]; then', - " exit 0", - "fi", - 'if [ "$1" = "gateway" ] && [ "$2" = "start" ] && [ "$3" = "--name" ] && [ "$4" = "nemoclaw" ]; then', - " exit 1", - "fi", - "exit 0", - ].join("\n"), - { mode: 0o755 }, - ); - - const statusResult = runWithEnv( - "alpha status", - { - HOME: home, - PATH: `${localBin}:${process.env.PATH || ""}`, - }, - Number(process.env.NEMOCLAW_EXEC_TIMEOUT || 10000), - ); - expect(statusResult.code).toBe(0); - expect( - statusResult.out.includes("gateway is no longer configured after restart/rebuild"), - ).toBeTruthy(); - expect(statusResult.out.includes("Start the gateway again")).toBeTruthy(); - }, Number(process.env.NEMOCLAW_TEST_TIMEOUT || 10000)); + Number(process.env.NEMOCLAW_EXEC_TIMEOUT || 10000), + ); + expect(statusResult.code).toBe(0); + expect( + statusResult.out.includes("gateway is no longer configured after restart/rebuild"), + ).toBeTruthy(); + expect(statusResult.out.includes("Start the gateway again")).toBeTruthy(); + }, + Number(process.env.NEMOCLAW_TEST_TIMEOUT || 10000), + ); }); describe("list shows live gateway inference", () => { diff --git a/test/config-set.test.ts b/test/config-set.test.ts index 33e27b9f9c8..14e6e8817aa 100644 --- a/test/config-set.test.ts +++ b/test/config-set.test.ts @@ -14,6 +14,11 @@ const { resolveAgentConfig, } = require("../dist/lib/sandbox-config"); +type MutableScalar = string | number | boolean | null | undefined; +type MutableValue = MutableScalar | MutableMap | MutableValue[]; +type MutableMap = { [key: string]: MutableValue }; +type NestedConfig = { a?: { b?: { c?: number } } }; + describe("resolveAgentConfig", () => { it("returns openclaw defaults for unknown sandbox", () => { const target = resolveAgentConfig("nonexistent-sandbox"); @@ -58,31 +63,31 @@ describe("config set helpers", () => { describe("setDotpath", () => { it("sets a top-level key", () => { - const obj: Record = { foo: "old" }; + const obj: MutableMap = { foo: "old" }; setDotpath(obj, "foo", "new"); expect(obj.foo).toBe("new"); }); it("sets a nested key", () => { - const obj: Record = { a: { b: { c: 1 } } }; + const obj: NestedConfig = { a: { b: { c: 1 } } }; setDotpath(obj, "a.b.c", 99); - expect((obj.a as Record).b).toEqual({ c: 99 }); + expect(obj.a?.b).toEqual({ c: 99 }); }); it("creates intermediate objects if missing", () => { - const obj: Record = {}; + const obj: MutableMap = {}; setDotpath(obj, "a.b.c", "deep"); expect(obj).toEqual({ a: { b: { c: "deep" } } }); }); it("overwrites non-object intermediate with empty object", () => { - const obj: Record = { a: "string" }; + const obj: MutableMap = { a: "string" }; setDotpath(obj, "a.b", "val"); expect(obj).toEqual({ a: { b: "val" } }); }); it("adds a new key to existing object", () => { - const obj: Record = { a: { existing: true } }; + const obj: MutableMap = { a: { existing: true } }; setDotpath(obj, "a.newKey", "added"); expect(obj.a).toEqual({ existing: true, newKey: "added" }); }); @@ -103,7 +108,9 @@ describe("config set helpers", () => { }); it("accepts existing keys whose value is null", () => { - expect(isRecognizedConfigPath({ provider: { endpoint: null } }, "provider.endpoint")).toBe(true); + expect(isRecognizedConfigPath({ provider: { endpoint: null } }, "provider.endpoint")).toBe( + true, + ); }); it("rejects an unknown top-level key", () => { diff --git a/test/credential-exposure.test.ts b/test/credential-exposure.test.ts index 9c4c5938257..81de834af7b 100644 --- a/test/credential-exposure.test.ts +++ b/test/credential-exposure.test.ts @@ -1,4 +1,3 @@ -// @ts-nocheck // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 // diff --git a/test/credential-rotation.test.ts b/test/credential-rotation.test.ts index c61c1851d9e..3fee9d5cc63 100644 --- a/test/credential-rotation.test.ts +++ b/test/credential-rotation.test.ts @@ -1,4 +1,3 @@ -// @ts-nocheck // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 @@ -7,17 +6,77 @@ import { createRequire } from "node:module"; const require = createRequire(import.meta.url); +type ModuleProperty = string | number | boolean | Function | object | null | undefined; +type ModuleRecord = { [key: string]: ModuleProperty }; + +type MessagingProvider = { + name: string; + envKey: string; + token: string | null; +}; + +type CredentialRotationInternals = { + hashCredential: (value: string | null | undefined) => string | null; + detectMessagingCredentialRotation: ( + sandboxName: string, + providers: MessagingProvider[], + ) => { changed: boolean; changedProviders: string[] }; +}; + +function isRecord(value: object | null): value is ModuleRecord { + return value !== null && !Array.isArray(value); +} + +function isCredentialRotationInternals(value: object | null): value is CredentialRotationInternals { + return ( + isRecord(value) && + typeof value.hashCredential === "function" && + typeof value.detectMessagingCredentialRotation === "function" + ); +} + +function isRegistryModule(value: object | null): value is typeof import("../dist/lib/registry.js") { + return isRecord(value) && typeof value.getSandbox === "function"; +} + +function loadCredentialRotationInternals(): CredentialRotationInternals { + const loaded = require("../dist/lib/onboard.js"); + const record = typeof loaded === "object" && loaded !== null ? loaded : null; + if (!isCredentialRotationInternals(record)) { + throw new Error("Expected onboard internals to expose credential rotation helpers"); + } + return record; +} + +function loadRegistryModule(): typeof import("../dist/lib/registry.js") { + const loaded = require("../dist/lib/registry.js"); + const record = typeof loaded === "object" && loaded !== null ? loaded : null; + if (!isRegistryModule(record)) { + throw new Error("Expected registry module to expose getSandbox"); + } + return record; +} + describe("credential rotation detection", () => { - let hashCredential; - let detectMessagingCredentialRotation; - let registry; + let hashCredential: CredentialRotationInternals["hashCredential"]; + let detectMessagingCredentialRotation: CredentialRotationInternals["detectMessagingCredentialRotation"]; + let registry: typeof import("../dist/lib/registry.js"); beforeEach(() => { // Fresh imports to avoid cross-test contamination - ({ hashCredential, detectMessagingCredentialRotation } = require("../dist/lib/onboard.js")); - registry = require("../dist/lib/registry.js"); + ({ hashCredential, detectMessagingCredentialRotation } = loadCredentialRotationInternals()); + registry = loadRegistryModule(); }); + function hashCredentialOrThrow(value: string): string { + const hash = hashCredential(value); + expect(hash).not.toBeNull(); + if (!hash) { + throw new Error(`Expected hashCredential(${JSON.stringify(value)}) to return a hash`); + } + return hash; + } + describe("hashCredential", () => { it("returns null for falsy values", () => { expect(hashCredential(null)).toBeNull(); @@ -66,7 +125,7 @@ describe("credential rotation detection", () => { }); it("returns changed: false when hashes match", () => { - const tokenHash = hashCredential("same-token"); + const tokenHash = hashCredentialOrThrow("same-token"); vi.spyOn(registry, "getSandbox").mockReturnValue({ name: "test-sandbox", providerCredentialHashes: { TELEGRAM_BOT_TOKEN: tokenHash }, @@ -82,7 +141,7 @@ describe("credential rotation detection", () => { }); it("returns changed: true with correct provider names when hashes differ", () => { - const oldHash = hashCredential("old-token"); + const oldHash = hashCredentialOrThrow("old-token"); vi.spyOn(registry, "getSandbox").mockReturnValue({ name: "test-sandbox", providerCredentialHashes: { TELEGRAM_BOT_TOKEN: oldHash }, @@ -98,8 +157,8 @@ describe("credential rotation detection", () => { }); it("detects rotation across multiple providers", () => { - const telegramHash = hashCredential("tg-old"); - const discordHash = hashCredential("dc-same"); + const telegramHash = hashCredentialOrThrow("tg-old"); + const discordHash = hashCredentialOrThrow("dc-same"); vi.spyOn(registry, "getSandbox").mockReturnValue({ name: "test-sandbox", providerCredentialHashes: { @@ -119,7 +178,7 @@ describe("credential rotation detection", () => { }); it("skips providers with null tokens", () => { - const hash = hashCredential("old-token"); + const hash = hashCredentialOrThrow("old-token"); vi.spyOn(registry, "getSandbox").mockReturnValue({ name: "test-sandbox", providerCredentialHashes: { TELEGRAM_BOT_TOKEN: hash }, diff --git a/test/credentials.test.ts b/test/credentials.test.ts index d31c9956ac8..a9d3e875e68 100644 --- a/test/credentials.test.ts +++ b/test/credentials.test.ts @@ -1,4 +1,3 @@ -// @ts-nocheck // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 @@ -8,16 +7,30 @@ import path from "node:path"; import { spawnSync } from "node:child_process"; import { afterEach, describe, expect, it, vi } from "vitest"; -async function importCredentialsModule(home) { +type CredentialsModule = typeof import("../dist/lib/credentials.js"); + +function isCredentialsModule(value: object | null): value is CredentialsModule { + return ( + value !== null && + typeof Reflect.get(value, "loadCredentials") === "function" && + typeof Reflect.get(value, "getCredential") === "function" && + typeof Reflect.get(value, "saveCredential") === "function" + ); +} + +async function importCredentialsModule(home: string): Promise { vi.resetModules(); vi.doUnmock("fs"); vi.doUnmock("child_process"); vi.doUnmock("readline"); vi.stubEnv("HOME", home); const module = await import("../dist/lib/credentials.js"); - /** @type {any} */ - const resolved = module.default ?? module; - return resolved; + const loaded = "default" in module ? module.default : module; + const moduleObject = typeof loaded === "object" && loaded !== null ? loaded : null; + if (!isCredentialsModule(moduleObject)) { + throw new Error("Expected credentials module exports to be available"); + } + return moduleObject; } afterEach(() => { diff --git a/test/dns-proxy.test.ts b/test/dns-proxy.test.ts index 3d44f282078..807ff3b8489 100644 --- a/test/dns-proxy.test.ts +++ b/test/dns-proxy.test.ts @@ -1,4 +1,3 @@ -// @ts-nocheck // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 @@ -20,7 +19,7 @@ describe("setup-dns-proxy.sh", () => { it("sources runtime.sh successfully", () => { const result = spawnSync("bash", ["-c", `source "${RUNTIME_SH}"; echo ok`], { - encoding: /** @type {const} */ ("utf-8"), + encoding: /** @type {const} */ "utf-8", env: { ...process.env }, }); expect(result.status).toBe(0); @@ -29,7 +28,7 @@ describe("setup-dns-proxy.sh", () => { it("exits with usage when no sandbox name provided", () => { const result = spawnSync("bash", [SETUP_DNS_PROXY, "nemoclaw"], { - encoding: /** @type {const} */ ("utf-8"), + encoding: /** @type {const} */ "utf-8", env: { ...process.env }, }); expect(result.status).not.toBe(0); diff --git a/test/e2e/brev-e2e.test.ts b/test/e2e/brev-e2e.test.ts index cec1c70cf4e..087ffca26fc 100644 --- a/test/e2e/brev-e2e.test.ts +++ b/test/e2e/brev-e2e.test.ts @@ -1,4 +1,3 @@ -// @ts-nocheck // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 @@ -39,7 +38,7 @@ */ import { describe, it, expect, beforeAll, afterAll } from "vitest"; -import { execSync, execFileSync } from "node:child_process"; +import { execSync, execFileSync, type StdioOptions } from "node:child_process"; import path from "node:path"; // Instance configuration @@ -52,6 +51,13 @@ const TEST_SUITE = process.env.TEST_SUITE || "full"; const REPO_DIR = path.resolve(import.meta.dirname, "../.."); const CLI_PATH = path.join(REPO_DIR, "bin", "nemoclaw.js"); +function requireInstanceName(): string { + if (!INSTANCE_NAME) { + throw new Error("INSTANCE_NAME is required for Brev E2E tests"); + } + return INSTANCE_NAME; +} + // Launchable configuration // CI-Ready CPU setup script: pre-bakes Docker, Node.js, OpenShell CLI, npm deps, Docker images. // The Brev CLI (v0.6.322+) uses `brev search cpu | brev create --startup-script @file`. @@ -64,12 +70,16 @@ const DEFAULT_SETUP_SCRIPT_PATH = // More reliable than grepping log files. const LAUNCHABLE_SENTINEL = "/var/run/nemoclaw-launchable-ready"; -let remoteDir; +let remoteDir = ""; let instanceCreated = false; +const STREAM_STDIO: StdioOptions = ["inherit", "inherit", "inherit"]; +const CAPTURE_STDIO: StdioOptions = ["pipe", "pipe", "pipe"]; +const PIPE_INPUT_STDIO: StdioOptions = ["pipe", "inherit", "inherit"]; + // --- low-level helpers ------------------------------------------------------ -function brev(...args) { +function brev(...args: string[]): string { return execFileSync("brev", args, { encoding: "utf-8", timeout: 60_000, @@ -77,7 +87,7 @@ function brev(...args) { }).trim(); } -function listBrevInstances() { +function listBrevInstances(): Array<{ name: string; status?: string }> { try { return JSON.parse(brev("ls", "--json")); } catch { @@ -85,17 +95,19 @@ function listBrevInstances() { } } -function hasBrevInstance(instanceName) { +function hasBrevInstance(instanceName: string): boolean { return listBrevInstances().some((instance) => instance.name === instanceName); } -function isBrevInstanceDeleting(instanceName) { +function isBrevInstanceDeleting(instanceName: string): boolean { const instances = listBrevInstances(); - const instance = instances.find((i) => i.name === instanceName); - return instance && (instance.status === "DELETING" || instance.status === "STOPPING"); + const instance = instances.find( + (i: { name: string; status?: string }) => i.name === instanceName, + ); + return Boolean(instance && (instance.status === "DELETING" || instance.status === "STOPPING")); } -function deleteBrevInstance(instanceName) { +function deleteBrevInstance(instanceName: string): boolean { if (!hasBrevInstance(instanceName)) { return true; } @@ -115,10 +127,12 @@ function deleteBrevInstance(instanceName) { return false; } -function ssh(cmd, { timeout = 120_000, stream = false } = {}) { +function ssh( + cmd: string, + { timeout = 120_000, stream = false }: { timeout?: number; stream?: boolean } = {}, +): string { const escaped = cmd.replace(/'/g, "'\\''"); - /** @type {import("child_process").StdioOptions} */ - const stdio = stream ? ["inherit", "inherit", "inherit"] : ["pipe", "pipe", "pipe"]; + const stdio = stream ? STREAM_STDIO : CAPTURE_STDIO; const result = execSync( `ssh -o StrictHostKeyChecking=no -o LogLevel=ERROR "${INSTANCE_NAME}" '${escaped}'`, { encoding: "utf-8", timeout, stdio }, @@ -130,12 +144,15 @@ function ssh(cmd, { timeout = 120_000, stream = false } = {}) { * Escape a value for safe inclusion in a single-quoted shell string. * Replaces single quotes with the shell-safe sequence: '\'' */ -function shellEscape(value) { +function shellEscape(value: string | null | undefined): string { return String(value).replace(/'/g, "'\\''"); } /** Run a command on the remote VM with env vars set for NemoClaw. */ -function sshEnv(cmd, { timeout = 600_000, stream = false } = {}) { +function sshEnv( + cmd: string, + { timeout = 600_000, stream = false }: { timeout?: number; stream?: boolean } = {}, +): string { const envParts = [ `export NVIDIA_API_KEY='${shellEscape(process.env.NVIDIA_API_KEY)}'`, `export GITHUB_TOKEN='${shellEscape(process.env.GITHUB_TOKEN)}'`, @@ -164,13 +181,16 @@ function sshEnv(cmd, { timeout = 600_000, stream = false } = {}) { return ssh(`${envPrefix} && ${cmd}`, { timeout, stream }); } -function waitForSsh(maxAttempts = 40, intervalMs = 5_000) { +function waitForSsh(maxAttempts = 40, intervalMs = 5_000): void { for (let i = 1; i <= maxAttempts; i++) { try { ssh("echo ok", { timeout: 10_000 }); return; } catch { - if (i === maxAttempts) throw new Error(`SSH not ready after ${maxAttempts} attempts (~${Math.round(maxAttempts * (intervalMs + 10_000) / 60_000)} min)`); + if (i === maxAttempts) + throw new Error( + `SSH not ready after ${maxAttempts} attempts (~${Math.round((maxAttempts * (intervalMs + 10_000)) / 60_000)} min)`, + ); console.log(` SSH attempt ${i}/${maxAttempts} failed, retrying in ${intervalMs / 1000}s...`); if (i % 5 === 0) { console.log(` Refreshing brev SSH config...`); @@ -189,7 +209,7 @@ function waitForSsh(maxAttempts = 40, intervalMs = 5_000) { * Wait for the launchable setup script to finish by checking a sentinel file. * Much more reliable than grepping log files. */ -function waitForLaunchableReady(maxWaitMs = 1_200_000, pollIntervalMs = 15_000) { +function waitForLaunchableReady(maxWaitMs = 1_200_000, pollIntervalMs = 15_000): void { const start = Date.now(); const elapsed = () => `${Math.round((Date.now() - start) / 1000)}s`; let consecutiveSshFailures = 0; @@ -241,7 +261,7 @@ function waitForLaunchableReady(maxWaitMs = 1_200_000, pollIntervalMs = 15_000) ); } -function runRemoteTest(scriptPath) { +function runRemoteTest(scriptPath: string): string { const cmd = [ `set -o pipefail`, `source ~/.nvm/nvm.sh 2>/dev/null || true`, @@ -259,7 +279,7 @@ function runRemoteTest(scriptPath) { return ssh("cat /tmp/test-output.log", { timeout: 30_000 }); } -function runLocalDeploy(instanceName) { +function runLocalDeploy(instanceName: string): void { const env = { ...process.env, NEMOCLAW_NON_INTERACTIVE: "1", @@ -285,12 +305,13 @@ function runLocalDeploy(instanceName) { * but the CLI got a network error (unexpected EOF) before confirming, * then the retry/fallback fails with "duplicate workspace". */ -function cleanupLeftoverInstance(elapsed) { - if (hasBrevInstance(INSTANCE_NAME)) { - if (!deleteBrevInstance(INSTANCE_NAME)) { - throw new Error(`Failed to delete leftover instance "${INSTANCE_NAME}"`); +function cleanupLeftoverInstance(elapsed: () => string): void { + const instanceName = requireInstanceName(); + if (hasBrevInstance(instanceName)) { + if (!deleteBrevInstance(instanceName)) { + throw new Error(`Failed to delete leftover instance "${instanceName}"`); } - console.log(`[${elapsed()}] Deleted leftover instance "${INSTANCE_NAME}"`); + console.log(`[${elapsed()}] Deleted leftover instance "${instanceName}"`); } } @@ -298,7 +319,7 @@ function cleanupLeftoverInstance(elapsed) { * Refresh brev SSH config and wait for SSH connectivity. * Shared by both the deploy-cli and launchable paths. */ -function refreshAndWaitForSsh(elapsed) { +function refreshAndWaitForSsh(elapsed: () => string): void { try { brev("refresh"); } catch { @@ -316,7 +337,7 @@ function refreshAndWaitForSsh(elapsed) { * fails with "duplicate workspace". To handle this, we catch create failures and * check if the instance exists anyway. */ -function createBrevInstance(elapsed) { +function createBrevInstance(elapsed: () => string): void { console.log( `[${elapsed()}] Creating instance via launchable (brev search cpu | brev create + startup-script)...`, ); @@ -328,7 +349,7 @@ function createBrevInstance(elapsed) { // Resolve the setup script to a local file path. // Default: repo-local scripts/brev-launchable-ci-cpu.sh (hermetic). // Override: set LAUNCHABLE_SETUP_SCRIPT to a URL and it gets downloaded. - let setupScriptPath; + let setupScriptPath: string; if (DEFAULT_SETUP_SCRIPT_PATH.startsWith("http")) { setupScriptPath = "/tmp/brev-ci-setup.sh"; execSync(`curl -fsSL -o ${setupScriptPath} "${DEFAULT_SETUP_SCRIPT_PATH}"`, { @@ -345,7 +366,7 @@ function createBrevInstance(elapsed) { execSync( `brev search cpu --min-vcpu ${BREV_MIN_VCPU} --min-ram ${BREV_MIN_RAM} --min-disk ${BREV_MIN_DISK} --provider ${BREV_PROVIDER} --sort price | ` + `brev create ${INSTANCE_NAME} --startup-script @${setupScriptPath} --detached`, - { encoding: "utf-8", timeout: 180_000, stdio: ["pipe", "inherit", "inherit"] }, + { encoding: "utf-8", timeout: 180_000, stdio: PIPE_INPUT_STDIO }, ); } catch (createErr) { console.log( @@ -357,10 +378,12 @@ function createBrevInstance(elapsed) { /* ignore */ } const lsOutput = execSync(`brev ls 2>&1 || true`, { encoding: "utf-8", timeout: 30_000 }); - if (!lsOutput.includes(INSTANCE_NAME)) { + const instanceName = requireInstanceName(); + if (!lsOutput.includes(instanceName)) { + const createMessage = createErr instanceof Error ? createErr.message : String(createErr); throw new Error( - `brev create failed and instance "${INSTANCE_NAME}" not found in brev ls. ` + - `Original error: ${createErr.message}`, + `brev create failed and instance "${instanceName}" not found in brev ls. ` + + `Original error: ${createMessage}`, { cause: createErr }, ); } @@ -378,7 +401,7 @@ function createBrevInstance(elapsed) { * Returns { remoteDir, needsOnboard } so the caller can see what was * resolved without relying on hidden side-effects. */ -function bootstrapLaunchable(elapsed) { +function bootstrapLaunchable(elapsed: () => string): { remoteDir: string; needsOnboard: boolean } { // The launchable clones NemoClaw to ~/NemoClaw const remoteHome = ssh("echo $HOME"); const resolvedRemoteDir = `${remoteHome}/NemoClaw`; @@ -427,13 +450,10 @@ function bootstrapLaunchable(elapsed) { // --ignore-scripts` skipped the `prepare` lifecycle that normally runs // `build:cli`, so do it explicitly. console.log(`[${elapsed()}] Building CLI (dist/) for PR branch...`); - ssh( - `source ~/.nvm/nvm.sh 2>/dev/null || true && cd ${resolvedRemoteDir} && npm run build:cli`, - { - timeout: 120_000, - stream: true, - }, - ); + ssh(`source ~/.nvm/nvm.sh 2>/dev/null || true && cd ${resolvedRemoteDir} && npm run build:cli`, { + timeout: 120_000, + stream: true, + }); console.log(`[${elapsed()}] CLI built`); // Rebuild TS plugin for our branch (reinstall plugin deps in case they changed) @@ -475,7 +495,7 @@ function bootstrapLaunchable(elapsed) { * background, poll for sandbox readiness via `openshell sandbox list`, then * hand off to writeManualRegistry() to kill the hung process. */ -function pollForSandboxReady(elapsed) { +function pollForSandboxReady(elapsed: () => string): void { // Launch onboard fully detached. We chmod the docker socket so we don't // need sg docker (which complicates backgrounding). nohup + /dev/null || echo '(no log yet)'", - { - timeout: 10_000, - }, - ); - console.log( - `[${onboardElapsed()}] Onboard in progress... ${tail.replace(/\n/g, " | ")}`, - ); + const tail = ssh("tail -2 /tmp/nemoclaw-onboard.log 2>/dev/null || echo '(no log yet)'", { + timeout: 10_000, + }); + console.log(`[${onboardElapsed()}] Onboard in progress... ${tail.replace(/\n/g, " | ")}`); } catch { /* ignore */ } @@ -564,7 +579,7 @@ function pollForSandboxReady(elapsed) { throw new Error(`Onboard failed: ${parsed.failure || "unknown"}\n${failLog}`); } } catch (e) { - if (e.message.startsWith("Onboard failed")) throw e; + if (e instanceof Error && e.message.startsWith("Onboard failed")) throw e; /* ignore parse errors */ } @@ -590,7 +605,7 @@ function pollForSandboxReady(elapsed) { * Note: The registry shape matches SandboxRegistry from src/lib/registry.ts * (sandboxes + defaultSandbox only — no version field). */ -function writeManualRegistry(elapsed) { +function writeManualRegistry(elapsed: () => string): void { console.log(`[${elapsed()}] Sandbox ready — killing hung onboard and writing registry...`); // Kill hung onboard processes. pkill may kill the SSH connection itself // if the pattern matches too broadly, so wrap in try/catch. @@ -654,7 +669,7 @@ describe.runIf(hasRequiredVars && hasAuthenticatedBrev)("Brev E2E", () => { if (TEST_SUITE === "deploy-cli") { console.log(`[${elapsed()}] Running nemoclaw deploy end to end...`); instanceCreated = true; - runLocalDeploy(INSTANCE_NAME); + runLocalDeploy(requireInstanceName()); refreshAndWaitForSsh(elapsed); const remoteHome = ssh("echo $HOME"); remoteDir = `${remoteHome}/nemoclaw`; @@ -707,7 +722,7 @@ describe.runIf(hasRequiredVars && hasAuthenticatedBrev)("Brev E2E", () => { console.log(` To delete: brev delete ${INSTANCE_NAME}\n`); return; } - deleteBrevInstance(INSTANCE_NAME); + deleteBrevInstance(requireInstanceName()); }, 120_000); // 2 min for cleanup // NOTE: The full E2E test runs install.sh --non-interactive which destroys and diff --git a/test/exec-approvals-path-regression.test.ts b/test/exec-approvals-path-regression.test.ts index 516c0c28aca..09064ca61fd 100644 --- a/test/exec-approvals-path-regression.test.ts +++ b/test/exec-approvals-path-regression.test.ts @@ -1,4 +1,3 @@ -// @ts-nocheck // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 @@ -11,8 +10,8 @@ describe("exec approvals path regression guard", () => { const dockerfileBase = path.join(import.meta.dirname, "..", "Dockerfile.base"); const src = fs.readFileSync(dockerfileBase, "utf-8"); - expect(src).toContain('LEGACY_EXEC_APPROVALS_PATH="$(printf \'%b\''); - expect(src).toContain('DATA_EXEC_APPROVALS_PATH="$(printf \'%b\''); + expect(src).toContain("LEGACY_EXEC_APPROVALS_PATH=\"$(printf '%b'"); + expect(src).toContain("DATA_EXEC_APPROVALS_PATH=\"$(printf '%b'"); expect(src).toContain('files_with_old_path_file="$(mktemp)"'); expect(src).toContain("--include='*.js'"); expect(src).toContain("OpenClaw dist directory not found:"); @@ -27,8 +26,8 @@ describe("exec approvals path regression guard", () => { expect(src).toContain("mkdir -p /sandbox/.openclaw-data"); expect(src).toContain("chown sandbox:sandbox /sandbox/.openclaw-data"); expect(src).toContain("chmod 755 /sandbox/.openclaw-data"); - expect(src).toContain('LEGACY_EXEC_APPROVALS_PATH="$(printf \'%b\''); - expect(src).toContain('DATA_EXEC_APPROVALS_PATH="$(printf \'%b\''); + expect(src).toContain("LEGACY_EXEC_APPROVALS_PATH=\"$(printf '%b'"); + expect(src).toContain("DATA_EXEC_APPROVALS_PATH=\"$(printf '%b'"); expect(src).toContain('files_with_old_path_file="$(mktemp)"'); expect(src).toContain("--include='*.js'"); expect(src).toContain("Unable to verify OpenClaw exec approvals path in dist"); diff --git a/test/gateway-cleanup.test.ts b/test/gateway-cleanup.test.ts index f5d81d7b13f..cf29135ad17 100644 --- a/test/gateway-cleanup.test.ts +++ b/test/gateway-cleanup.test.ts @@ -1,4 +1,3 @@ -// @ts-nocheck // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 // @@ -24,6 +23,9 @@ describe("gateway cleanup: Docker volumes removed on failure (#17)", () => { const content = fs.readFileSync(path.join(ROOT, "src/lib/onboard.ts"), "utf-8"); const startGwBlock = content.match(/async function startGatewayWithOptions[\s\S]*?^}/m); expect(startGwBlock).toBeTruthy(); + if (!startGwBlock) { + throw new Error("Expected startGatewayWithOptions() in src/lib/onboard.ts"); + } // Current behavior: // 1. stale gateway is detected but NOT destroyed upfront — gateway start diff --git a/test/gateway-liveness-probe.test.ts b/test/gateway-liveness-probe.test.ts index 5ae95ed5c22..390c04f6fdb 100644 --- a/test/gateway-liveness-probe.test.ts +++ b/test/gateway-liveness-probe.test.ts @@ -1,4 +1,3 @@ -// @ts-nocheck // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 // @@ -56,6 +55,9 @@ describe("gateway liveness probe (#2020)", () => { // Both probe sites must check containerState === "missing" before cleanup const downgrades = content.match(/containerState === "missing"/g); expect(downgrades).toBeTruthy(); + if (!downgrades) { + throw new Error('Expected containerState === "missing" checks in src/lib/onboard.ts'); + } expect(downgrades.length).toBeGreaterThanOrEqual(2); }); @@ -71,14 +73,14 @@ describe("gateway liveness probe (#2020)", () => { it("does not modify isGatewayHealthy() in gateway-state.ts", () => { // isGatewayHealthy() must remain a pure function — no I/O. // Scope the check to the function body so unrelated helpers don't cause false failures. - const gsContent = fs.readFileSync( - path.join(ROOT, "src/lib/gateway-state.ts"), - "utf-8", - ); + const gsContent = fs.readFileSync(path.join(ROOT, "src/lib/gateway-state.ts"), "utf-8"); const fnMatch = gsContent.match( /(?:function isGatewayHealthy|const isGatewayHealthy\b)[\s\S]*?\n\}/, ); expect(fnMatch).toBeTruthy(); + if (!fnMatch) { + throw new Error("Expected isGatewayHealthy() in src/lib/gateway-state.ts"); + } const fnBody = fnMatch[0]; expect(fnBody).not.toContain("docker"); expect(fnBody).not.toContain("spawn"); diff --git a/test/gateway-state-reconcile-2276.test.ts b/test/gateway-state-reconcile-2276.test.ts index e627d4172f0..97ad14a078a 100644 --- a/test/gateway-state-reconcile-2276.test.ts +++ b/test/gateway-state-reconcile-2276.test.ts @@ -198,7 +198,7 @@ process.exit(0); fs.writeFileSync(openshellPath, stub, { mode: 0o755 }); } -function runCli(action: string, extraEnv: NodeJS.ProcessEnv = {}): HarnessResult { +function runCli(action: string, extraEnv: Record = {}): HarnessResult { const repoRoot = path.join(import.meta.dirname, ".."); const result = spawnSync( process.execPath, @@ -244,9 +244,7 @@ function runCli(action: string, extraEnv: NodeJS.ProcessEnv = {}): HarnessResult } }); - const selectCalls = callLog.filter( - (c) => c[0] === "gateway" && c[1] === "select", - ).length; + const selectCalls = callLog.filter((c) => c[0] === "gateway" && c[1] === "select").length; return { status: result.status, @@ -290,34 +288,30 @@ afterEach(() => { // ─── Scenario 1 ─── destructive path preserved for `connect` ─────────────── describe("Scenario 1: connect — healthy nemoclaw active + sandbox NotFound truly gone", () => { - it( - "removes the registry entry, clears session, and exits 1", - { timeout: TIMEOUT_MS }, - () => { - writeStubOpenshell({ - sandboxGet: [{ output: SANDBOX_GET_NOT_FOUND, exit: 1 }], - status: [{ output: STATUS_CONNECTED_NEMOCLAW, exit: 0 }], - gatewayInfo: [{ output: GATEWAY_INFO_NEMOCLAW, exit: 0 }], - gatewaySelect: { output: "", exit: 0 }, - selectFlipsActive: false, - }); - - const r = runCli("connect"); + it("removes the registry entry, clears session, and exits 1", { timeout: TIMEOUT_MS }, () => { + writeStubOpenshell({ + sandboxGet: [{ output: SANDBOX_GET_NOT_FOUND, exit: 1 }], + status: [{ output: STATUS_CONNECTED_NEMOCLAW, exit: 0 }], + gatewayInfo: [{ output: GATEWAY_INFO_NEMOCLAW, exit: 0 }], + gatewaySelect: { output: "", exit: 0 }, + selectFlipsActive: false, + }); - assert.equal(r.status, 1, `expected exit 1, got ${r.status}\n${r.stderr}`); - assert.equal( - registrySandboxPresent(r), - false, - `expected registry entry removed, got: ${JSON.stringify(r.registry)}`, - ); - assert.equal( - r.sessionSandboxName === null || r.sessionSandboxName === undefined, - true, - `expected session sandboxName cleared, got: ${r.sessionSandboxName}`, - ); - assert.match(r.stderr, /Removed stale local registry entry/); - }, - ); + const r = runCli("connect"); + + assert.equal(r.status, 1, `expected exit 1, got ${r.status}\n${r.stderr}`); + assert.equal( + registrySandboxPresent(r), + false, + `expected registry entry removed, got: ${JSON.stringify(r.registry)}`, + ); + assert.equal( + r.sessionSandboxName === null || r.sessionSandboxName === undefined, + true, + `expected session sandboxName cleared, got: ${r.sessionSandboxName}`, + ); + assert.match(r.stderr, /Removed stale local registry entry/); + }); }); // ─── Scenario 2 ─── destructive path preserved for `status` ──────────────── @@ -381,11 +375,7 @@ describe("Scenario 3: status — select succeeds, sandbox reappears, registry in true, `expected registry preserved, got: ${JSON.stringify(r.registry)}`, ); - assert.equal( - r.sessionSandboxName, - SANDBOX_NAME, - "expected session sandboxName preserved", - ); + assert.equal(r.sessionSandboxName, SANDBOX_NAME, "expected session sandboxName preserved"); // gateway select nemoclaw should have been invoked. assert.ok(r.selectCalls >= 1, `expected ≥1 gateway select calls, got ${r.selectCalls}`); }, @@ -418,11 +408,7 @@ describe("Scenario 4: connect — select fails, sandbox still NotFound", () => { true, `registry must be preserved, got: ${JSON.stringify(r.registry)}`, ); - assert.equal( - r.sessionSandboxName, - SANDBOX_NAME, - "session sandboxName must be preserved", - ); + assert.equal(r.sessionSandboxName, SANDBOX_NAME, "session sandboxName must be preserved"); // User-facing guidance. assert.match(r.stderr, /NOT been removed/); assert.match(r.stderr, /openshell gateway select nemoclaw/); @@ -694,13 +680,7 @@ describe("Scenario 12: skill install — wrong gateway active yields guidance, n const repoRoot = path.join(import.meta.dirname, ".."); const result = spawnSync( process.execPath, - [ - path.join(repoRoot, "bin", "nemoclaw.js"), - SANDBOX_NAME, - "skill", - "install", - skillDir, - ], + [path.join(repoRoot, "bin", "nemoclaw.js"), SANDBOX_NAME, "skill", "install", skillDir], { cwd: repoRoot, encoding: "utf-8", diff --git a/test/gemini-probe-auth.test.ts b/test/gemini-probe-auth.test.ts index 3ce4da9d538..9d4af6ff26b 100644 --- a/test/gemini-probe-auth.test.ts +++ b/test/gemini-probe-auth.test.ts @@ -1,10 +1,25 @@ -// @ts-nocheck // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 import { describe, it, expect } from "vitest"; -import { getProbeAuthMode } from "../dist/lib/onboard"; +type OnboardProbeInternals = { + getProbeAuthMode: (provider: string) => "query-param" | undefined; +}; + +function isOnboardProbeInternals(value: object | null): value is OnboardProbeInternals { + return value !== null && typeof Reflect.get(value, "getProbeAuthMode") === "function"; +} + +const loadedOnboardProbeInternals = require("../dist/lib/onboard"); +const onboardProbeInternals = + typeof loadedOnboardProbeInternals === "object" && loadedOnboardProbeInternals !== null + ? loadedOnboardProbeInternals + : null; +if (!isOnboardProbeInternals(onboardProbeInternals)) { + throw new Error("Expected onboard probe internals to expose getProbeAuthMode"); +} +const { getProbeAuthMode } = onboardProbeInternals; // The onboarder's Gemini validation probes target the OpenAI-compat // endpoint at https://generativelanguage.googleapis.com/v1beta/openai/. diff --git a/test/http-proxy-fix-sync.test.ts b/test/http-proxy-fix-sync.test.ts index 7ce7154f300..db2f0dea210 100644 --- a/test/http-proxy-fix-sync.test.ts +++ b/test/http-proxy-fix-sync.test.ts @@ -1,4 +1,3 @@ -// @ts-nocheck // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 @@ -34,13 +33,14 @@ describe("http-proxy-fix heredoc sync (#2109)", () => { it("embedded heredoc matches canonical file byte-for-byte", () => { const canonical = fs.readFileSync(CANONICAL_FIX, "utf-8"); const startScript = fs.readFileSync(START_SCRIPT, "utf-8"); - const match = startScript.match( - /<<'HTTP_PROXY_FIX_EOF'\n([\s\S]*?)\nHTTP_PROXY_FIX_EOF/, - ); + const match = startScript.match(/<<'HTTP_PROXY_FIX_EOF'\n([\s\S]*?)\nHTTP_PROXY_FIX_EOF/); expect(match).not.toBeNull(); + if (!match) { + throw new Error("Expected HTTP_PROXY_FIX_EOF heredoc in scripts/nemoclaw-start.sh"); + } // The heredoc capture excludes the final newline preceding the delimiter. // POSIX convention: the canonical file ends with a trailing newline. - const embedded = match[1] + "\n"; + const embedded = `${match[1]}\n`; if (embedded !== canonical) { const embeddedLines = embedded.split("\n"); const canonicalLines = canonical.split("\n"); diff --git a/test/image-cleanup.test.ts b/test/image-cleanup.test.ts index 5392a4def1f..10721a8b908 100644 --- a/test/image-cleanup.test.ts +++ b/test/image-cleanup.test.ts @@ -1,4 +1,3 @@ -// @ts-nocheck // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 // @@ -23,6 +22,9 @@ describe("image cleanup: sandbox destroy removes Docker image (#2086)", () => { // Extract the sandboxDestroy function body const destroyMatch = nemoclawSrc.match(/async function sandboxDestroy[\s\S]*?^}/m); expect(destroyMatch).toBeTruthy(); + if (!destroyMatch) { + throw new Error("Expected sandboxDestroy() in src/nemoclaw.ts"); + } const destroyBody = destroyMatch[0]; // removeSandboxImage must appear before registry.removeSandbox @@ -38,6 +40,9 @@ describe("image cleanup: sandbox destroy removes Docker image (#2086)", () => { /async function sandboxRebuild[\s\S]*?^\s*console\.log\(`\s*\$\{G\}.*Sandbox.*rebuilt/m, ); expect(rebuildMatch).toBeTruthy(); + if (!rebuildMatch) { + throw new Error("Expected sandboxRebuild() in src/nemoclaw.ts"); + } const rebuildBody = rebuildMatch[0]; const removeImageIdx = rebuildBody.indexOf("removeSandboxImage("); @@ -51,6 +56,9 @@ describe("image cleanup: sandbox destroy removes Docker image (#2086)", () => { // The function should check for imageTag before attempting removal const fnMatch = nemoclawSrc.match(/function removeSandboxImage[\s\S]*?^}/m); expect(fnMatch).toBeTruthy(); + if (!fnMatch) { + throw new Error("Expected removeSandboxImage() in src/nemoclaw.ts"); + } expect(fnMatch[0]).toContain("imageTag"); }); }); @@ -85,6 +93,9 @@ describe("image cleanup: registry stores imageTag (#2086)", () => { // The registerSandbox function should include imageTag in the stored entry const registerMatch = registrySrc.match(/function registerSandbox[\s\S]*?^}/m); expect(registerMatch).toBeTruthy(); + if (!registerMatch) { + throw new Error("Expected registerSandbox() in src/lib/registry.ts"); + } expect(registerMatch[0]).toContain("imageTag"); }); }); @@ -95,6 +106,9 @@ describe("image cleanup: gc command exists (#2086)", () => { it("gc is a global command", () => { const globalBlock = nemoclawSrc.match(/GLOBAL_COMMANDS\s*=\s*new Set\(\[[\s\S]*?\]\)/); expect(globalBlock).toBeTruthy(); + if (!globalBlock) { + throw new Error("Expected GLOBAL_COMMANDS definition in src/nemoclaw.ts"); + } expect(globalBlock[0]).toContain('"gc"'); }); @@ -106,6 +120,9 @@ describe("image cleanup: gc command exists (#2086)", () => { it("garbageCollectImages lists sandbox-from images and cross-references registry", () => { const gcMatch = nemoclawSrc.match(/async function garbageCollectImages[\s\S]*?^}/m); expect(gcMatch).toBeTruthy(); + if (!gcMatch) { + throw new Error("Expected garbageCollectImages() in src/nemoclaw.ts"); + } const gcBody = gcMatch[0]; // Must query docker for sandbox-from images @@ -121,6 +138,9 @@ describe("image cleanup: gc command exists (#2086)", () => { it("gc appears in help text", () => { const helpMatch = nemoclawSrc.match(/function help\(\)[\s\S]*?^}/m); expect(helpMatch).toBeTruthy(); + if (!helpMatch) { + throw new Error("Expected help() in src/nemoclaw.ts"); + } expect(helpMatch[0]).toContain("nemoclaw gc"); }); }); diff --git a/test/install-npm-resolution.test.ts b/test/install-npm-resolution.test.ts index 1029a22c86b..4bf46857b58 100644 --- a/test/install-npm-resolution.test.ts +++ b/test/install-npm-resolution.test.ts @@ -41,7 +41,7 @@ function writeExecutable(target: string, contents: string): void { function runInstallerFunction( bashSnippet: string, fakeBin: string, - extraEnv: NodeJS.ProcessEnv = {}, + extraEnv: Record = {}, cwd?: string, /** When true, bashSnippet is run verbatim (caller handles sourcing). */ rawSnippet = false, @@ -65,7 +65,9 @@ function runInstallerFunction( * drop privileges for permission-sensitive assertions. */ function isLinuxRoot(): boolean { - return typeof process.getuid === "function" && process.getuid() === 0 && process.platform === "linux"; + return ( + typeof process.getuid === "function" && process.getuid() === 0 && process.platform === "linux" + ); } describe("installer npm resolution", () => { @@ -186,7 +188,8 @@ exit 98 fs.chmodSync(prefixBin, needsDrop ? 0o777 : 0o755); fs.chmodSync(prefixLib, 0o555); - const innerSnippet = 'if npm_link_targets_writable "$TARGET_PREFIX"; then echo WRITABLE; else echo BLOCKED; fi'; + const innerSnippet = + 'if npm_link_targets_writable "$TARGET_PREFIX"; then echo WRITABLE; else echo BLOCKED; fi'; let result; if (needsDrop) { @@ -196,12 +199,17 @@ exit 98 const localPayload = path.join(tmp, "install.sh"); fs.copyFileSync(INSTALLER_PAYLOAD, localPayload); fs.chmodSync(localPayload, 0o644); - const wrapped = - `su -s /bin/bash nobody -c 'source "${localPayload}" >/dev/null 2>&1; ${innerSnippet}'`; - result = runInstallerFunction(wrapped, fakeBin, { - HOME: tmp, - TARGET_PREFIX: prefix, - }, tmp, true); + const wrapped = `su -s /bin/bash nobody -c 'source "${localPayload}" >/dev/null 2>&1; ${innerSnippet}'`; + result = runInstallerFunction( + wrapped, + fakeBin, + { + HOME: tmp, + TARGET_PREFIX: prefix, + }, + tmp, + true, + ); } else { result = runInstallerFunction(innerSnippet, fakeBin, { HOME: tmp, diff --git a/test/install-openshell-version-check.test.ts b/test/install-openshell-version-check.test.ts index 533537dc142..4fc4f457738 100644 --- a/test/install-openshell-version-check.test.ts +++ b/test/install-openshell-version-check.test.ts @@ -1,4 +1,3 @@ -// @ts-nocheck // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 diff --git a/test/install-preflight.test.ts b/test/install-preflight.test.ts index 4f6a4889857..bfb1e14c423 100644 --- a/test/install-preflight.test.ts +++ b/test/install-preflight.test.ts @@ -1,4 +1,3 @@ -// @ts-nocheck // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 @@ -49,7 +48,9 @@ function buildIsolatedSystemPath() { // the first pass. Any other error (EPERM, EACCES, EINVAL, ENOENT…) // would leave TEST_SYSTEM_PATH partially populated and turn into a // confusing downstream test failure, so re-throw it. - if (err && err.code === "EEXIST") continue; + const code = + typeof err === "object" && err !== null && "code" in err ? err.code : undefined; + if (code === "EEXIST") continue; throw err; } } @@ -59,16 +60,24 @@ function buildIsolatedSystemPath() { const TEST_SYSTEM_PATH = buildIsolatedSystemPath(); -function writeExecutable(target, contents) { +function writeExecutable(target: string, contents: string) { fs.writeFileSync(target, contents, { mode: 0o755 }); } +function requireMatch(match: RegExpMatchArray | null, message: string): RegExpMatchArray { + expect(match).not.toBeNull(); + if (!match) { + throw new Error(message); + } + return match; +} + // --------------------------------------------------------------------------- // Helpers shared across suites // --------------------------------------------------------------------------- /** Fake node that reports v22.16.0. */ -function writeNodeStub(fakeBin) { +function writeNodeStub(fakeBin: string) { writeExecutable( path.join(fakeBin, "node"), `#!/usr/bin/env bash @@ -87,7 +96,7 @@ exit 99`, * Minimal npm stub. Handles --version, config-get-prefix, and a custom * install handler injected as a shell snippet via NPM_INSTALL_HANDLER. */ -function writeNpmStub(fakeBin, installSnippet = "exit 0") { +function writeNpmStub(fakeBin: string, installSnippet: string = "exit 0") { writeExecutable( path.join(fakeBin, "npm"), `#!/usr/bin/env bash @@ -1334,7 +1343,7 @@ describe("installer release-tag resolution", () => { * Requires the source guard so that main() doesn't run on source. * `fakeBin` must contain a `curl` stub (and optionally `node`). */ - function callResolveReleaseTag(fakeBin, env = {}) { + function callResolveReleaseTag(fakeBin: string, env: Record = {}) { return spawnSync("bash", ["-c", `source "${INSTALLER}" 2>/dev/null; resolve_release_tag`], { cwd: path.join(import.meta.dirname, ".."), encoding: "utf-8", @@ -1539,9 +1548,11 @@ fi`, // block where it's easy to miss. it("install_nodejs upgrade path emits a Node-specific shell-reload hint", () => { const script = fs.readFileSync(INSTALLER_PAYLOAD, "utf-8"); - const installNodejs = script.match(/install_nodejs\(\)\s*\{[\s\S]*?\n\}/); - expect(installNodejs).not.toBeNull(); - const body = installNodejs![0]; + const installNodejs = requireMatch( + script.match(/install_nodejs\(\)\s*\{[\s\S]*?\n\}/), + "Expected install_nodejs() function body to be present", + ); + const body = installNodejs[0]; // Anchor to the actual warn/printf calls (not the comment) so the test // fails if the executable statements are removed. A child process can't // mutate the parent's PATH, so the honest fix is printing the exact @@ -1562,7 +1573,7 @@ describe("installer pure helpers", () => { /** * Helper: source install.sh and call a function, returning stdout. */ - function callInstallerFn(fnCall, env = {}) { + function callInstallerFn(fnCall: string, env: Record = {}) { return spawnSync("bash", ["-c", `source "${INSTALLER}" 2>/dev/null; ${fnCall}`], { cwd: path.join(import.meta.dirname, ".."), encoding: "utf-8", @@ -1837,7 +1848,10 @@ describe("installer runtime checks (sourced)", () => { * Call ensure_supported_runtime() in isolation by sourcing install.sh. * This avoids triggering install_nodejs() which would download real nvm. */ - function callEnsureSupportedRuntime(fakeBin, env = {}) { + function callEnsureSupportedRuntime( + fakeBin: string, + env: Record = {}, + ) { return spawnSync( "bash", ["-c", `source "${INSTALLER}" 2>/dev/null; ensure_supported_runtime`], @@ -1974,7 +1988,10 @@ describe("curl-pipe installer release-tag resolution", () => { * Unlike install.sh, this script also requires docker, openshell, and * uname stubs because it runs everything top-to-bottom with no main(). */ - function buildCurlPipeEnv(tmp, { curlStub, gitStub }) { + function buildCurlPipeEnv( + tmp: string, + { curlStub, gitStub }: { curlStub: string; gitStub: string }, + ) { const fakeBin = path.join(tmp, "bin"); const prefix = path.join(tmp, "prefix"); const gitLog = path.join(tmp, "git.log"); diff --git a/test/legacy-path-guard.test.ts b/test/legacy-path-guard.test.ts index 30a977ca187..5fda3474aab 100644 --- a/test/legacy-path-guard.test.ts +++ b/test/legacy-path-guard.test.ts @@ -27,36 +27,41 @@ function initTempRepo(prefix: string): string { run("git", ["init", "-b", "main"], repoDir); run("git", ["config", "user.name", "Test User"], repoDir); run("git", ["config", "user.email", "test@example.com"], repoDir); + run("git", ["config", "commit.gpgsign", "false"], repoDir); return repoDir; } describe("ts-migration:guard", () => { - it("blocks renaming a removed shim by checking the source path in R entries", { timeout: 15000 }, () => { - const repoDir = initTempRepo("nemoclaw-legacy-guard-"); - const originalPath = path.join(repoDir, "bin", "lib", "runner.js"); - const renamedPath = path.join(repoDir, "tmp", "runner.js"); - - fs.mkdirSync(path.dirname(originalPath), { recursive: true }); - fs.writeFileSync(originalPath, "module.exports = {};\n"); - run("git", ["add", "."], repoDir); - run("git", ["commit", "-m", "base"], repoDir); - - run("git", ["checkout", "-b", "feature"], repoDir); - fs.mkdirSync(path.dirname(renamedPath), { recursive: true }); - run("git", ["mv", "bin/lib/runner.js", "tmp/runner.js"], repoDir); - run("git", ["commit", "-m", "rename shim"], repoDir); - - const result = spawnSync(TSX, [GUARD_SCRIPT, "--base", "main", "--head", "HEAD"], { - cwd: repoDir, - encoding: "utf-8", - }); - - expect(result.status).toBe(1); - expect(`${result.stdout}${result.stderr}`).toContain( - "Removed compatibility shims must not be reintroduced or edited directly:", - ); - expect(`${result.stdout}${result.stderr}`).toContain( - "bin/lib/runner.js -> src/lib/runner.ts", - ); - }); + it( + "blocks renaming a removed shim by checking the source path in R entries", + { timeout: 15000 }, + () => { + const repoDir = initTempRepo("nemoclaw-legacy-guard-"); + const originalPath = path.join(repoDir, "bin", "lib", "runner.js"); + const renamedPath = path.join(repoDir, "tmp", "runner.js"); + + fs.mkdirSync(path.dirname(originalPath), { recursive: true }); + fs.writeFileSync(originalPath, "module.exports = {};\n"); + run("git", ["add", "."], repoDir); + run("git", ["commit", "-m", "base"], repoDir); + + run("git", ["checkout", "-b", "feature"], repoDir); + fs.mkdirSync(path.dirname(renamedPath), { recursive: true }); + run("git", ["mv", "bin/lib/runner.js", "tmp/runner.js"], repoDir); + run("git", ["commit", "-m", "rename shim"], repoDir); + + const result = spawnSync(TSX, [GUARD_SCRIPT, "--base", "main", "--head", "HEAD"], { + cwd: repoDir, + encoding: "utf-8", + }); + + expect(result.status).toBe(1); + expect(`${result.stdout}${result.stderr}`).toContain( + "Removed compatibility shims must not be reintroduced or edited directly:", + ); + expect(`${result.stdout}${result.stderr}`).toContain( + "bin/lib/runner.js -> src/lib/runner.ts", + ); + }, + ); }); diff --git a/test/nemoclaw-cli-recovery.test.ts b/test/nemoclaw-cli-recovery.test.ts index fd5cff56e20..46794e3da28 100644 --- a/test/nemoclaw-cli-recovery.test.ts +++ b/test/nemoclaw-cli-recovery.test.ts @@ -1,4 +1,3 @@ -// @ts-nocheck // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 diff --git a/test/ollama-proxy-recovery.test.ts b/test/ollama-proxy-recovery.test.ts index b4c7ce92bdc..7657e614415 100644 --- a/test/ollama-proxy-recovery.test.ts +++ b/test/ollama-proxy-recovery.test.ts @@ -9,6 +9,14 @@ import { spawnSync } from "node:child_process"; import { describe, it } from "vitest"; +function parseStdoutJson(stdout: string): T { + const line = stdout.trim().split("\n").pop(); + if (!line) { + throw new Error("Expected JSON payload on the last stdout line"); + } + return JSON.parse(line); +} + describe("ollama auth proxy recovery", () => { it("restarts the proxy from the persisted token when the recorded pid is stale", () => { const repoRoot = path.join(import.meta.dirname, ".."); @@ -72,7 +80,20 @@ console.log(JSON.stringify({ }); assert.equal(result.status, 0, result.stderr); - const payload = JSON.parse(result.stdout.trim().split("\n").pop() as string); + const payload = parseStdoutJson<{ + proxySpawns: Array<{ + cmd: string; + args: string[]; + detached: boolean; + stdio: string; + env: { + OLLAMA_PROXY_TOKEN: string; + OLLAMA_PROXY_PORT: string; + OLLAMA_BACKEND_PORT: string; + }; + }>; + pid: string; + }>(result.stdout); assert.equal(payload.proxySpawns.length, 1); assert.equal(payload.pid, "4242"); assert.equal(payload.proxySpawns[0].cmd, process.execPath); @@ -131,7 +152,7 @@ console.log(JSON.stringify({ proxySpawns })); }); assert.equal(result.status, 0, result.stderr); - const payload = JSON.parse(result.stdout.trim().split("\n").pop() as string); + const payload = parseStdoutJson<{ proxySpawns: object[] }>(result.stdout); assert.equal(payload.proxySpawns.length, 0); }); }); diff --git a/test/onboard-readiness.test.ts b/test/onboard-readiness.test.ts index 23f143069d4..3844e1b566c 100644 --- a/test/onboard-readiness.test.ts +++ b/test/onboard-readiness.test.ts @@ -1,10 +1,33 @@ -// @ts-nocheck // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 import { describe, it, expect } from "vitest"; import { applyPreset, buildPolicySetCommand, buildPolicyGetCommand } from "../dist/lib/policies"; -import { hasStaleGateway, isSandboxReady, parseSandboxStatus } from "../dist/lib/onboard"; + +type OnboardReadinessInternals = { + hasStaleGateway: (output: string | null | undefined) => boolean; + isSandboxReady: (output: string | null | undefined, sandboxName: string) => boolean; + parseSandboxStatus: (output: string | null | undefined, sandboxName: string) => string | null; +}; + +function isOnboardReadinessInternals(value: object | null): value is OnboardReadinessInternals { + return ( + value !== null && + typeof Reflect.get(value, "hasStaleGateway") === "function" && + typeof Reflect.get(value, "isSandboxReady") === "function" && + typeof Reflect.get(value, "parseSandboxStatus") === "function" + ); +} + +const loadedOnboardReadinessInternals = require("../dist/lib/onboard"); +const onboardReadinessInternals = + typeof loadedOnboardReadinessInternals === "object" && loadedOnboardReadinessInternals !== null + ? loadedOnboardReadinessInternals + : null; +if (!isOnboardReadinessInternals(onboardReadinessInternals)) { + throw new Error("Expected onboard readiness internals to be available"); +} +const { hasStaleGateway, isSandboxReady, parseSandboxStatus } = onboardReadinessInternals; describe("sandbox readiness parsing", () => { it("detects Ready sandbox", () => { @@ -132,7 +155,9 @@ describe("parseSandboxStatus", () => { }); it("returns ContainerCreating status", () => { - expect(parseSandboxStatus("my-assistant ContainerCreating 5s ago", "my-assistant")).toBe("ContainerCreating"); + expect(parseSandboxStatus("my-assistant ContainerCreating 5s ago", "my-assistant")).toBe( + "ContainerCreating", + ); }); it("returns Failed status", () => { @@ -140,7 +165,9 @@ describe("parseSandboxStatus", () => { }); it("returns CrashLoopBackOff status", () => { - expect(parseSandboxStatus("my-assistant CrashLoopBackOff 3m ago", "my-assistant")).toBe("CrashLoopBackOff"); + expect(parseSandboxStatus("my-assistant CrashLoopBackOff 3m ago", "my-assistant")).toBe( + "CrashLoopBackOff", + ); }); it("returns null when sandbox not found", () => { @@ -158,7 +185,10 @@ describe("parseSandboxStatus", () => { it("strips ANSI codes before parsing", () => { expect( - parseSandboxStatus("\x1b[1mmy-assistant\x1b[0m \x1b[33mPending\x1b[0m 10s", "my-assistant") + parseSandboxStatus( + "\x1b[1mmy-assistant\x1b[0m \x1b[33mPending\x1b[0m 10s", + "my-assistant", + ), ).toBe("Pending"); }); diff --git a/test/onboard-selection.test.ts b/test/onboard-selection.test.ts index 78d0a2a4eb6..d29469e5300 100644 --- a/test/onboard-selection.test.ts +++ b/test/onboard-selection.test.ts @@ -1,4 +1,3 @@ -// @ts-nocheck // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 @@ -14,7 +13,7 @@ const CREDENTIAL_RETRY_PROMPT = const CREDENTIAL_RETRY_PROMPT_RE = /Options: retry \(re-enter key\), back \(change provider\), exit \[retry\]: /; -function writeOpenAiStyleAuthRetryCurl(fakeBin, goodToken, models = ["gpt-5.4"]) { +function writeOpenAiStyleAuthRetryCurl(fakeBin: string, goodToken: string, models = ["gpt-5.4"]) { fs.writeFileSync( path.join(fakeBin, "curl"), `#!/usr/bin/env bash @@ -59,7 +58,11 @@ printf '%s' "$status" ); } -function writeAnthropicStyleAuthRetryCurl(fakeBin, goodToken, models = ["claude-sonnet-4-6"]) { +function writeAnthropicStyleAuthRetryCurl( + fakeBin: string, + goodToken: string, + models = ["claude-sonnet-4-6"], +) { fs.writeFileSync( path.join(fakeBin, "curl"), `#!/usr/bin/env bash @@ -188,9 +191,13 @@ const { setupNim } = require(${onboardPath}); assert.equal(payload.promptCalls, 2); assert.match(payload.messages[0], /Choose \[/); assert.match(payload.messages[1], /Choose model \[1\]/); - assert.ok(payload.lines.some((line) => line.includes("Detected local inference option"))); - assert.ok(payload.lines.some((line) => line.includes("Cloud models:"))); - assert.ok(payload.lines.some((line) => line.includes("Chat Completions API available"))); + assert.ok( + payload.lines.some((line: string) => line.includes("Detected local inference option")), + ); + assert.ok(payload.lines.some((line: string) => line.includes("Cloud models:"))); + assert.ok( + payload.lines.some((line: string) => line.includes("Chat Completions API available")), + ); }); it("does not label NVIDIA Endpoints as recommended in the provider list", () => { @@ -264,8 +271,10 @@ const { setupNim } = require(${onboardPath}); assert.equal(result.status, 0, result.stderr); const payload = JSON.parse(result.stdout.trim()); - assert.ok(payload.lines.some((line) => line.includes("NVIDIA Endpoints"))); - assert.ok(!payload.lines.some((line) => line.includes("NVIDIA Endpoints (recommended)"))); + assert.ok(payload.lines.some((line: string) => line.includes("NVIDIA Endpoints"))); + assert.ok( + !payload.lines.some((line: string) => line.includes("NVIDIA Endpoints (recommended)")), + ); }); it("accepts a manually entered NVIDIA Endpoints model after validating it against /models", () => { @@ -363,7 +372,7 @@ const { setupNim } = require(${onboardPath}); assert.equal(payload.result.preferredInferenceApi, "openai-completions"); assert.match(payload.messages[1], /Choose model \[1\]/); assert.match(payload.messages[2], /NVIDIA Endpoints model id:/); - assert.ok(payload.lines.some((line) => line.includes("Other..."))); + assert.ok(payload.lines.some((line: string) => line.includes("Other..."))); }); it("reprompts for a manual NVIDIA Endpoints model when /models validation rejects it", () => { @@ -456,11 +465,12 @@ const { setupNim } = require(${onboardPath}); const payload = JSON.parse(result.stdout.trim()); assert.equal(payload.result.model, "z-ai/glm5"); assert.equal( - payload.messages.filter((message) => /NVIDIA Endpoints model id:/.test(message)).length, + payload.messages.filter((message: string) => /NVIDIA Endpoints model id:/.test(message)) + .length, 2, ); assert.ok( - payload.lines.some((line) => line.includes("is not available from NVIDIA Endpoints")), + payload.lines.some((line: string) => line.includes("is not available from NVIDIA Endpoints")), ); }); @@ -551,10 +561,12 @@ const { setupNim } = require(${onboardPath}); assert.match(payload.messages[0], /Choose \[/); assert.match(payload.messages[1], /Choose model \[5\]/); assert.match(payload.messages[2], /Google Gemini model id:/); - assert.ok(payload.lines.some((line) => line.includes("Google Gemini models:"))); - assert.ok(payload.lines.some((line) => line.includes("gemini-2.5-flash"))); - assert.ok(payload.lines.some((line) => line.includes("Other..."))); - assert.ok(payload.lines.some((line) => line.includes("Chat Completions API available"))); + assert.ok(payload.lines.some((line: string) => line.includes("Google Gemini models:"))); + assert.ok(payload.lines.some((line: string) => line.includes("gemini-2.5-flash"))); + assert.ok(payload.lines.some((line: string) => line.includes("Other..."))); + assert.ok( + payload.lines.some((line: string) => line.includes("Chat Completions API available")), + ); }); it("warms and validates Ollama via 127.0.0.1 before moving on", () => { @@ -652,10 +664,14 @@ const { setupNim } = require(${onboardPath}); assert.equal(payload.result.provider, "ollama-local"); assert.equal(payload.result.preferredInferenceApi, "openai-completions"); assert.ok( - payload.lines.some((line) => line.includes("Loading Ollama model: nemotron-3-nano:30b")), + payload.lines.some((line: string) => + line.includes("Loading Ollama model: nemotron-3-nano:30b"), + ), ); assert.ok( - payload.commands.some((command) => command.includes("http://127.0.0.1:11434/api/generate")), + payload.commands.some((command: string) => + command.includes("http://127.0.0.1:11434/api/generate"), + ), ); }); @@ -748,9 +764,14 @@ const { setupNim } = require(${onboardPath}); assert.equal(result.status, 0, result.stderr); const payload = JSON.parse(result.stdout.trim()); assert.equal(payload.result.provider, "nvidia-prod"); - assert.ok(payload.lines.some((line) => line.includes("Returning to provider selection."))); - assert.equal(payload.messages.filter((message) => /Choose \[/.test(message)).length, 2); - assert.equal(payload.messages.filter((message) => /Ollama model id: /.test(message)).length, 1); + assert.ok( + payload.lines.some((line: string) => line.includes("Returning to provider selection.")), + ); + assert.equal(payload.messages.filter((message: string) => /Choose \[/.test(message)).length, 2); + assert.equal( + payload.messages.filter((message: string) => /Ollama model id: /.test(message)).length, + 1, + ); }); it("offers starter Ollama models when none are installed and pulls the selected model", () => { @@ -853,11 +874,15 @@ const { setupNim } = require(${onboardPath}); const payload = JSON.parse(result.stdout.trim()); assert.equal(payload.result.provider, "ollama-local"); assert.equal(payload.result.model, "qwen2.5:7b"); - assert.ok(payload.lines.some((line) => line.includes("Ollama starter models:"))); + assert.ok(payload.lines.some((line: string) => line.includes("Ollama starter models:"))); + assert.ok( + payload.lines.some((line: string) => + line.includes("No local Ollama models are installed yet"), + ), + ); assert.ok( - payload.lines.some((line) => line.includes("No local Ollama models are installed yet")), + payload.lines.some((line: string) => line.includes("Pulling Ollama model: qwen2.5:7b")), ); - assert.ok(payload.lines.some((line) => line.includes("Pulling Ollama model: qwen2.5:7b"))); assert.equal(fs.readFileSync(pullLog, "utf8").trim(), "qwen2.5:7b"); }); @@ -965,14 +990,19 @@ const { setupNim } = require(${onboardPath}); assert.equal(payload.result.provider, "ollama-local"); assert.equal(payload.result.model, "llama3.2:3b"); assert.ok( - payload.lines.some((line) => line.includes("Failed to pull Ollama model 'qwen2.5:7b'")), + payload.lines.some((line: string) => + line.includes("Failed to pull Ollama model 'qwen2.5:7b'"), + ), ); assert.ok( - payload.lines.some((line) => + payload.lines.some((line: string) => line.includes("Choose a different Ollama model or select Other."), ), ); - assert.equal(payload.messages.filter((message) => /Ollama model id:/.test(message)).length, 1); + assert.equal( + payload.messages.filter((message: string) => /Ollama model id:/.test(message)).length, + 1, + ); assert.equal(fs.readFileSync(pullLog, "utf8").trim(), "qwen2.5:7b\nllama3.2:3b"); }); @@ -1059,8 +1089,11 @@ const { setupNim } = require(${onboardPath}); assert.equal(result.status, 0, result.stderr); const payload = JSON.parse(result.stdout.trim()); assert.equal(payload.result.model, "gpt-5.4-mini"); - assert.equal(payload.messages.filter((message) => /OpenAI model id:/.test(message)).length, 2); - assert.ok(payload.lines.some((line) => line.includes("is not available from OpenAI"))); + assert.equal( + payload.messages.filter((message: string) => /OpenAI model id:/.test(message)).length, + 2, + ); + assert.ok(payload.lines.some((line: string) => line.includes("is not available from OpenAI"))); }); it("reprompts for an Anthropic Other model when /v1/models validation rejects it", () => { @@ -1143,10 +1176,12 @@ const { setupNim } = require(${onboardPath}); const payload = JSON.parse(result.stdout.trim()); assert.equal(payload.result.model, "claude-haiku-4-5"); assert.equal( - payload.messages.filter((message) => /Anthropic model id:/.test(message)).length, + payload.messages.filter((message: string) => /Anthropic model id:/.test(message)).length, 2, ); - assert.ok(payload.lines.some((line) => line.includes("is not available from Anthropic"))); + assert.ok( + payload.lines.some((line: string) => line.includes("is not available from Anthropic")), + ); }); it("returns to provider selection when Anthropic live validation fails interactively", () => { @@ -1238,9 +1273,13 @@ const { setupNim } = require(${onboardPath}); const payload = JSON.parse(result.stdout.trim()); assert.equal(payload.result.provider, "anthropic-prod"); assert.equal(payload.result.model, "claude-haiku-4-5"); - assert.ok(payload.lines.some((line) => line.includes("Anthropic endpoint validation failed"))); - assert.ok(payload.lines.some((line) => line.includes("Please choose a provider/model again"))); - assert.equal(payload.messages.filter((message) => /Choose \[/.test(message)).length, 2); + assert.ok( + payload.lines.some((line: string) => line.includes("Anthropic endpoint validation failed")), + ); + assert.ok( + payload.lines.some((line: string) => line.includes("Please choose a provider/model again")), + ); + assert.equal(payload.messages.filter((message: string) => /Choose \[/.test(message)).length, 2); }); it("supports Other Anthropic-compatible endpoint with live validation", () => { @@ -1325,7 +1364,9 @@ const { setupNim } = require(${onboardPath}); assert.equal(payload.result.preferredInferenceApi, "anthropic-messages"); assert.match(payload.messages[1], /Anthropic-compatible base URL/); assert.match(payload.messages[2], /Other Anthropic-compatible endpoint model/); - assert.ok(payload.lines.some((line) => line.includes("Anthropic Messages API available"))); + assert.ok( + payload.lines.some((line: string) => line.includes("Anthropic Messages API available")), + ); }); it("reprompts only for model name when Other OpenAI-compatible endpoint validation fails", () => { @@ -1418,25 +1459,27 @@ const { setupNim } = require(${onboardPath}); assert.equal(payload.result.model, "good-model"); assert.equal(payload.result.preferredInferenceApi, "openai-completions"); assert.ok( - payload.lines.some((line) => + payload.lines.some((line: string) => line.includes("Other OpenAI-compatible endpoint endpoint validation failed"), ), ); assert.ok( - payload.lines.some((line) => + payload.lines.some((line: string) => line.includes("Please enter a different Other OpenAI-compatible endpoint model name."), ), ); assert.equal( - payload.messages.filter((message) => /OpenAI-compatible base URL/.test(message)).length, + payload.messages.filter((message: string) => /OpenAI-compatible base URL/.test(message)) + .length, 1, ); assert.equal( - payload.messages.filter((message) => /Other OpenAI-compatible endpoint model/.test(message)) - .length, + payload.messages.filter((message: string) => + /Other OpenAI-compatible endpoint model/.test(message), + ).length, 2, ); - assert.equal(payload.messages.filter((message) => /Choose \[/.test(message)).length, 1); + assert.equal(payload.messages.filter((message: string) => /Choose \[/.test(message)).length, 1); }); it("falls back to chat completions for custom OpenAI-compatible endpoints when /responses lacks tool calls", () => { @@ -1530,7 +1573,9 @@ const { setupNim } = require(${onboardPath}); assert.equal(payload.result.provider, "compatible-endpoint"); assert.equal(payload.result.model, "custom-model"); assert.equal(payload.result.preferredInferenceApi, "openai-completions"); - assert.ok(payload.lines.some((line) => line.includes("Chat Completions API available"))); + assert.ok( + payload.lines.some((line: string) => line.includes("Chat Completions API available")), + ); }); it("forces chat completions for custom OpenAI-compatible endpoints even when /responses returns valid tool calls (#1932)", () => { @@ -1631,9 +1676,7 @@ const { setupNim } = require(${onboardPath}); assert.equal(payload.result.preferredInferenceApi, "openai-completions"); // Verify the wizard selected chat completions (either via our forced // override or via the streaming fallback — both are correct). - assert.ok( - payload.lines.some((line) => line.includes("openai-completions")), - ); + assert.ok(payload.lines.some((line: string) => line.includes("openai-completions"))); }); it("honors NEMOCLAW_PREFERRED_API=openai-responses override for custom OpenAI-compatible endpoints (#1932)", () => { @@ -1739,7 +1782,7 @@ const { setupNim } = require(${onboardPath}); assert.equal(payload.result.preferredInferenceApi, "openai-completions"); // Verify the forced-override message was NOT printed (env var bypassed it) assert.ok( - !payload.lines.some((line) => + !payload.lines.some((line: string) => line.includes("compatible endpoints may not support the Responses API developer role"), ), ); @@ -1826,12 +1869,16 @@ const { setupNim } = require(${onboardPath}); assert.equal(payload.result.provider, "nvidia-prod"); assert.equal(payload.result.model, "nvidia/nemotron-3-super-120b-a12b"); assert.ok( - payload.lines.some((line) => + payload.lines.some((line: string) => line.includes("Endpoint URL is required for Other OpenAI-compatible endpoint."), ), ); - assert.ok(payload.messages.some((message) => /OpenAI-compatible base URL/.test(message))); - assert.ok(payload.messages.filter((message) => /Choose \[1\]/.test(message)).length >= 2); + assert.ok( + payload.messages.some((message: string) => /OpenAI-compatible base URL/.test(message)), + ); + assert.ok( + payload.messages.filter((message: string) => /Choose \[1\]/.test(message)).length >= 2, + ); }); it("reprompts only for model name when Other Anthropic-compatible endpoint validation fails", () => { @@ -1923,26 +1970,27 @@ const { setupNim } = require(${onboardPath}); assert.equal(payload.result.model, "good-claude"); assert.equal(payload.result.preferredInferenceApi, "anthropic-messages"); assert.ok( - payload.lines.some((line) => + payload.lines.some((line: string) => line.includes("Other Anthropic-compatible endpoint endpoint validation failed"), ), ); assert.ok( - payload.lines.some((line) => + payload.lines.some((line: string) => line.includes("Please enter a different Other Anthropic-compatible endpoint model name."), ), ); assert.equal( - payload.messages.filter((message) => /Anthropic-compatible base URL/.test(message)).length, + payload.messages.filter((message: string) => /Anthropic-compatible base URL/.test(message)) + .length, 1, ); assert.equal( - payload.messages.filter((message) => + payload.messages.filter((message: string) => /Other Anthropic-compatible endpoint model/.test(message), ).length, 2, ); - assert.equal(payload.messages.filter((message) => /Choose \[/.test(message)).length, 1); + assert.equal(payload.messages.filter((message: string) => /Choose \[/.test(message)).length, 1); }); it("lets users type back at a lower-level model prompt to return to provider selection", () => { @@ -2023,10 +2071,13 @@ const { setupNim } = require(${onboardPath}); assert.equal(result.status, 0, result.stderr); const payload = JSON.parse(result.stdout.trim()); assert.equal(payload.result.provider, "nvidia-prod"); - assert.ok(payload.lines.some((line) => line.includes("Returning to provider selection."))); - assert.equal(payload.messages.filter((message) => /Choose \[/.test(message)).length, 2); + assert.ok( + payload.lines.some((line: string) => line.includes("Returning to provider selection.")), + ); + assert.equal(payload.messages.filter((message: string) => /Choose \[/.test(message)).length, 2); assert.equal( - payload.messages.filter((message) => /OpenAI-compatible base URL/.test(message)).length, + payload.messages.filter((message: string) => /OpenAI-compatible base URL/.test(message)) + .length, 1, ); }); @@ -2113,16 +2164,20 @@ const { setupNim } = require(${onboardPath}); const payload = JSON.parse(result.stdout.trim()); assert.equal(payload.result.provider, "nvidia-prod"); assert.ok( - payload.lines.some((line) => line.includes("could not resolve the provider hostname")), + payload.lines.some((line: string) => + line.includes("could not resolve the provider hostname"), + ), + ); + assert.ok( + payload.lines.some((line: string) => line.includes("Returning to provider selection.")), ); - assert.ok(payload.lines.some((line) => line.includes("Returning to provider selection."))); assert.equal( - payload.messages.filter((message) => + payload.messages.filter((message: string) => /Type 'retry', 'back', or 'exit' \[retry\]: /.test(message), ).length, 1, ); - assert.equal(payload.messages.filter((message) => /Choose \[/.test(message)).length, 2); + assert.equal(payload.messages.filter((message: string) => /Choose \[/.test(message)).length, 2); }); it("returns to provider selection when endpoint validation fails interactively", () => { @@ -2222,9 +2277,13 @@ const { setupNim } = require(${onboardPath}); const payload = JSON.parse(result.stdout.trim()); assert.equal(payload.result.provider, "nvidia-prod"); assert.equal(payload.result.preferredInferenceApi, "openai-completions"); - assert.ok(payload.lines.some((line) => line.includes("OpenAI endpoint validation failed"))); - assert.ok(payload.lines.some((line) => line.includes("Please choose a provider/model again"))); - assert.equal(payload.messages.filter((message) => /Choose \[/.test(message)).length, 2); + assert.ok( + payload.lines.some((line: string) => line.includes("OpenAI endpoint validation failed")), + ); + assert.ok( + payload.lines.some((line: string) => line.includes("Please choose a provider/model again")), + ); + assert.equal(payload.messages.filter((message: string) => /Choose \[/.test(message)).length, 2); }); it("fails early in non-interactive mode when NVIDIA_API_KEY is not an nvapi- key", () => { @@ -2319,9 +2378,11 @@ const { setupNim, __setNonInteractive } = onboardModule.exports; assert.equal(payload.completed, false); assert.equal(payload.exitCode, 1); assert.equal(payload.prompts.length, 0); - assert.ok(payload.lines.some((line) => line.includes("Invalid key. Must start with nvapi-"))); assert.ok( - payload.lines.some((line) => + payload.lines.some((line: string) => line.includes("Invalid key. Must start with nvapi-")), + ); + assert.ok( + payload.lines.some((line: string) => line.includes("Get a key from https://build.nvidia.com/settings/api-keys"), ), ); @@ -2423,19 +2484,25 @@ const { setupNim } = require(${onboardPath}); assert.equal(payload.result.provider, "nvidia-prod"); assert.equal(payload.result.preferredInferenceApi, "openai-completions"); assert.equal(payload.key, "nvapi-good"); - assert.ok(payload.lines.some((line) => line.includes("NVIDIA Endpoints authorization failed"))); - assert.equal(payload.messages.filter((message) => /Choose \[/.test(message)).length, 1); + assert.ok( + payload.lines.some((line: string) => line.includes("NVIDIA Endpoints authorization failed")), + ); + assert.equal(payload.messages.filter((message: string) => /Choose \[/.test(message)).length, 1); assert.equal( - payload.messages.filter((message) => /Choose model \[1\]/.test(message)).length, + payload.messages.filter((message: string) => /Choose model \[1\]/.test(message)).length, 1, ); - assert.ok(payload.messages.some((message) => CREDENTIAL_RETRY_PROMPT_RE.test(message))); - const retryPrompt = payload.prompts.find((entry) => CREDENTIAL_RETRY_PROMPT_RE.test(entry.message)); + assert.ok(payload.messages.some((message: string) => CREDENTIAL_RETRY_PROMPT_RE.test(message))); + const retryPrompt = payload.prompts.find((entry: { message: string }) => + CREDENTIAL_RETRY_PROMPT_RE.test(entry.message), + ); assert.deepEqual(retryPrompt, { message: CREDENTIAL_RETRY_PROMPT, secret: true, }); - assert.ok(payload.messages.some((message) => /NVIDIA Endpoints API key: /.test(message))); + assert.ok( + payload.messages.some((message: string) => /NVIDIA Endpoints API key: /.test(message)), + ); }); it("treats a pasted NVIDIA API key at the retry prompt as retry and re-prompts securely", () => { @@ -2501,13 +2568,15 @@ const { setupNim } = require(${onboardPath}); assert.equal(payload.result.provider, "nvidia-prod"); assert.equal(payload.result.preferredInferenceApi, "openai-completions"); assert.equal(payload.key, "nvapi-good"); - assert.ok(payload.lines.some((line) => line.includes("That looks like an API key"))); - assert.ok(payload.lines.some((line) => line.includes("Treating as 'retry'"))); - assert.ok(payload.messages.some((message) => CREDENTIAL_RETRY_PROMPT_RE.test(message))); - assert.ok(payload.messages.some((message) => /NVIDIA Endpoints API key: /.test(message))); - assert.equal(payload.messages.filter((message) => /Choose \[/.test(message)).length, 1); + assert.ok(payload.lines.some((line: string) => line.includes("That looks like an API key"))); + assert.ok(payload.lines.some((line: string) => line.includes("Treating as 'retry'"))); + assert.ok(payload.messages.some((message: string) => CREDENTIAL_RETRY_PROMPT_RE.test(message))); + assert.ok( + payload.messages.some((message: string) => /NVIDIA Endpoints API key: /.test(message)), + ); + assert.equal(payload.messages.filter((message: string) => /Choose \[/.test(message)).length, 1); assert.equal( - payload.messages.filter((message) => /Choose model \[1\]/.test(message)).length, + payload.messages.filter((message: string) => /Choose model \[1\]/.test(message)).length, 1, ); }); @@ -2578,12 +2647,12 @@ const { setupNim } = require(${onboardPath}); assert.equal(payload.result.model, "gpt-5.4"); assert.equal(payload.result.preferredInferenceApi, "openai-responses"); assert.equal(payload.key, "sk-good"); - assert.ok(payload.lines.some((line) => line.includes("OpenAI authorization failed"))); - assert.ok(payload.messages.some((message) => CREDENTIAL_RETRY_PROMPT_RE.test(message))); - assert.ok(payload.messages.some((message) => /OpenAI API key: /.test(message))); - assert.equal(payload.messages.filter((message) => /Choose \[/.test(message)).length, 1); + assert.ok(payload.lines.some((line: string) => line.includes("OpenAI authorization failed"))); + assert.ok(payload.messages.some((message: string) => CREDENTIAL_RETRY_PROMPT_RE.test(message))); + assert.ok(payload.messages.some((message: string) => /OpenAI API key: /.test(message))); + assert.equal(payload.messages.filter((message: string) => /Choose \[/.test(message)).length, 1); assert.equal( - payload.messages.filter((message) => /Choose model \[1\]/.test(message)).length, + payload.messages.filter((message: string) => /Choose model \[1\]/.test(message)).length, 2, ); }); @@ -2652,12 +2721,14 @@ const { setupNim } = require(${onboardPath}); assert.equal(payload.result.model, "claude-sonnet-4-6"); assert.equal(payload.result.preferredInferenceApi, "anthropic-messages"); assert.equal(payload.key, "anthropic-good"); - assert.ok(payload.lines.some((line) => line.includes("Anthropic authorization failed"))); - assert.ok(payload.messages.some((message) => CREDENTIAL_RETRY_PROMPT_RE.test(message))); - assert.ok(payload.messages.some((message) => /Anthropic API key: /.test(message))); - assert.equal(payload.messages.filter((message) => /Choose \[/.test(message)).length, 1); + assert.ok( + payload.lines.some((line: string) => line.includes("Anthropic authorization failed")), + ); + assert.ok(payload.messages.some((message: string) => CREDENTIAL_RETRY_PROMPT_RE.test(message))); + assert.ok(payload.messages.some((message: string) => /Anthropic API key: /.test(message))); + assert.equal(payload.messages.filter((message: string) => /Choose \[/.test(message)).length, 1); assert.equal( - payload.messages.filter((message) => /Choose model \[1\]/.test(message)).length, + payload.messages.filter((message: string) => /Choose model \[1\]/.test(message)).length, 2, ); }); @@ -2726,12 +2797,14 @@ const { setupNim } = require(${onboardPath}); assert.equal(payload.result.model, "gemini-2.5-flash"); assert.equal(payload.result.preferredInferenceApi, "openai-completions"); assert.equal(payload.key, "gemini-good"); - assert.ok(payload.lines.some((line) => line.includes("Google Gemini authorization failed"))); - assert.ok(payload.messages.some((message) => CREDENTIAL_RETRY_PROMPT_RE.test(message))); - assert.ok(payload.messages.some((message) => /Google Gemini API key: /.test(message))); - assert.equal(payload.messages.filter((message) => /Choose \[/.test(message)).length, 1); + assert.ok( + payload.lines.some((line: string) => line.includes("Google Gemini authorization failed")), + ); + assert.ok(payload.messages.some((message: string) => CREDENTIAL_RETRY_PROMPT_RE.test(message))); + assert.ok(payload.messages.some((message: string) => /Google Gemini API key: /.test(message))); + assert.equal(payload.messages.filter((message: string) => /Choose \[/.test(message)).length, 1); assert.equal( - payload.messages.filter((message) => /Choose model \[5\]/.test(message)).length, + payload.messages.filter((message: string) => /Choose model \[5\]/.test(message)).length, 2, ); }); @@ -2804,26 +2877,28 @@ const { setupNim } = require(${onboardPath}); assert.equal(payload.result.preferredInferenceApi, "openai-completions"); assert.equal(payload.key, "proxy-good"); assert.ok( - payload.lines.some((line) => + payload.lines.some((line: string) => line.includes("Other OpenAI-compatible endpoint authorization failed"), ), ); - assert.ok(payload.messages.some((message) => CREDENTIAL_RETRY_PROMPT_RE.test(message))); + assert.ok(payload.messages.some((message: string) => CREDENTIAL_RETRY_PROMPT_RE.test(message))); assert.ok( - payload.messages.some((message) => + payload.messages.some((message: string) => /Other OpenAI-compatible endpoint API key: /.test(message), ), ); assert.equal( - payload.messages.filter((message) => /OpenAI-compatible base URL/.test(message)).length, + payload.messages.filter((message: string) => /OpenAI-compatible base URL/.test(message)) + .length, 1, ); assert.equal( - payload.messages.filter((message) => /Other OpenAI-compatible endpoint model/.test(message)) - .length, + payload.messages.filter((message: string) => + /Other OpenAI-compatible endpoint model/.test(message), + ).length, 2, ); - assert.equal(payload.messages.filter((message) => /Choose \[/.test(message)).length, 1); + assert.equal(payload.messages.filter((message: string) => /Choose \[/.test(message)).length, 1); }); it("lets users re-enter a custom Anthropic-compatible API key without re-entering the endpoint URL", () => { @@ -2894,27 +2969,28 @@ const { setupNim } = require(${onboardPath}); assert.equal(payload.result.preferredInferenceApi, "anthropic-messages"); assert.equal(payload.key, "anthropic-proxy-good"); assert.ok( - payload.lines.some((line) => + payload.lines.some((line: string) => line.includes("Other Anthropic-compatible endpoint authorization failed"), ), ); - assert.ok(payload.messages.some((message) => CREDENTIAL_RETRY_PROMPT_RE.test(message))); + assert.ok(payload.messages.some((message: string) => CREDENTIAL_RETRY_PROMPT_RE.test(message))); assert.ok( - payload.messages.some((message) => + payload.messages.some((message: string) => /Other Anthropic-compatible endpoint API key: /.test(message), ), ); assert.equal( - payload.messages.filter((message) => /Anthropic-compatible base URL/.test(message)).length, + payload.messages.filter((message: string) => /Anthropic-compatible base URL/.test(message)) + .length, 1, ); assert.equal( - payload.messages.filter((message) => + payload.messages.filter((message: string) => /Other Anthropic-compatible endpoint model/.test(message), ).length, 2, ); - assert.equal(payload.messages.filter((message) => /Choose \[/.test(message)).length, 1); + assert.equal(payload.messages.filter((message: string) => /Choose \[/.test(message)).length, 1); }); it("forces openai-completions for vLLM even when probe detects openai-responses", () => { @@ -3015,8 +3091,8 @@ const { setupNim } = require(${onboardPath}); // Key assertion: even though probe detected openai-responses, the override // forces openai-completions so tool-call-parser works correctly. assert.equal(payload.result.preferredInferenceApi, "openai-completions"); - assert.ok(payload.lines.some((line) => line.includes("Using existing vLLM"))); - assert.ok(payload.lines.some((line) => line.includes("tool-call-parser requires"))); + assert.ok(payload.lines.some((line: string) => line.includes("Using existing vLLM"))); + assert.ok(payload.lines.some((line: string) => line.includes("tool-call-parser requires"))); }); it("forces openai-completions for NIM-local even when probe detects openai-responses", () => { @@ -3128,7 +3204,7 @@ const { setupNim } = require(${onboardPath}); assert.equal(payload.result.model, "nvidia/nemotron-3-nano"); // Key assertion: NIM uses vLLM internally — same override must apply. assert.equal(payload.result.preferredInferenceApi, "openai-completions"); - assert.ok(payload.lines.some((line) => line.includes("tool-call-parser requires"))); + assert.ok(payload.lines.some((line: string) => line.includes("tool-call-parser requires"))); }); it("offers install-ollama option on Linux when Ollama is not installed", () => { @@ -3271,7 +3347,7 @@ const { setupNim } = require(${onboardPath}); // Should have shown the "Install Ollama (Linux)" option assert.ok( payload.lines.some((line: string) => line.includes("Install Ollama (Linux)")), - "Should show Install Ollama option on Linux" + "Should show Install Ollama option on Linux", ); // Should have selected ollama-local provider after install @@ -3280,11 +3356,11 @@ const { setupNim } = require(${onboardPath}); // Should have run the curl installer (not brew) assert.ok( payload.runCommands.some((cmd: string) => cmd.includes("ollama.com/install.sh")), - "Should use curl installer on Linux" + "Should use curl installer on Linux", ); assert.ok( !payload.runCommands.some((cmd: string) => cmd.includes("brew install")), - "Should NOT use brew on Linux" + "Should NOT use brew on Linux", ); }); }); diff --git a/test/onboard.test.ts b/test/onboard.test.ts index cb56a652552..81ad12a448e 100644 --- a/test/onboard.test.ts +++ b/test/onboard.test.ts @@ -1,4 +1,3 @@ -// @ts-nocheck // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 @@ -9,7 +8,117 @@ import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; -import { +import { buildChain, buildControlUiUrls } from "../dist/lib/dashboard-contract.js"; +import { stageOptimizedSandboxBuildContext } from "../dist/lib/sandbox-build-context.js"; + +type ShimScalar = string | number | boolean | null | undefined; +type ShimCallable = (...args: readonly string[]) => ShimValue; +type ShimValue = ShimScalar | { [key: string]: ShimValue } | ShimValue[] | ShimCallable; +type ShimFn = (...args: ShimValue[]) => TReturn; +type CommandEntry = { + command: string; + env?: Record; +}; +type DashboardAccess = { label: string; url: string }; +type ResumeConflict = { field: string; requested: string | null; recorded: string | null }; +type SandboxInferenceConfig = { + providerKey: string; + primaryModelRef: string; + inferenceBaseUrl: string; + inferenceApi: string; + inferenceCompat: ShimValue; +}; +type ValidationClassification = { kind: string; retry: string }; + +type OnboardTestInternals = { + buildProviderArgs: ( + action: "create" | "update", + name: string, + type: string, + credentialEnv: string, + baseUrl: string | null, + ) => string[]; + buildSandboxConfigSyncScript: ShimFn; + classifySandboxCreateFailure: (output?: string) => { kind: string; uploadedToGateway: boolean }; + compactText: (value?: string) => string; + computeSetupPresetSuggestions: ShimFn; + formatEnvAssignment: (name: string, value: string) => string; + findDashboardForwardOwner: ( + forwardListOutput: string | null | undefined, + portToStop: string, + ) => string | null; + formatOnboardConfigSummary: ShimFn; + getDashboardAccessInfo: ShimFn; + getDashboardForwardStartCommand: ShimFn; + getNavigationChoice: (value?: string | null) => string | null; + getGatewayReuseState: ShimFn; + getPortConflictServiceHints: (platform?: string) => string[]; + getFutureShellPathHint: (binDir: string, pathValue?: string) => string | null; + getSandboxInferenceConfig: ShimFn; + getInstalledOpenshellVersion: (versionOutput?: string | null) => string | null; + getBlueprintMinOpenshellVersion: (rootDir?: string) => string | null; + getBlueprintMaxOpenshellVersion: (rootDir?: string) => string | null; + versionGte: (left?: string | null, right?: string | null) => boolean; + getRequestedModelHint: ShimFn; + getRequestedProviderHint: ShimFn; + getRequestedSandboxNameHint: ShimFn; + getResumeConfigConflicts: ShimFn; + getResumeSandboxConflict: ShimFn<{ + requestedSandboxName: string; + recordedSandboxName: string; + } | null>; + getSandboxStateFromOutputs: ShimFn; + getStableGatewayImageRef: (versionOutput?: string | null) => string | null; + getSuggestedPolicyPresets: ShimFn; + isGatewayHealthy: ShimFn; + classifyValidationFailure: ShimFn; + hasResponsesToolCall: (body?: string | null) => boolean; + isLoopbackHostname: (hostname?: string) => boolean; + normalizeProviderBaseUrl: ( + value: string | null | undefined, + flavor: "openai" | "anthropic", + ) => string; + parsePolicyPresetEnv: (value: string | null) => string[]; + patchStagedDockerfile: ShimFn; + pullAndResolveBaseImageDigest: () => { digest: string; ref: string } | null; + SANDBOX_BASE_IMAGE: string; + printSandboxCreateRecoveryHints: ShimFn; + resolveDashboardForwardTarget: (chatUiUrl?: string) => string; + summarizeCurlFailure: ShimFn; + summarizeProbeFailure: ShimFn; + shouldIncludeBuildContextPath: ShimFn; + writeSandboxConfigSyncFile: (script: string) => string; +}; + +function parseStdoutJson(stdout: string): T { + const line = stdout.trim().split("\n").pop(); + assert.ok(line, `expected JSON payload in stdout:\n${stdout}`); + return JSON.parse(line); +} + +type OnboardTestInternalsCandidate = Partial | null; + +function isOnboardTestInternals( + value: OnboardTestInternalsCandidate, +): value is OnboardTestInternals { + return ( + value !== null && + typeof value.buildProviderArgs === "function" && + typeof value.classifySandboxCreateFailure === "function" && + typeof value.writeSandboxConfigSyncFile === "function" + ); +} + +const loadedOnboardInternals = require("../dist/lib/onboard"); +const onboardTestInternals = + typeof loadedOnboardInternals === "object" && loadedOnboardInternals !== null + ? loadedOnboardInternals + : null; +if (!isOnboardTestInternals(onboardTestInternals)) { + throw new Error("Expected onboard test internals to expose helper functions"); +} + +const { buildProviderArgs, buildSandboxConfigSyncScript, classifySandboxCreateFailure, @@ -49,10 +158,7 @@ import { writeSandboxConfigSyncFile, findDashboardForwardOwner, formatOnboardConfigSummary, -} from "../dist/lib/onboard"; -import { buildChain, buildControlUiUrls } from "../dist/lib/dashboard-contract"; -import { stageOptimizedSandboxBuildContext } from "../dist/lib/sandbox-build-context"; -import { buildWebSearchDockerConfig } from "../dist/lib/web-search"; +} = onboardTestInternals; describe("onboard helpers", () => { it("classifies sandbox create timeout failures and tracks upload progress", () => { @@ -206,8 +312,8 @@ describe("onboard helpers", () => { enabledChannels: ["telegram", "slack"], knownPresetNames: known, }); - expect(suggestions.filter((n) => n === "telegram")).toHaveLength(1); - expect(suggestions.filter((n) => n === "slack")).toHaveLength(1); + expect(suggestions.filter((n: string) => n === "telegram")).toHaveLength(1); + expect(suggestions.filter((n: string) => n === "slack")).toHaveLength(1); }); it("drops channel names that are not known presets", () => { @@ -927,6 +1033,9 @@ describe("onboard helpers", () => { const repoRoot = path.resolve(import.meta.dirname, ".."); const v = getBlueprintMinOpenshellVersion(repoRoot); expect(v).not.toBe(null); + if (!v) { + throw new Error("expected min_openshell_version in shipped blueprint"); + } expect(/^[0-9]+\.[0-9]+\.[0-9]+/.test(v)).toBe(true); }); @@ -974,6 +1083,9 @@ describe("onboard helpers", () => { const repoRoot = path.resolve(import.meta.dirname, ".."); const v = getBlueprintMaxOpenshellVersion(repoRoot); expect(v).not.toBe(null); + if (!v) { + throw new Error("expected max_openshell_version in shipped blueprint"); + } expect(/^[0-9]+\.[0-9]+\.[0-9]+/.test(v)).toBe(true); }); @@ -1639,7 +1751,7 @@ const { setupInference } = require(${onboardPath}); }); expect(result.status).toBe(0); - const commands = JSON.parse(result.stdout.trim().split("\n").pop()); + const commands = parseStdoutJson(result.stdout); assert.equal(commands.length, 4); assert.match(commands[0].command, /gateway select nemoclaw/); assert.match(commands[1].command, /provider get/); @@ -1884,7 +1996,7 @@ const { setupInference } = require(${onboardPath}); }); assert.equal(result.status, 0, result.stderr); - const commands = JSON.parse(result.stdout.trim().split("\n").pop()); + const commands = parseStdoutJson(result.stdout); assert.equal(commands.length, 4); assert.match(commands[0].command, /gateway select nemoclaw/); assert.match(commands[1].command, /provider get/); @@ -1958,7 +2070,7 @@ const { setupInference } = require(${onboardPath}); }); assert.equal(result.status, 0, result.stderr); - const commands = JSON.parse(result.stdout.trim().split("\n").pop()); + const commands = parseStdoutJson(result.stdout); assert.equal(commands.length, 4); assert.match(commands[0].command, /gateway select nemoclaw/); assert.match(commands[1].command, /provider get/); @@ -2043,12 +2155,16 @@ const { setupInference } = require(${onboardPath}); }); assert.equal(result.status, 0, result.stderr); - const payload = JSON.parse(result.stdout.trim().split("\n").pop()); + const payload = parseStdoutJson<{ + key: string; + inferenceSetCalls: number; + commands: CommandEntry[]; + }>(result.stdout); assert.equal(payload.key, "sk-good"); assert.equal(payload.inferenceSetCalls, 2); const providerEnvs = payload.commands - .filter((entry) => entry.command.includes("provider")) - .map((entry) => entry.env && entry.env.OPENAI_API_KEY) + .filter((entry: CommandEntry) => entry.command.includes("provider")) + .map((entry: CommandEntry) => entry.env && entry.env.OPENAI_API_KEY) .filter(Boolean); assert.deepEqual(providerEnvs, ["sk-bad", "sk-good"]); }); @@ -2111,10 +2227,14 @@ const { setupInference } = require(${onboardPath}); }); assert.equal(result.status, 0, result.stderr); - const payload = JSON.parse(result.stdout.trim().split("\n").pop()); + const payload = parseStdoutJson<{ + result: { retry: "selection" }; + commands: CommandEntry[]; + }>(result.stdout); assert.deepEqual(payload.result, { retry: "selection" }); assert.equal( - payload.commands.filter((entry) => entry.command.includes("inference set")).length, + payload.commands.filter((entry: CommandEntry) => entry.command.includes("inference set")) + .length, 1, ); }); @@ -2172,7 +2292,7 @@ const { setupInference } = require(${onboardPath}); assert.match( source, - /startRecordedStep\("sandbox", \{ sandboxName, provider, model \}\);\s*selectedMessagingChannels = await setupMessagingChannels\(\);\s*onboardSession\.updateSession\(\(current\) => \{\s*current\.messagingChannels = selectedMessagingChannels;\s*return current;\s*\}\);\s*sandboxName = await createSandbox\(\s*gpu,\s*model,\s*provider,\s*preferredInferenceApi,\s*sandboxName,\s*nextWebSearchConfig,\s*selectedMessagingChannels,\s*fromDockerfile,\s*agent,\s*dangerouslySkipPermissions,\s*\);/, + /startRecordedStep\("sandbox", \{ sandboxName, provider, model \}\);\s*selectedMessagingChannels = await setupMessagingChannels\(\);\s*onboardSession\.updateSession\(\(current[^)]*\) => \{\s*current\.messagingChannels = selectedMessagingChannels;\s*return current;\s*\}\);[\s\S]*?sandboxName = await createSandbox\(\s*gpu,\s*model,\s*provider,\s*preferredInferenceApi,\s*sandboxName,\s*nextWebSearchConfig,\s*selectedMessagingChannels,\s*fromDockerfile,\s*agent,\s*dangerouslySkipPermissions,\s*\);/, ); }); @@ -2182,8 +2302,8 @@ const { setupInference } = require(${onboardPath}); "utf-8", ); - assert.match(source, /const ONBOARD_STEP_INDEX = \{/); - assert.match(source, /function skippedStepMessage\(stepName, detail, reason = "resume"\)/); + assert.match(source, /const ONBOARD_STEP_INDEX(?::[^=]+)? = \{/); + assert.match(source, /function skippedStepMessage\([\s\S]*?reason[^=]*= "resume"[\s\S]*?\)/); assert.match(source, /step\(stepInfo\.number, 8, stepInfo\.title\);/); assert.match(source, /skippedStepMessage\("openclaw", sandboxName\)/); assert.match( @@ -2295,11 +2415,16 @@ const { setupInference } = require(${onboardPath}); }); assert.equal(result.status, 0, result.stderr); - const payload = JSON.parse(result.stdout.trim().split("\n").pop()); + const payload = parseStdoutJson<{ + openai: string; + commands: CommandEntry[]; + }>(result.stdout); assert.equal(payload.openai, "sk-stored-secret"); // commands[0]=gateway select, [1]=provider get, [2]=provider update - assert.equal(payload.commands[2].env.OPENAI_API_KEY, "sk-stored-secret"); - assert.doesNotMatch(payload.commands[2].command, /sk-stored-secret/); + const providerUpdate = payload.commands[2]; + assert.ok(providerUpdate, "expected provider update command"); + assert.equal(providerUpdate.env?.OPENAI_API_KEY, "sk-stored-secret"); + assert.doesNotMatch(providerUpdate.command, /sk-stored-secret/); }); it("drops stale local sandbox registry entries when the live sandbox is gone", () => { @@ -2442,7 +2567,7 @@ const { createSandbox } = require(${onboardPath}); assert.ok(payloadLine, `expected JSON payload in stdout:\n${result.stdout}`); const payload = JSON.parse(payloadLine); assert.equal(payload.sandboxName, "my-assistant"); - const createCommand = payload.commands.find((entry) => + const createCommand = payload.commands.find((entry: CommandEntry) => entry.command.includes("sandbox create"), ); assert.ok(createCommand, "expected sandbox create command"); @@ -2454,7 +2579,7 @@ const { createSandbox } = require(${onboardPath}); assert.doesNotMatch(createCommand.command, /SLACK_BOT_TOKEN=/); assert.ok( payload.commands.some( - (entry) => + (entry: CommandEntry) => entry.command.includes("forward start --background 18789 my-assistant") || entry.command.includes("forward start --background 0.0.0.0:18789 my-assistant"), ), @@ -2542,9 +2667,9 @@ const { createSandbox } = require(${onboardPath}); }); assert.equal(result.status, 0, result.stderr); - const commands = JSON.parse(result.stdout.trim().split("\n").pop()); + const commands = parseStdoutJson(result.stdout); assert.ok( - commands.some((entry) => + commands.some((entry: CommandEntry) => entry.command.includes("forward start --background 0.0.0.0:18789 my-assistant"), ), "expected remote dashboard forward target", @@ -2650,7 +2775,7 @@ const { createSandbox } = require(${onboardPath}); .find((line) => line.startsWith("{") && line.endsWith("}")); assert.ok(payloadLine, `expected JSON payload in stdout:\n${result.stdout}`); const payload = JSON.parse(payloadLine); - const createCommand = payload.commands.find((entry) => + const createCommand = payload.commands.find((entry: CommandEntry) => entry.command.includes("sandbox create"), ); assert.ok(createCommand, "expected sandbox create command"); @@ -2661,18 +2786,18 @@ const { createSandbox } = require(${onboardPath}); // Forward must use same-port mapping (openshell does not support asymmetric) assert.ok( payload.commands.some( - (entry) => + (entry: CommandEntry) => entry.command.includes("forward start --background 19000 my-assistant") || entry.command.includes("forward start --background 0.0.0.0:19000 my-assistant"), ), "expected dashboard forward for port 19000", ); assert.ok( - !payload.commands.some((entry) => entry.command.includes("19000:18789")), + !payload.commands.some((entry: CommandEntry) => entry.command.includes("19000:18789")), "forward must not use asymmetric 19000:18789 mapping", ); assert.ok( - !payload.commands.some((entry) => entry.command.includes("19000:19000")), + !payload.commands.some((entry: CommandEntry) => entry.command.includes("19000:19000")), "forward must not use port:port form (openshell does not support it)", ); }); @@ -2778,29 +2903,31 @@ const { createSandbox } = require(${onboardPath}); const payload = JSON.parse(payloadLine); // Verify providers were created with the right credential keys - const providerCommands = payload.commands.filter((e) => + const providerCommands = payload.commands.filter((e: CommandEntry) => e.command.includes("provider create"), ); - const discordProvider = providerCommands.find((e) => + const discordProvider = providerCommands.find((e: CommandEntry) => e.command.includes("my-assistant-discord-bridge"), ); assert.ok(discordProvider, "expected my-assistant-discord-bridge provider create command"); assert.match(discordProvider.command, /--credential DISCORD_BOT_TOKEN/); - const slackProvider = providerCommands.find((e) => + const slackProvider = providerCommands.find((e: CommandEntry) => e.command.includes("my-assistant-slack-bridge"), ); assert.ok(slackProvider, "expected my-assistant-slack-bridge provider create command"); assert.match(slackProvider.command, /--credential SLACK_BOT_TOKEN/); - const telegramProvider = providerCommands.find((e) => + const telegramProvider = providerCommands.find((e: CommandEntry) => e.command.includes("my-assistant-telegram-bridge"), ); assert.ok(telegramProvider, "expected my-assistant-telegram-bridge provider create command"); assert.match(telegramProvider.command, /--credential TELEGRAM_BOT_TOKEN/); // Verify sandbox create includes --provider flags for all three - const createCommand = payload.commands.find((e) => e.command.includes("sandbox create")); + const createCommand = payload.commands.find((e: CommandEntry) => + e.command.includes("sandbox create"), + ); assert.ok(createCommand, "expected sandbox create command"); assert.match(createCommand.command, /--provider my-assistant-discord-bridge/); assert.match(createCommand.command, /--provider my-assistant-slack-bridge/); @@ -3015,26 +3142,28 @@ const { createSandbox } = require(${onboardPath}); assert.equal(payload.sandboxName, "my-assistant", "should reuse existing sandbox"); assert.ok( - payload.commands.every((entry) => !entry.command.includes("sandbox create")), + payload.commands.every((entry: CommandEntry) => !entry.command.includes("sandbox create")), "should NOT recreate sandbox when providers already exist in gateway", ); assert.ok( - payload.commands.every((entry) => !entry.command.includes("sandbox delete")), + payload.commands.every((entry: CommandEntry) => !entry.command.includes("sandbox delete")), "should NOT delete sandbox when providers already exist in gateway", ); // Providers should still be upserted on reuse (credential refresh). // Since the mock reports providers as existing (run returns status 0), // upsertProvider issues 'update' rather than 'create'. - const providerUpserts = payload.commands.filter((entry) => + const providerUpserts = payload.commands.filter((entry: CommandEntry) => entry.command.includes("provider update"), ); assert.ok( - providerUpserts.some((e) => e.command.includes("my-assistant-discord-bridge")), + providerUpserts.some((e: CommandEntry) => + e.command.includes("my-assistant-discord-bridge"), + ), "should upsert discord provider on reuse to refresh credentials", ); assert.ok( - providerUpserts.some((e) => e.command.includes("my-assistant-slack-bridge")), + providerUpserts.some((e: CommandEntry) => e.command.includes("my-assistant-slack-bridge")), "should upsert slack provider on reuse to refresh credentials", ); }, @@ -3095,7 +3224,7 @@ const { createSandbox } = require(${onboardPath}); `; fs.writeFileSync(scriptPath, script); - const env = { + const env: Record = { ...process.env, HOME: tmpDir, PATH: `${fakeBin}:${process.env.PATH || ""}`, @@ -3212,11 +3341,11 @@ const { createSandbox } = require(${onboardPath}); const payload = JSON.parse(payloadLine); assert.ok( - payload.commands.some((entry) => entry.command.includes("sandbox delete")), + payload.commands.some((entry: CommandEntry) => entry.command.includes("sandbox delete")), "should delete existing sandbox when --recreate-sandbox is set", ); assert.ok( - payload.commands.some((entry) => entry.command.includes("sandbox create")), + payload.commands.some((entry: CommandEntry) => entry.command.includes("sandbox create")), "should create a new sandbox when --recreate-sandbox is set", ); }, @@ -3424,7 +3553,7 @@ const { createSandbox } = require(${onboardPath}); fs.writeFileSync(scriptPath, script); // Run WITHOUT NEMOCLAW_NON_INTERACTIVE to exercise interactive path - const env = { + const env: Record = { ...process.env, HOME: tmpDir, PATH: `${fakeBin}:${process.env.PATH || ""}`, @@ -3449,11 +3578,11 @@ const { createSandbox } = require(${onboardPath}); assert.equal(payload.sandboxName, "my-assistant", "should reuse when user answers y"); assert.ok( - payload.commands.every((entry) => !entry.command.includes("sandbox create")), + payload.commands.every((entry: CommandEntry) => !entry.command.includes("sandbox create")), "should NOT recreate sandbox when user chooses to reuse", ); assert.ok( - payload.commands.every((entry) => !entry.command.includes("sandbox delete")), + payload.commands.every((entry: CommandEntry) => !entry.command.includes("sandbox delete")), "should NOT delete sandbox when user chooses to reuse", ); assert.ok( @@ -3561,7 +3690,7 @@ const { createSandbox } = require(${onboardPath}); fs.writeFileSync(scriptPath, script); // Run WITHOUT NEMOCLAW_NON_INTERACTIVE to exercise interactive path - const env = { + const env: Record = { ...process.env, HOME: tmpDir, PATH: `${fakeBin}:${process.env.PATH || ""}`, @@ -3585,11 +3714,15 @@ const { createSandbox } = require(${onboardPath}); const payload = JSON.parse(payloadLine); assert.ok( - payload.commands.some((entry) => /sandbox.*delete/.test(String(entry.command))), + payload.commands.some((entry: CommandEntry) => + /sandbox.*delete/.test(String(entry.command)), + ), "should delete existing sandbox when user confirms recreate", ); assert.ok( - payload.commands.some((entry) => /sandbox.*create/.test(String(entry.command))), + payload.commands.some((entry: CommandEntry) => + /sandbox.*create/.test(String(entry.command)), + ), "should create a new sandbox when user confirms recreate", ); assert.ok( @@ -3692,7 +3825,7 @@ const { createSandbox } = require(${onboardPath}); fs.writeFileSync(scriptPath, script); // Run WITHOUT NEMOCLAW_NON_INTERACTIVE to exercise interactive path - const env = { + const env: Record = { ...process.env, HOME: tmpDir, PATH: `${fakeBin}:${process.env.PATH || ""}`, @@ -3716,11 +3849,11 @@ const { createSandbox } = require(${onboardPath}); const payload = JSON.parse(payloadLine); assert.ok( - payload.commands.some((entry) => entry.command.includes("sandbox delete")), + payload.commands.some((entry: CommandEntry) => entry.command.includes("sandbox delete")), "should delete not-ready sandbox after user confirms", ); assert.ok( - payload.commands.some((entry) => entry.command.includes("sandbox create")), + payload.commands.some((entry: CommandEntry) => entry.command.includes("sandbox create")), "should recreate sandbox when existing one is not ready", ); assert.ok(result.stdout.includes("not ready"), "should mention sandbox is not ready"); @@ -3731,7 +3864,10 @@ const { createSandbox } = require(${onboardPath}); path.join(import.meta.dirname, "..", "src", "lib", "onboard.ts"), "utf-8", ); - assert.match(source, /const selectionDrift = getSelectionDrift\(sandboxName, provider, model\);/); + assert.match( + source, + /const selectionDrift = getSelectionDrift\(sandboxName, provider, model\);/, + ); assert.match( source, /const confirmedSelectionDrift = selectionDrift\.changed && !selectionDrift\.unknown;/, @@ -3791,7 +3927,10 @@ console.log(JSON.stringify({ result, commands })); }); assert.equal(result.status, 0, result.stderr); - const payload = JSON.parse(result.stdout.trim().split("\n").pop()); + const payload = parseStdoutJson<{ + result: { ok: true }; + commands: string[]; + }>(result.stdout); assert.deepEqual(payload.result, { ok: true }); assert.equal(payload.commands.length, 2); assert.match(payload.commands[0], /provider get/); @@ -3875,7 +4014,10 @@ console.log(JSON.stringify({ result, commands })); }); assert.equal(result.status, 0, result.stderr); - const payload = JSON.parse(result.stdout.trim().split("\n").pop()); + const payload = parseStdoutJson<{ + result: { ok: true }; + commands: string[]; + }>(result.stdout); assert.deepEqual(payload.result, { ok: true }); assert.equal(payload.commands.length, 2); assert.match(payload.commands[0], /provider get/); @@ -3920,7 +4062,11 @@ console.log(JSON.stringify(result)); }); assert.equal(result.status, 0, result.stderr); - const payload = JSON.parse(result.stdout.trim().split("\n").pop()); + const payload = parseStdoutJson<{ + ok: false; + status: number; + message: string; + }>(result.stdout); assert.equal(payload.ok, false); assert.equal(payload.status, 1); assert.match(payload.message, /gateway unreachable/); @@ -3957,7 +4103,7 @@ console.log(JSON.stringify({ exists: providerExistsInGateway("discord-bridge") } }); assert.equal(result.status, 0, result.stderr); - const payload = JSON.parse(result.stdout.trim().split("\n").pop()); + const payload = parseStdoutJson<{ exists: boolean }>(result.stdout); assert.equal(payload.exists, true); }); @@ -4006,7 +4152,12 @@ console.log(JSON.stringify({ }); assert.equal(result.status, 0, result.stderr); - const payload = JSON.parse(result.stdout.trim().split("\n").pop()); + const payload = parseStdoutJson<{ + nullResult: null; + hydrated: string; + envSet: string; + missing: null; + }>(result.stdout); assert.equal(payload.nullResult, null, "should return null for null input"); assert.equal( payload.hydrated, @@ -4052,7 +4203,7 @@ console.log(JSON.stringify({ exists: providerExistsInGateway("nonexistent") })); }); assert.equal(result.status, 0, result.stderr); - const payload = JSON.parse(result.stdout.trim().split("\n").pop()); + const payload = parseStdoutJson<{ exists: boolean }>(result.stdout); assert.equal(payload.exists, false); }); @@ -4237,22 +4388,25 @@ const { createSandbox } = require(${onboardPath}); }); assert.equal(result.status, 0, result.stderr); - const payload = JSON.parse(result.stdout.trim().split("\n").pop()); + const payload = parseStdoutJson<{ + sandboxName: string; + commands: CommandEntry[]; + }>(result.stdout); assert.equal(payload.sandboxName, "my-assistant"); assert.ok( - payload.commands.some((entry) => + payload.commands.some((entry: CommandEntry) => entry.command.includes("forward start --background 0.0.0.0:18789 my-assistant"), ), "expected dashboard forward restore on sandbox reuse", ); assert.ok( - payload.commands.every((entry) => !entry.command.includes("sandbox create")), + payload.commands.every((entry: CommandEntry) => !entry.command.includes("sandbox create")), "did not expect sandbox create when reusing existing sandbox", ); }); it("prints resume guidance when sandbox image upload times out", () => { - const errors = []; + const errors: string[] = []; const originalError = console.error; console.error = (...args) => errors.push(args.join(" ")); try { @@ -4278,7 +4432,7 @@ const { createSandbox } = require(${onboardPath}); }); it("prints resume guidance when sandbox image upload resets after transfer progress", () => { - const errors = []; + const errors: string[] = []; const originalError = console.error; console.error = (...args) => errors.push(args.join(" ")); try { @@ -4369,7 +4523,7 @@ const { setupInference } = require(${onboardPath}); }); assert.equal(result.status, 0, result.stderr); - const commands = JSON.parse(result.stdout.trim().split("\n").pop()); + const commands = parseStdoutJson(result.stdout); // gateway select + provider get + provider update + inference set assert.equal(commands.length, 4); }); @@ -4440,7 +4594,7 @@ const { setupInference } = require(${onboardPath}); }); assert.equal(result.status, 0, result.stderr); - const commands = JSON.parse(result.stdout.trim().split("\n").pop()); + const commands = parseStdoutJson(result.stdout); // gateway select + provider get + provider update + inference set assert.equal(commands.length, 4); }); @@ -4547,27 +4701,29 @@ const { createSandbox } = require(${onboardPath}); const payload = JSON.parse(payloadLine); // Only telegram provider should be created - const providerCommands = payload.commands.filter((e) => + const providerCommands = payload.commands.filter((e: CommandEntry) => e.command.includes("provider create"), ); - const telegramProvider = providerCommands.find((e) => + const telegramProvider = providerCommands.find((e: CommandEntry) => e.command.includes("my-assistant-telegram-bridge"), ); assert.ok(telegramProvider, "expected telegram provider to be created"); // Discord and slack providers should NOT be created - const discordProvider = providerCommands.find((e) => + const discordProvider = providerCommands.find((e: CommandEntry) => e.command.includes("my-assistant-discord-bridge"), ); assert.ok(!discordProvider, "discord provider should be filtered out"); - const slackProvider = providerCommands.find((e) => + const slackProvider = providerCommands.find((e: CommandEntry) => e.command.includes("my-assistant-slack-bridge"), ); assert.ok(!slackProvider, "slack provider should be filtered out"); // Sandbox create should only have the telegram --provider flag - const createCommand = payload.commands.find((e) => e.command.includes("sandbox create")); + const createCommand = payload.commands.find((e: CommandEntry) => + e.command.includes("sandbox create"), + ); assert.ok(createCommand, "expected sandbox create command"); assert.match(createCommand.command, /--provider my-assistant-telegram-bridge/); assert.doesNotMatch(createCommand.command, /my-assistant-discord-bridge/); @@ -4675,7 +4831,7 @@ const { createSandbox } = require(${onboardPath}); const payload = JSON.parse(payloadLine); // No messaging providers should be created at all - const providerCommands = payload.commands.filter((e) => + const providerCommands = payload.commands.filter((e: CommandEntry) => e.command.includes("provider create"), ); assert.equal( @@ -4685,7 +4841,9 @@ const { createSandbox } = require(${onboardPath}); ); // Sandbox create should have no --provider flags for messaging bridges - const createCommand = payload.commands.find((e) => e.command.includes("sandbox create")); + const createCommand = payload.commands.find((e: CommandEntry) => + e.command.includes("sandbox create"), + ); assert.ok(createCommand, "expected sandbox create command"); assert.doesNotMatch(createCommand.command, /discord-bridge/); assert.doesNotMatch(createCommand.command, /slack-bridge/); @@ -4758,8 +4916,7 @@ const { setupMessagingChannels } = require(${onboardPath}); }); assert.equal(result.status, 0, result.stderr); - const outputLine = result.stdout.trim().split("\n").pop(); - const channels = JSON.parse(outputLine); + const channels = parseStdoutJson(result.stdout); // Should return only the channels that have tokens set assert.ok(Array.isArray(channels), "expected an array return value"); @@ -4824,8 +4981,7 @@ const { setupMessagingChannels } = require(${onboardPath}); }); assert.equal(result.status, 0, result.stderr); - const outputLine = result.stdout.trim().split("\n").pop(); - const channels = JSON.parse(outputLine); + const channels = parseStdoutJson(result.stdout); assert.ok(Array.isArray(channels), "expected an array return value"); assert.equal(channels.length, 0, "expected empty array when no tokens are set"); diff --git a/test/openclaw-data-ownership.test.ts b/test/openclaw-data-ownership.test.ts index 63c8b10cbad..953a2231b3d 100644 --- a/test/openclaw-data-ownership.test.ts +++ b/test/openclaw-data-ownership.test.ts @@ -1,4 +1,3 @@ -// @ts-nocheck // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 @@ -15,7 +14,7 @@ const scriptContent = fs.readFileSync(SCRIPT, "utf-8"); // Pull ensure_identity_symlink and fix_openclaw_data_ownership function bodies // from the script. They are defined as shell functions we can source directly. -function extractFunction(name) { +function extractFunction(name: string): string { const re = new RegExp(`^ ${name}\\(\\) \\{$`, "m"); const start = scriptContent.search(re); if (start === -1) throw new Error(`Function ${name} not found in ${SCRIPT}`); @@ -38,7 +37,7 @@ function extractFunction(name) { const ENSURE_IDENTITY_SYMLINK = extractFunction("ensure_identity_symlink"); const FIX_OPENCLAW_DATA_OWNERSHIP = extractFunction("fix_openclaw_data_ownership"); -function runShell(script, env = {}) { +function runShell(script: string, env: Record = {}) { return spawnSync("bash", ["-euo", "pipefail", "-c", script], { cwd: path.join(import.meta.dirname, ".."), encoding: "utf-8", @@ -48,7 +47,7 @@ function runShell(script, env = {}) { } describe("ensure_identity_symlink", () => { - let tmpDir; + let tmpDir: string; beforeEach(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-identity-")); @@ -145,7 +144,7 @@ describe("ensure_identity_symlink", () => { }); describe("fix_openclaw_data_ownership", () => { - let tmpDir; + let tmpDir: string; beforeEach(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-ownership-")); diff --git a/test/platform.test.ts b/test/platform.test.ts index 4a53c363655..20498098bc9 100644 --- a/test/platform.test.ts +++ b/test/platform.test.ts @@ -1,4 +1,3 @@ -// @ts-nocheck // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 @@ -86,7 +85,7 @@ describe("platform helpers", () => { it("finds the first available Colima socket", () => { const home = "/tmp/test-home"; const sockets = new Set([path.join(home, ".config/colima/default/docker.sock")]); - const existsSync = (socketPath) => sockets.has(socketPath); + const existsSync = (socketPath: string) => sockets.has(socketPath); expect(findColimaDockerSocket({ home, existsSync })).toBe( path.join(home, ".config/colima/default/docker.sock"), @@ -116,7 +115,7 @@ describe("platform helpers", () => { path.join(home, ".colima/default/docker.sock"), path.join(home, ".docker/run/docker.sock"), ]); - const existsSync = (socketPath) => sockets.has(socketPath); + const existsSync = (socketPath: string) => sockets.has(socketPath); expect(detectDockerHost({ env: {}, platform: "darwin", home, existsSync })).toEqual({ dockerHost: `unix://${path.join(home, ".colima/default/docker.sock")}`, @@ -128,7 +127,7 @@ describe("platform helpers", () => { it("detects Docker Desktop when Colima is absent", () => { const home = "/tmp/test-home"; const socketPath = path.join(home, ".docker/run/docker.sock"); - const existsSync = (candidate) => candidate === socketPath; + const existsSync = (candidate: string) => candidate === socketPath; expect(detectDockerHost({ env: {}, platform: "darwin", home, existsSync })).toEqual({ dockerHost: `unix://${socketPath}`, @@ -187,7 +186,7 @@ describe("platform helpers", () => { it("detects Podman socket on macOS when Colima is absent", () => { const home = "/tmp/test-home"; const podmanSocket = path.join(home, ".local/share/containers/podman/machine/podman.sock"); - const existsSync = (candidate) => candidate === podmanSocket; + const existsSync = (candidate: string) => candidate === podmanSocket; expect(detectDockerHost({ env: {}, platform: "darwin", home, existsSync })).toEqual({ dockerHost: `unix://${podmanSocket}`, @@ -201,7 +200,7 @@ describe("platform helpers", () => { const colimaSocket = path.join(home, ".colima/default/docker.sock"); const podmanSocket = path.join(home, ".local/share/containers/podman/machine/podman.sock"); const sockets = new Set([colimaSocket, podmanSocket]); - const existsSync = (candidate) => sockets.has(candidate); + const existsSync = (candidate: string) => sockets.has(candidate); expect(detectDockerHost({ env: {}, platform: "darwin", home, existsSync })).toEqual({ dockerHost: `unix://${colimaSocket}`, diff --git a/test/policies.test.ts b/test/policies.test.ts index 2069e7424ef..ae902efbc1d 100644 --- a/test/policies.test.ts +++ b/test/policies.test.ts @@ -1,4 +1,3 @@ -// @ts-nocheck // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 @@ -20,7 +19,30 @@ const SELECT_FROM_LIST_ITEMS = [ { name: "pypi", description: "Python Package Index (PyPI) access" }, ]; -function runPolicyAdd(confirmAnswer, extraArgs = [], envOverrides = {}) { +type PolicyCall = { + type: string; + message?: string; + sandboxName?: string; + presetName?: string; +}; + +type AppliedOptions = { + applied?: string[]; +}; + +function requirePresetContent(content: string | null): string { + expect(content).toBeTruthy(); + if (!content) { + throw new Error("Expected preset content to be present"); + } + return content; +} + +function runPolicyAdd( + confirmAnswer: string, + extraArgs: string[] = [], + envOverrides: Record = {}, +) { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-policy-add-")); const scriptPath = path.join(tmpDir, "policy-add-check.js"); const script = String.raw` @@ -65,7 +87,7 @@ setImmediate(() => { }); } -function runSelectFromList(input, { applied = [] } = {}) { +function runSelectFromList(input: string, { applied = [] }: AppliedOptions = {}) { const script = String.raw` const { selectFromList } = require(${POLICIES_PATH}); const items = JSON.parse(process.env.NEMOCLAW_TEST_ITEMS); @@ -134,8 +156,7 @@ describe("policies", () => { describe("loadPreset", () => { it("loads existing preset", () => { - const content = policies.loadPreset("outlook"); - expect(content).toBeTruthy(); + const content = requirePresetContent(policies.loadPreset("outlook")); expect(content.includes("network_policies:")).toBeTruthy(); }); @@ -150,14 +171,14 @@ describe("policies", () => { it("includes /usr/bin/node in communication presets", () => { for (const preset of ["discord", "slack", "telegram"]) { - const content = policies.loadPreset(preset); + const content = requirePresetContent(policies.loadPreset(preset)); expect(content).toContain("/usr/local/bin/node"); expect(content).toContain("/usr/bin/node"); } }); it("local-inference preset targets host.openshell.internal on Ollama, proxy, and vLLM ports", () => { - const content = policies.loadPreset("local-inference"); + const content = requirePresetContent(policies.loadPreset("local-inference")); expect(content).toContain("host.openshell.internal"); expect(content).toContain("port: 11434"); expect(content).toContain("port: 11435"); @@ -165,7 +186,7 @@ describe("policies", () => { }); it("local-inference preset includes openclaw, claude, and common tool binaries", () => { - const content = policies.loadPreset("local-inference"); + const content = requirePresetContent(policies.loadPreset("local-inference")); expect(content).toContain("/usr/local/bin/openclaw"); expect(content).toContain("/usr/local/bin/claude"); // node, curl, and python3 are needed for direct inference access (#2199) @@ -177,7 +198,7 @@ describe("policies", () => { describe("getPresetEndpoints", () => { it("extracts hosts from outlook preset", () => { - const content = policies.loadPreset("outlook"); + const content = requirePresetContent(policies.loadPreset("outlook")); const hosts = policies.getPresetEndpoints(content); expect(hosts.includes("graph.microsoft.com")).toBeTruthy(); expect(hosts.includes("login.microsoftonline.com")).toBeTruthy(); @@ -186,14 +207,14 @@ describe("policies", () => { }); it("extracts hosts from telegram preset", () => { - const content = policies.loadPreset("telegram"); + const content = requirePresetContent(policies.loadPreset("telegram")); const hosts = policies.getPresetEndpoints(content); expect(hosts).toEqual(["api.telegram.org"]); }); it("every preset has at least one endpoint", () => { for (const p of policies.listPresets()) { - const content = policies.loadPreset(p.name); + const content = requirePresetContent(policies.loadPreset(p.name)); const hosts = policies.getPresetEndpoints(content); expect(hosts.length > 0).toBeTruthy(); } @@ -220,7 +241,9 @@ describe("policies", () => { } catch { /* applyPreset may throw if sandbox not running — we only care about the log */ } - const messages = logSpy.mock.calls.map((c) => c[0]); + const messages = logSpy.mock.calls.map((call) => + typeof call[0] === "string" ? call[0] : undefined, + ); expect( messages.some((m) => typeof m === "string" && m.includes("Widening sandbox egress")), ).toBe(true); @@ -237,7 +260,9 @@ describe("policies", () => { try { policies.applyPreset("test-sandbox", "nonexistent"); - const messages = logSpy.mock.calls.map((c) => c[0]); + const messages = logSpy.mock.calls.map((call) => + typeof call[0] === "string" ? call[0] : undefined, + ); expect( messages.some((m) => typeof m === "string" && m.includes("Widening sandbox egress")), ).toBe(false); @@ -263,7 +288,9 @@ describe("policies", () => { } catch { /* applyPreset may throw if sandbox not running */ } - const messages = logSpy.mock.calls.map((c) => c[0]); + const messages = logSpy.mock.calls.map((call) => + typeof call[0] === "string" ? call[0] : undefined, + ); expect( messages.some((m) => typeof m === "string" && m.includes("Widening sandbox egress")), ).toBe(false); @@ -279,7 +306,15 @@ describe("policies", () => { describe("buildPolicySetCommand", () => { it("returns an argv array with sandbox name as a separate element", () => { const cmd = policies.buildPolicySetCommand("/tmp/policy.yaml", "my-assistant"); - expect(cmd).toEqual(["openshell", "policy", "set", "--policy", "/tmp/policy.yaml", "--wait", "my-assistant"]); + expect(cmd).toEqual([ + "openshell", + "policy", + "set", + "--policy", + "/tmp/policy.yaml", + "--wait", + "my-assistant", + ]); }); it("preserves shell metacharacters literally in sandbox name (no injection)", () => { @@ -299,7 +334,15 @@ describe("policies", () => { process.env.NEMOCLAW_OPENSHELL_BIN = "/tmp/fake path/openshell"; try { const cmd = policies.buildPolicySetCommand("/tmp/policy.yaml", "my-assistant"); - expect(cmd).toEqual(["/tmp/fake path/openshell", "policy", "set", "--policy", "/tmp/policy.yaml", "--wait", "my-assistant"]); + expect(cmd).toEqual([ + "/tmp/fake path/openshell", + "policy", + "set", + "--policy", + "/tmp/policy.yaml", + "--wait", + "my-assistant", + ]); } finally { delete process.env.NEMOCLAW_OPENSHELL_BIN; } @@ -357,7 +400,7 @@ describe("policies", () => { it("works on every real preset file", () => { for (const p of policies.listPresets()) { - const content = policies.loadPreset(p.name); + const content = requirePresetContent(policies.loadPreset(p.name)); const entries = policies.extractPresetEntries(content); expect(entries).toBeTruthy(); expect(entries).toContain("endpoints:"); @@ -569,7 +612,7 @@ describe("policies", () => { it("no preset has rules at NetworkPolicyRuleDef level", () => { // rules must be inside endpoints, not as sibling of endpoints/binaries for (const p of policies.listPresets()) { - const content = policies.loadPreset(p.name); + const content = requirePresetContent(policies.loadPreset(p.name)); const lines = content.split("\n"); for (let i = 0; i < lines.length; i++) { const line = lines[i]; @@ -586,7 +629,7 @@ describe("policies", () => { it("every preset has network_policies section", () => { for (const p of policies.listPresets()) { - const content = policies.loadPreset(p.name); + const content = requirePresetContent(policies.loadPreset(p.name)); expect(content.includes("network_policies:")).toBeTruthy(); } }); @@ -597,7 +640,7 @@ describe("policies", () => { // PUT/POST (publish, exfiltrate). Restrict via rest rules. const packagePresets = ["pypi", "npm"]; for (const name of packagePresets) { - const content = policies.loadPreset(name); + const content = requirePresetContent(policies.loadPreset(name)); expect(content).toBeTruthy(); expect(content.includes("access: full")).toBe(false); expect(content.includes("protocol: rest")).toBe(true); @@ -612,7 +655,7 @@ describe("policies", () => { it("outlook preset allows PATCH on graph.microsoft.com", () => { // Microsoft Graph API uses PATCH for common email and calendar operations: // marking messages as read, updating drafts, modifying calendar events. - const content = policies.loadPreset("outlook"); + const content = requirePresetContent(policies.loadPreset("outlook")); const graphSection = content.split("host: graph.microsoft.com")[1]?.split("- host:")[0] ?? ""; expect(graphSection).toContain("method: PATCH"); }); @@ -625,14 +668,14 @@ describe("policies", () => { ]; for (const { preset, pattern } of cases) { - const content = policies.loadPreset(preset); + const content = requirePresetContent(policies.loadPreset(preset)); expect(content).toBeTruthy(); expect(content).toMatch(pattern); } }); it("telegram REST preset uses tls: terminate for L7 proxy", () => { - const content = policies.loadPreset("telegram"); + const content = requirePresetContent(policies.loadPreset("telegram")); expect(content).toBeTruthy(); expect(content).toMatch( /host:\s*api\.telegram\.org[\s\S]*?protocol:\s*rest[\s\S]*?tls:\s*terminate/, @@ -642,7 +685,7 @@ describe("policies", () => { it("pypi preset allows HEAD for pip lazy-wheel metadata checks", () => { // pip and uv use HEAD requests for lazy wheel downloads and // range-request support. GET-only would break pip install. - const content = policies.loadPreset("pypi"); + const content = requirePresetContent(policies.loadPreset("pypi")); expect(content.includes("method: HEAD")).toBe(true); }); @@ -654,7 +697,7 @@ describe("policies", () => { { name: "npm", expectedBinary: "npm" }, ]; for (const { name, expectedBinary } of packagePresets) { - const content = policies.loadPreset(name); + const content = requirePresetContent(policies.loadPreset(name)); expect(content).toBeTruthy(); expect(content.includes("binaries:")).toBe(true); expect(content.includes(expectedBinary)).toBe(true); @@ -792,8 +835,7 @@ describe("policies", () => { }); it("returns policy unchanged when network_policies is a legacy array", () => { - const current = - "version: 1\n\nnetwork_policies:\n - host: pypi.org\n allow: true\n"; + const current = "version: 1\n\nnetwork_policies:\n - host: pypi.org\n allow: true\n"; const result = policies.removePresetFromPolicy(current, pypiEntries); expect(result).toContain("pypi.org"); expect(result).toContain("allow: true"); @@ -801,7 +843,7 @@ describe("policies", () => { }); describe("selectForRemoval", () => { - function runSelectForRemoval(input, { applied = [] } = {}) { + function runSelectForRemoval(input: string, { applied = [] }: AppliedOptions = {}) { const script = String.raw` const { selectForRemoval } = require(${POLICIES_PATH}); const items = JSON.parse(process.env.NEMOCLAW_TEST_ITEMS); @@ -902,7 +944,7 @@ selectForRemoval(items, options) type: "prompt", message: " Apply 'pypi' to sandbox 'test-sandbox'? [Y/n]: ", }); - expect(calls.some((call) => call.type === "apply")).toBeFalsy(); + expect(calls.some((call: PolicyCall) => call.type === "apply")).toBeFalsy(); }); it("does not prompt or apply when --dry-run is passed", () => { @@ -910,8 +952,8 @@ selectForRemoval(items, options) expect(result.status).toBe(0); const calls = JSON.parse(result.stdout.split("__CALLS__")[1].trim()); - expect(calls.some((call) => call.type === "prompt")).toBeFalsy(); - expect(calls.some((call) => call.type === "apply")).toBeFalsy(); + expect(calls.some((call: PolicyCall) => call.type === "prompt")).toBeFalsy(); + expect(calls.some((call: PolicyCall) => call.type === "apply")).toBeFalsy(); expect(result.stdout).toMatch(/Endpoints that would be opened: pypi\.org/); expect(result.stdout).toMatch(/--dry-run: no changes applied\./); }); @@ -921,7 +963,7 @@ selectForRemoval(items, options) expect(result.status).toBe(0); const calls = JSON.parse(result.stdout.split("__CALLS__")[1].trim()); - expect(calls.some((call) => call.type === "prompt")).toBeFalsy(); + expect(calls.some((call: PolicyCall) => call.type === "prompt")).toBeFalsy(); expect(calls).toContainEqual({ type: "apply", sandboxName: "test-sandbox", @@ -934,7 +976,7 @@ selectForRemoval(items, options) expect(result.status).toBe(0); const calls = JSON.parse(result.stdout.split("__CALLS__")[1].trim()); - expect(calls.some((call) => call.type === "prompt")).toBeFalsy(); + expect(calls.some((call: PolicyCall) => call.type === "prompt")).toBeFalsy(); expect(calls).toContainEqual({ type: "apply", sandboxName: "test-sandbox", @@ -946,12 +988,18 @@ selectForRemoval(items, options) const result = runPolicyAdd("y", [], { NEMOCLAW_NON_INTERACTIVE: "1" }); expect(result.status).not.toBe(0); - expect(`${result.stdout}${result.stderr}`).toMatch(/Non-interactive mode requires a preset name/); + expect(`${result.stdout}${result.stderr}`).toMatch( + /Non-interactive mode requires a preset name/, + ); }); }); describe("policy-remove confirmation", () => { - function runPolicyRemove(confirmAnswer, extraArgs = [], envOverrides = {}) { + function runPolicyRemove( + confirmAnswer: string, + extraArgs: string[] = [], + envOverrides: Record = {}, + ) { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-policy-remove-")); const scriptPath = path.join(tmpDir, "policy-remove-check.js"); const script = String.raw` @@ -1022,7 +1070,7 @@ setImmediate(() => { type: "prompt", message: " Remove 'pypi' from sandbox 'test-sandbox'? [Y/n]: ", }); - expect(calls.some((call) => call.type === "remove")).toBeFalsy(); + expect(calls.some((call: PolicyCall) => call.type === "remove")).toBeFalsy(); }); it("does not prompt or remove when --dry-run is passed", () => { @@ -1030,8 +1078,8 @@ setImmediate(() => { expect(result.status).toBe(0); const calls = JSON.parse(result.stdout.split("__CALLS__")[1].trim()); - expect(calls.some((call) => call.type === "prompt")).toBeFalsy(); - expect(calls.some((call) => call.type === "remove")).toBeFalsy(); + expect(calls.some((call: PolicyCall) => call.type === "prompt")).toBeFalsy(); + expect(calls.some((call: PolicyCall) => call.type === "remove")).toBeFalsy(); expect(result.stdout).toMatch(/Endpoints that would be removed: pypi\.org/); expect(result.stdout).toMatch(/--dry-run: no changes applied\./); }); @@ -1041,7 +1089,7 @@ setImmediate(() => { expect(result.status).toBe(0); const calls = JSON.parse(result.stdout.split("__CALLS__")[1].trim()); - expect(calls.some((call) => call.type === "prompt")).toBeFalsy(); + expect(calls.some((call: PolicyCall) => call.type === "prompt")).toBeFalsy(); expect(calls).toContainEqual({ type: "remove", sandboxName: "test-sandbox", @@ -1054,7 +1102,7 @@ setImmediate(() => { expect(result.status).toBe(0); const calls = JSON.parse(result.stdout.split("__CALLS__")[1].trim()); - expect(calls.some((call) => call.type === "prompt")).toBeFalsy(); + expect(calls.some((call: PolicyCall) => call.type === "prompt")).toBeFalsy(); expect(calls).toContainEqual({ type: "remove", sandboxName: "test-sandbox", @@ -1066,7 +1114,9 @@ setImmediate(() => { const result = runPolicyRemove("y", [], { NEMOCLAW_NON_INTERACTIVE: "1" }); expect(result.status).not.toBe(0); - expect(`${result.stdout}${result.stderr}`).toMatch(/Non-interactive mode requires a preset name/); + expect(`${result.stdout}${result.stderr}`).toMatch( + /Non-interactive mode requires a preset name/, + ); }); }); }); diff --git a/test/policy-tiers.test.ts b/test/policy-tiers.test.ts index caab8ad1222..7c9f968e918 100644 --- a/test/policy-tiers.test.ts +++ b/test/policy-tiers.test.ts @@ -30,10 +30,39 @@ interface Preset { name: string; } +type TierShape = { + name?: string; + label?: string; + description?: string; + presets?: TierPreset[]; +}; + +function requireTierPreset(value: TierPreset | undefined, name: string): TierPreset { + expect(value).toBeDefined(); + if (!value) { + throw new Error(`Expected preset '${name}' to be present`); + } + return value; +} + +function isTier(value: TierShape | null): value is Tier { + return ( + value !== null && + typeof value.name === "string" && + typeof value.label === "string" && + typeof value.description === "string" && + Array.isArray(value.presets) + ); +} + function mustGetTier(name: string): Tier { const tier = tiers.getTier(name); expect(tier).not.toBeNull(); - return tier as Tier; + const tierObject: TierShape | null = typeof tier === "object" && tier !== null ? tier : null; + if (!isTier(tierObject)) { + throw new Error(`Expected tier '${name}' to be present`); + } + return tierObject; } describe("tiers", () => { @@ -169,10 +198,16 @@ describe("tiers", () => { const resolved: TierPreset[] = tiers.resolveTierPresets("balanced", { overrides: { npm: "read" }, }); - const npm = resolved.find((preset: TierPreset) => preset.name === "npm"); - expect(npm!.access).toBe("read"); - const pypi = resolved.find((preset: TierPreset) => preset.name === "pypi"); - expect(pypi!.access).toBe("read-write"); + const npm = requireTierPreset( + resolved.find((preset: TierPreset) => preset.name === "npm"), + "npm", + ); + expect(npm.access).toBe("read"); + const pypi = requireTierPreset( + resolved.find((preset: TierPreset) => preset.name === "pypi"), + "pypi", + ); + expect(pypi.access).toBe("read-write"); }); it("restricts to selected presets when selected list is provided", () => { diff --git a/test/presets-checkbox.test.ts b/test/presets-checkbox.test.ts index bef188cf2b7..533000eb27d 100644 --- a/test/presets-checkbox.test.ts +++ b/test/presets-checkbox.test.ts @@ -1,4 +1,3 @@ -// @ts-nocheck // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 @@ -10,7 +9,17 @@ const REPO_ROOT = path.join(import.meta.dirname, ".."); const ONBOARD_PATH = JSON.stringify(path.join(REPO_ROOT, "dist", "lib", "onboard.js")); const CREDENTIALS_PATH = JSON.stringify(path.join(REPO_ROOT, "dist", "lib", "credentials.js")); -const SAMPLE_PRESETS = [ +type Preset = { + name: string; + description: string; +}; + +type SelectorOptions = { + presets?: Preset[]; + initialSelected?: string[]; +}; + +const SAMPLE_PRESETS: Preset[] = [ { name: "npm", description: "npm and Yarn registry access" }, { name: "pypi", description: "Python Package Index (PyPI) access" }, { name: "slack", description: "Slack API access" }, @@ -21,9 +30,12 @@ const SAMPLE_PRESETS = [ * The subprocess writes console.log output (preset listing, messages) to * stdout before the final JSON line, so we must look at only the last line. */ -function parseResult(stdout) { +function parseResult(stdout: string): string[] { const lines = stdout.trim().split("\n").filter(Boolean); - return JSON.parse(lines[lines.length - 1]); + const parsed: Array = JSON.parse(lines[lines.length - 1]); + return Array.isArray(parsed) + ? parsed.filter((entry): entry is string => typeof entry === "string") + : []; } /** @@ -34,8 +46,8 @@ function parseResult(stdout) { * user would have typed at the "Select presets" prompt. */ function runCheckboxSelector( - promptResponse, - { presets = SAMPLE_PRESETS, initialSelected = [] } = {}, + promptResponse: string, + { presets = SAMPLE_PRESETS, initialSelected = [] }: SelectorOptions = {}, ) { // Stub credentials.prompt BEFORE requiring onboard so the destructured // binding inside onboard.js picks up the stub at load time. diff --git a/test/rebuild-policy-presets.test.ts b/test/rebuild-policy-presets.test.ts index c2c1ad7ff9b..a2d77b32abc 100644 --- a/test/rebuild-policy-presets.test.ts +++ b/test/rebuild-policy-presets.test.ts @@ -1,4 +1,3 @@ -// @ts-nocheck // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 // @@ -15,6 +14,20 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; const REPO_ROOT = path.join(import.meta.dirname, ".."); +type ManifestWithOptionalPresets = { + version: number; + sandboxName: string; + timestamp: string; + agentType: string; + agentVersion: string | null; + expectedVersion: string | null; + stateDirs: string[]; + writableDir: string; + backupPath: string; + blueprintDigest: string | null; + policyPresets?: string[] | null; +}; + // Import compiled modules from dist/ const sandboxState = await import(path.join(REPO_ROOT, "dist", "lib", "sandbox-state.js")); @@ -22,7 +35,7 @@ describe("rebuild policy preset restoration (#1952)", () => { describe("RebuildManifest policyPresets field", () => { it("manifest interface accepts policyPresets array", () => { // Verify the manifest structure supports policyPresets - const manifest = { + const manifest: ManifestWithOptionalPresets = { version: 1, sandboxName: "test", timestamp: "2026-04-17", @@ -39,7 +52,7 @@ describe("rebuild policy preset restoration (#1952)", () => { }); it("manifest policyPresets defaults to undefined when not set", () => { - const manifest = { + const manifest: ManifestWithOptionalPresets = { version: 1, sandboxName: "test", timestamp: "2026-04-17", @@ -55,7 +68,7 @@ describe("rebuild policy preset restoration (#1952)", () => { }); it("manifest policyPresets can be an empty array", () => { - const manifest = { + const manifest: ManifestWithOptionalPresets = { version: 1, sandboxName: "test", timestamp: "2026-04-17", @@ -73,7 +86,7 @@ describe("rebuild policy preset restoration (#1952)", () => { }); describe("manifest serialization round-trip", () => { - let tmpDir; + let tmpDir: string; beforeEach(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-manifest-test-")); @@ -84,7 +97,7 @@ describe("rebuild policy preset restoration (#1952)", () => { }); it("policyPresets survives JSON write and read", () => { - const manifest = { + const manifest: ManifestWithOptionalPresets = { version: 1, sandboxName: "test-sandbox", timestamp: "2026-04-17T10-00-00-000Z", @@ -107,7 +120,7 @@ describe("rebuild policy preset restoration (#1952)", () => { it("older manifests without policyPresets read as undefined", () => { // Simulate a manifest from before the fix - const oldManifest = { + const oldManifest: ManifestWithOptionalPresets = { version: 1, sandboxName: "test-sandbox", timestamp: "2026-04-01T10-00-00-000Z", diff --git a/test/registry.test.ts b/test/registry.test.ts index 6c8bd031915..ddcfd4b89b4 100644 --- a/test/registry.test.ts +++ b/test/registry.test.ts @@ -1,4 +1,3 @@ -// @ts-nocheck // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 diff --git a/test/repro-2201.test.ts b/test/repro-2201.test.ts index 7847f9fc3c6..1531e532b07 100644 --- a/test/repro-2201.test.ts +++ b/test/repro-2201.test.ts @@ -32,12 +32,16 @@ import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; const REPO_ROOT = path.join(import.meta.dirname, ".."); -const NODE_BIN = path.dirname(process.execPath); // need node on PATH for shebangs +const NODE_BIN = path.dirname(process.execPath); // need node on PATH for shebangs const tmpFixtures: string[] = []; afterEach(() => { for (const dir of tmpFixtures.splice(0)) { - try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* */ } + try { + fs.rmSync(dir, { recursive: true, force: true }); + } catch { + /* */ + } } }); @@ -73,13 +77,19 @@ function createFixture({ defaultSandbox: rebuildTarget.name, sandboxes: { [rebuildTarget.name]: { - name: rebuildTarget.name, model: "m", provider: "p", - gpuEnabled: false, policies: [], + name: rebuildTarget.name, + model: "m", + provider: "p", + gpuEnabled: false, + policies: [], agent: rebuildTarget.agent, }, [lastOnboarded.name]: { - name: lastOnboarded.name, model: "m", provider: "p", - gpuEnabled: false, policies: [], + name: lastOnboarded.name, + model: "m", + provider: "p", + gpuEnabled: false, + policies: [], agent: lastOnboarded.agent, }, }, @@ -91,23 +101,37 @@ function createFixture({ fs.writeFileSync( path.join(nemoclawDir, "onboard-session.json"), JSON.stringify({ - version: 1, sessionId: "s", resumable: true, status: "complete", - mode: "interactive", startedAt: "2026-01-01", updatedAt: "2026-01-01", - lastStepStarted: null, lastCompletedStep: "inference", failure: null, - agent: lastOnboarded.agent, sandboxName: lastOnboarded.name, - provider: "p", model: "m", endpointUrl: null, credentialEnv: null, - preferredInferenceApi: null, nimContainer: null, webSearchConfig: null, - policyPresets: [], messagingChannels: null, + version: 1, + sessionId: "s", + resumable: true, + status: "complete", + mode: "interactive", + startedAt: "2026-01-01", + updatedAt: "2026-01-01", + lastStepStarted: null, + lastCompletedStep: "inference", + failure: null, + agent: lastOnboarded.agent, + sandboxName: lastOnboarded.name, + provider: "p", + model: "m", + endpointUrl: null, + credentialEnv: null, + preferredInferenceApi: null, + nimContainer: null, + webSearchConfig: null, + policyPresets: [], + messagingChannels: null, metadata: { gatewayName: "nemoclaw", fromDockerfile: fromDockerfile }, steps: { - preflight: { status: "complete", startedAt: null, completedAt: null, error: null }, - gateway: { status: "complete", startedAt: null, completedAt: null, error: null }, - sandbox: { status: "complete", startedAt: null, completedAt: null, error: null }, + preflight: { status: "complete", startedAt: null, completedAt: null, error: null }, + gateway: { status: "complete", startedAt: null, completedAt: null, error: null }, + sandbox: { status: "complete", startedAt: null, completedAt: null, error: null }, provider_selection: { status: "complete", startedAt: null, completedAt: null, error: null }, - inference: { status: "complete", startedAt: null, completedAt: null, error: null }, - openclaw: { status: "pending", startedAt: null, completedAt: null, error: null }, - agent_setup:{ status: "pending", startedAt: null, completedAt: null, error: null }, - policies: { status: "pending", startedAt: null, completedAt: null, error: null }, + inference: { status: "complete", startedAt: null, completedAt: null, error: null }, + openclaw: { status: "pending", startedAt: null, completedAt: null, error: null }, + agent_setup: { status: "pending", startedAt: null, completedAt: null, error: null }, + policies: { status: "pending", startedAt: null, completedAt: null, error: null }, }, }), { mode: 0o600 }, @@ -190,46 +214,56 @@ function runRebuild(fixture: ReturnType) { ); } -function readSession(fixture: ReturnType): Record { +type SessionFixture = { agent?: string | null }; + +function readSession(fixture: ReturnType): SessionFixture { const p = path.join(fixture.nemoclawDir, "onboard-session.json"); return JSON.parse(fs.readFileSync(p, "utf-8")); } -function readSessionAgent(fixture: ReturnType): unknown { +function readSessionAgent(fixture: ReturnType): string | null | undefined { return readSession(fixture).agent; } describe("Issue #2201: rebuild syncs agent from registry, not stale session", () => { - it("rebuild openclaw after hermes was onboarded last (reporter scenario)", - { timeout: 60_000 }, () => { + it( + "rebuild openclaw after hermes was onboarded last (reporter scenario)", + { timeout: 60_000 }, + () => { // Exact scenario from the bug report: user has openclaw + hermes, // hermes was onboarded last, then runs `nemoclaw openclaw rebuild`. const f = createFixture({ rebuildTarget: { name: "openclaw", agent: null }, - lastOnboarded: { name: "hermes", agent: "hermes" }, + lastOnboarded: { name: "hermes", agent: "hermes" }, }); runRebuild(f); // With fix: session.agent = null (synced from openclaw registry entry) // Without fix: session.agent stays "hermes" (from hermes onboard) expect(readSessionAgent(f)).toBeNull(); - }); + }, + ); - it("rebuild hermes after openclaw was onboarded last (reverse scenario)", - { timeout: 60_000 }, () => { + it( + "rebuild hermes after openclaw was onboarded last (reverse scenario)", + { timeout: 60_000 }, + () => { const f = createFixture({ - rebuildTarget: { name: "hermes", agent: "hermes" }, + rebuildTarget: { name: "hermes", agent: "hermes" }, lastOnboarded: { name: "openclaw", agent: null }, }); runRebuild(f); // With fix: session.agent = "hermes" (synced from hermes registry entry) // Without fix: session.agent stays null (from openclaw onboard) expect(readSessionAgent(f)).toBe("hermes"); - }); + }, + ); }); describe("Issue #2301: rebuild forwards stored --from Dockerfile to onboard", () => { - it("rebuild does not hit fromDockerfile conflict when session has a stored --from path", - { timeout: 60_000 }, () => { + it( + "rebuild does not hit fromDockerfile conflict when session has a stored --from path", + { timeout: 60_000 }, + () => { // Scenario: user onboarded with --from /path/to/Dockerfile, then // runs rebuild. Without the fix, onboard's conflict check sees // requestedFrom=null vs recordedFrom="/path/to/Dockerfile" and @@ -244,5 +278,6 @@ describe("Issue #2301: rebuild forwards stored --from Dockerfile to onboard", () // With fix: rebuild proceeds past conflict check (may still fail // later in the fake-env backup step — that's expected with stubs). expect(result.stderr).not.toMatch(/Session was started with --from/); - }); + }, + ); }); diff --git a/test/resolve-openshell.test.ts b/test/resolve-openshell.test.ts index 91cbbeb38a6..74588b12f9c 100644 --- a/test/resolve-openshell.test.ts +++ b/test/resolve-openshell.test.ts @@ -1,4 +1,3 @@ -// @ts-nocheck // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 diff --git a/test/runner.test.ts b/test/runner.test.ts index fbc35342e7b..6eb1543e220 100644 --- a/test/runner.test.ts +++ b/test/runner.test.ts @@ -1,7 +1,8 @@ -// @ts-nocheck // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import type { StdioOptions } from "node:child_process"; + import { spawnSync } from "node:child_process"; import childProcess from "node:child_process"; import fs from "node:fs"; @@ -13,6 +14,37 @@ import { runCapture } from "../dist/lib/runner"; const runnerPath = path.join(import.meta.dirname, "..", "dist", "lib", "runner.js"); +type SpawnCallOptions = { + stdio?: StdioOptions; + shell?: boolean; + env?: Record; +}; + +type SpawnCall = [command: string, args?: readonly string[], options?: SpawnCallOptions]; +type RedactedRunnerError = Error & { + cmd?: string; + output?: string[]; +}; + +function captureSpawnCall( + calls: SpawnCall[], + result: { status: number; stdout: string; stderr: string }, +) { + return (command: string, args?: readonly string[], options?: SpawnCallOptions) => { + calls.push([command, args, options]); + return result; + }; +} + +function requireCall(calls: SpawnCall[], index: number): SpawnCall { + const call = calls[index]; + expect(call).toBeDefined(); + if (!call) { + throw new Error(`Expected spawnSync call ${index}`); + } + return call; +} + describe("runner helpers", () => { it("does not let child commands consume installer stdin", () => { const script = ` @@ -35,13 +67,10 @@ describe("runner helpers", () => { }); it("uses inherited stdio for interactive commands only", () => { - const calls = []; + const calls: SpawnCall[] = []; const originalSpawnSync = childProcess.spawnSync; // @ts-expect-error — intentional partial mock for testing - childProcess.spawnSync = (...args) => { - calls.push(args); - return { status: 0, stdout: "", stderr: "" }; - }; + childProcess.spawnSync = captureSpawnCall(calls, { status: 0, stdout: "", stderr: "" }); try { delete require.cache[require.resolve(runnerPath)]; @@ -54,17 +83,16 @@ describe("runner helpers", () => { } expect(calls).toHaveLength(2); - expect(calls[0][2].stdio).toEqual(["ignore", "pipe", "pipe"]); - expect(calls[1][2].stdio).toEqual(["inherit", "pipe", "pipe"]); + const firstCall = requireCall(calls, 0); + const secondCall = requireCall(calls, 1); + expect(firstCall[2]?.stdio).toEqual(["ignore", "pipe", "pipe"]); + expect(secondCall[2]?.stdio).toEqual(["inherit", "pipe", "pipe"]); }); it("runs argv-style commands without going through bash -c", () => { - const calls = []; + const calls: SpawnCall[] = []; const originalSpawnSync = childProcess.spawnSync; // @ts-expect-error — intentional partial mock for testing - childProcess.spawnSync = (...args) => { - calls.push(args); - return { status: 0, stdout: "", stderr: "" }; - }; + childProcess.spawnSync = captureSpawnCall(calls, { status: 0, stdout: "", stderr: "" }); try { delete require.cache[require.resolve(runnerPath)]; @@ -76,10 +104,11 @@ describe("runner helpers", () => { } expect(calls).toHaveLength(1); - expect(calls[0][0]).toBe("bash"); - expect(calls[0][1]).toEqual(["/tmp/setup.sh", "safe;name", "$(id)"]); - expect(calls[0][2].shell).toBe(false); - expect(calls[0][2].stdio).toEqual(["ignore", "pipe", "pipe"]); + const firstCall = requireCall(calls, 0); + expect(firstCall[0]).toBe("bash"); + expect(firstCall[1]).toEqual(["/tmp/setup.sh", "safe;name", "$(id)"]); + expect(firstCall[2]?.shell).toBe(false); + expect(firstCall[2]?.stdio).toEqual(["ignore", "pipe", "pipe"]); }); it("rejects opts.shell for argv-style commands", () => { @@ -135,14 +164,11 @@ describe("runner env merging", () => { }); it("preserves process env when opts.env is provided to run", () => { - const calls = []; + const calls: SpawnCall[] = []; const originalSpawnSync = childProcess.spawnSync; const originalPath = process.env.PATH; // @ts-expect-error — intentional partial mock for testing - childProcess.spawnSync = (...args) => { - calls.push(args); - return { status: 0, stdout: "", stderr: "" }; - }; + childProcess.spawnSync = captureSpawnCall(calls, { status: 0, stdout: "", stderr: "" }); try { delete require.cache[require.resolve(runnerPath)]; @@ -162,19 +188,19 @@ describe("runner env merging", () => { } expect(calls).toHaveLength(1); - expect(calls[0][2].env.OPENSHELL_CLUSTER_IMAGE).toBe("ghcr.io/nvidia/openshell/cluster:0.0.12"); - expect(calls[0][2].env.PATH).toBe("/usr/local/bin:/usr/bin"); + const firstCall = requireCall(calls, 0); + expect(firstCall[2]?.env?.OPENSHELL_CLUSTER_IMAGE).toBe( + "ghcr.io/nvidia/openshell/cluster:0.0.12", + ); + expect(firstCall[2]?.env?.PATH).toBe("/usr/local/bin:/usr/bin"); }); it("preserves process env when opts.env is provided to runFile", () => { - const calls = []; + const calls: SpawnCall[] = []; const originalSpawnSync = childProcess.spawnSync; const originalPath = process.env.PATH; // @ts-expect-error — intentional partial mock for testing - childProcess.spawnSync = (...args) => { - calls.push(args); - return { status: 0, stdout: "", stderr: "" }; - }; + childProcess.spawnSync = captureSpawnCall(calls, { status: 0, stdout: "", stderr: "" }); try { delete require.cache[require.resolve(runnerPath)]; @@ -194,8 +220,11 @@ describe("runner env merging", () => { } expect(calls).toHaveLength(1); - expect(calls[0][2].env.OPENSHELL_CLUSTER_IMAGE).toBe("ghcr.io/nvidia/openshell/cluster:0.0.12"); - expect(calls[0][2].env.PATH).toBe("/usr/local/bin:/usr/bin"); + const firstCall = requireCall(calls, 0); + expect(firstCall[2]?.env?.OPENSHELL_CLUSTER_IMAGE).toBe( + "ghcr.io/nvidia/openshell/cluster:0.0.12", + ); + expect(firstCall[2]?.env?.PATH).toBe("/usr/local/bin:/usr/bin"); }); }); @@ -371,14 +400,21 @@ describe("regression guards", () => { delete require.cache[require.resolve(runnerPath)]; const { runCapture } = require(runnerPath); - let error; + let error: Error | undefined; try { runCapture("echo nope"); } catch (err) { - error = err; + if (err instanceof Error) { + error = err; + } else { + throw err; + } } expect(error).toBeInstanceOf(Error); + if (!error) { + throw new Error("Expected runCapture() to throw"); + } expect(error.message).toContain("ghp_"); expect(error.message).not.toContain("supersecretvalue12345"); expect(error.message).not.toContain("abcdefghijklmnopqrstuvwxyz1234567890"); @@ -391,7 +427,7 @@ describe("regression guards", () => { it("runCapture redacts execSync error cmd/output fields", () => { const originalExecSync = childProcess.execSync; childProcess.execSync = () => { - const err = /** @type {any} */ (new Error("command failed")); + const err: RedactedRunnerError = new Error("command failed"); err.cmd = "echo nvapi-aaaabbbbcccc1111 && echo ghp_abcdefghijklmnopqrstuvwxyz123456"; err.output = ["stdout: nvapi-aaaabbbbcccc1111", "stderr: PASSWORD=secret123456"]; throw err; @@ -401,15 +437,27 @@ describe("regression guards", () => { delete require.cache[require.resolve(runnerPath)]; const { runCapture } = require(runnerPath); - let error; + let error: RedactedRunnerError | undefined; try { runCapture("echo nope"); } catch (err) { - error = /** @type {any} */ (err); + if (err instanceof Error) { + error = err; + } else { + throw err; + } } expect(error).toBeDefined(); expect(error).toBeInstanceOf(Error); + if (!error) { + throw new Error("Expected runCapture() to throw"); + } + expect(error.cmd).toBeDefined(); + expect(error.output).toBeDefined(); + if (!error.cmd || !error.output) { + throw new Error("Expected redacted cmd/output fields on the thrown error"); + } expect(error.cmd).not.toContain("nvapi-aaaabbbbcccc1111"); expect(error.cmd).not.toContain("ghp_abcdefghijklmnopqrstuvwxyz123456"); expect(Array.isArray(error.output)).toBe(true); @@ -461,23 +509,21 @@ describe("regression guards", () => { const originalSpawnSync = childProcess.spawnSync; const stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true); const stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true); - const calls = []; + const calls: SpawnCall[] = []; // @ts-expect-error — intentional partial mock for testing - childProcess.spawnSync = (...args) => { - calls.push(args); - return { - status: 0, - stdout: "visit https://alice:secret@example.com/?token=abc123456789\n", // gitleaks:allow - stderr: "", - }; - }; + childProcess.spawnSync = captureSpawnCall(calls, { + status: 0, + stdout: "visit https://alice:secret@example.com/?token=abc123456789\n", // gitleaks:allow + stderr: "", + }); try { delete require.cache[require.resolve(runnerPath)]; const { runInteractive } = require(runnerPath); runInteractive("echo interactive"); - expect(calls[0][2].stdio).toEqual(["inherit", "pipe", "pipe"]); + const firstCall = requireCall(calls, 0); + expect(firstCall[2]?.stdio).toEqual(["inherit", "pipe", "pipe"]); expect(stdoutSpy).toHaveBeenCalledWith("visit https://****:****@example.com/?token=****\n"); expect(stderrSpy).not.toHaveBeenCalled(); } finally { @@ -489,7 +535,10 @@ describe("regression guards", () => { }); it("nemoclaw.ts does not use execSync", () => { - const src = fs.readFileSync(path.join(import.meta.dirname, "..", "src", "nemoclaw.ts"), "utf-8"); + const src = fs.readFileSync( + path.join(import.meta.dirname, "..", "src", "nemoclaw.ts"), + "utf-8", + ); const lines = src.split("\n"); for (let i = 0; i < lines.length; i += 1) { if (lines[i].includes("execSync") && !lines[i].includes("execFileSync")) { @@ -501,11 +550,12 @@ describe("regression guards", () => { it("keeps a single shellQuote definition in the root CLI codebase", () => { const repoRoot = path.join(import.meta.dirname, ".."); const searchRoots = [path.join(repoRoot, "bin"), path.join(repoRoot, "src")]; - const files = []; - function walk(dir) { + const files: string[] = []; + function walk(dir: string): void { for (const f of fs.readdirSync(dir, { withFileTypes: true })) { if (f.isDirectory() && f.name !== "node_modules") walk(path.join(dir, f.name)); - else if (f.name.endsWith(".js") || f.name.endsWith(".ts")) files.push(path.join(dir, f.name)); + else if (f.name.endsWith(".js") || f.name.endsWith(".ts")) + files.push(path.join(dir, f.name)); } } for (const root of searchRoots) { @@ -553,7 +603,10 @@ describe("regression guards", () => { describe("credential exposure guards (#429)", () => { it("onboard createSandbox does not pass NVIDIA_API_KEY to sandbox env", () => { const fs = require("fs"); - const src = fs.readFileSync(path.join(import.meta.dirname, "..", "src", "lib", "onboard.ts"), "utf-8"); + const src = fs.readFileSync( + path.join(import.meta.dirname, "..", "src", "lib", "onboard.ts"), + "utf-8", + ); // Find the envArgs block in createSandbox — it should not contain NVIDIA_API_KEY const envArgsMatch = src.match(/const envArgs = \[[\s\S]*?\];/); expect(envArgsMatch).toBeTruthy(); @@ -562,13 +615,19 @@ describe("regression guards", () => { it("onboard clears NVIDIA_API_KEY from process.env after setupInference", () => { const fs = require("fs"); - const src = fs.readFileSync(path.join(import.meta.dirname, "..", "src", "lib", "onboard.ts"), "utf-8"); + const src = fs.readFileSync( + path.join(import.meta.dirname, "..", "src", "lib", "onboard.ts"), + "utf-8", + ); expect(src.includes("delete process.env.NVIDIA_API_KEY")).toBeTruthy(); }); it("setupSpark is a compatibility alias that does not shell out to sudo", () => { const fs = require("fs"); - const src = fs.readFileSync(path.join(import.meta.dirname, "..", "src", "nemoclaw.ts"), "utf-8"); + const src = fs.readFileSync( + path.join(import.meta.dirname, "..", "src", "nemoclaw.ts"), + "utf-8", + ); expect(src).toContain("runDeprecatedOnboardAliasCommand"); expect(src).toContain('kind: "setup-spark"'); expect(src).not.toContain('sudo bash "${SCRIPTS}/setup-spark.sh"'); @@ -584,7 +643,7 @@ describe("regression guards", () => { const cmdLines = src .split("\n") .filter( - (l) => + (l: string) => !l.trim().startsWith("#") && !l.trim().startsWith("echo") && (l.includes("tmux") || l.includes("openshell sandbox connect")), @@ -669,34 +728,34 @@ describe("regression guards", () => { describe("curl-pipe-to-shell guards (#574, #583)", () => { // Strip comment lines, then join line continuations so multiline // curl ... |\n bash patterns are caught by the single-line regex. - const stripComments = (src, commentPrefix) => + const stripComments = (src: string, commentPrefix: string): string => src .split("\n") - .filter((l) => !l.trim().startsWith(commentPrefix)) + .filter((l: string) => !l.trim().startsWith(commentPrefix)) .join("\n"); - const joinContinuations = (src) => src.replace(/\\\n\s*/g, " "); + const joinContinuations = (src: string): string => src.replace(/\\\n\s*/g, " "); - const collapseMultilinePipes = (src) => src.replace(/\|\s*\n\s*/g, "| "); + const collapseMultilinePipes = (src: string): string => src.replace(/\|\s*\n\s*/g, "| "); - const normalize = (src, commentPrefix) => + const normalize = (src: string, commentPrefix: string): string => collapseMultilinePipes(joinContinuations(stripComments(src, commentPrefix))); const shellViolationRe = /curl\s[^|]*\|\s*(sh|bash|sudo\s+(-\S+\s+)*(sh|bash))\b/; const jsViolationRe = /curl.*\|\s*(sh|bash|sudo\s+(-\S+\s+)*(sh|bash))\b/; - const findShellViolations = (src) => { + const findShellViolations = (src: string): string[] => { const normalized = normalize(src, "#"); - return normalized.split("\n").filter((line) => { + return normalized.split("\n").filter((line: string) => { const t = line.trim(); if (t.startsWith("printf") || t.startsWith("echo") || t.startsWith("warn")) return false; return shellViolationRe.test(t); }); }; - const findJsViolations = (src) => { + const findJsViolations = (src: string): string[] => { const normalized = normalize(src, "//"); - return normalized.split("\n").filter((line) => { + return normalized.split("\n").filter((line: string) => { const t = line.trim(); if (t.startsWith("*")) return false; return jsViolationRe.test(t); @@ -742,7 +801,10 @@ describe("regression guards", () => { path.join(import.meta.dirname, "..", "src", "lib", "deploy.ts"), "utf-8", ); - const src = fs.readFileSync(path.join(import.meta.dirname, "..", "src", "nemoclaw.ts"), "utf-8"); + const src = fs.readFileSync( + path.join(import.meta.dirname, "..", "src", "nemoclaw.ts"), + "utf-8", + ); expect(src).toContain('const { executeDeploy } = require("./lib/deploy")'); expect(tsSrc).toContain("export function inferDeployProvider("); expect(tsSrc).toContain("export function buildDeployEnvLines("); @@ -831,7 +893,10 @@ describe("regression guards", () => { }); it("src/nemoclaw.ts does not pipe curl to shell", () => { - const src = fs.readFileSync(path.join(import.meta.dirname, "..", "src", "nemoclaw.ts"), "utf-8"); + const src = fs.readFileSync( + path.join(import.meta.dirname, "..", "src", "nemoclaw.ts"), + "utf-8", + ); expect(findJsViolations(src)).toEqual([]); }); }); diff --git a/test/runtime-shell.test.ts b/test/runtime-shell.test.ts index c9fa602774e..5bb133474a4 100644 --- a/test/runtime-shell.test.ts +++ b/test/runtime-shell.test.ts @@ -1,4 +1,3 @@ -// @ts-nocheck // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 @@ -6,11 +5,14 @@ import { describe, it, expect } from "vitest"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { spawnSync } from "node:child_process"; +import { spawnSync, type SpawnSyncReturns } from "node:child_process"; const RUNTIME_SH = path.join(import.meta.dirname, "..", "scripts", "lib", "runtime.sh"); -function runShell(script, env = {}) { +function runShell( + script: string, + env: Record = {}, +): SpawnSyncReturns { return spawnSync("bash", ["-lc", script], { cwd: path.join(import.meta.dirname, ".."), encoding: "utf-8", diff --git a/test/sandbox-build-context.test.ts b/test/sandbox-build-context.test.ts index da8d6e520ed..1ce63cade63 100644 --- a/test/sandbox-build-context.test.ts +++ b/test/sandbox-build-context.test.ts @@ -1,4 +1,3 @@ -// @ts-nocheck // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 diff --git a/test/sandbox-connect-inference.test.ts b/test/sandbox-connect-inference.test.ts index 28b79ffca7d..77a44d7d1c3 100644 --- a/test/sandbox-connect-inference.test.ts +++ b/test/sandbox-connect-inference.test.ts @@ -1,4 +1,3 @@ -// @ts-nocheck // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 @@ -15,8 +14,17 @@ import { describe, expect, it } from "vitest"; * file, sets up a sandbox registry, and spawns the real CLI entrypoint. */ +type SandboxEntryFixture = { + name: string; + model?: string | null; + provider?: string | null; + nimContainer?: string | null; + gpuEnabled?: boolean; + policies?: string[]; +}; + function setupFixture( - sandboxEntry: Record, + sandboxEntry: SandboxEntryFixture, liveInferenceProvider: string | null, liveInferenceModel: string | null, ) { @@ -162,7 +170,9 @@ describe("sandbox connect inference route swap (#1248)", () => { // Verify the notice was printed const combined = (result.stdout || "") + (result.stderr || ""); - expect(combined).toContain("Switching inference route to anthropic-prod/claude-sonnet-4-20250514"); + expect(combined).toContain( + "Switching inference route to anthropic-prod/claude-sonnet-4-20250514", + ); }, ); diff --git a/test/sandbox-init.test.ts b/test/sandbox-init.test.ts index 311b34d39a9..cf49dd56929 100644 --- a/test/sandbox-init.test.ts +++ b/test/sandbox-init.test.ts @@ -1,4 +1,3 @@ -// @ts-nocheck // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 @@ -36,6 +35,22 @@ function getOctalPerms(filePath: string): string { * Run a bash snippet that sources sandbox-init.sh and executes the given body. * Returns { stdout, stderr } as trimmed strings. */ +type ExecFailureShape = { stdout?: string | Buffer; stderr?: string | Buffer }; + +function readExecFileSyncOutput(error: ExecFailureShape | null, key: "stdout" | "stderr"): string { + if (error === null) { + return ""; + } + const value = Reflect.get(error, key); + if (typeof value === "string") { + return value.trim(); + } + if (Buffer.isBuffer(value)) { + return value.toString().trim(); + } + return ""; +} + function runWithLib( body: string, opts: { env?: Record; expectFail?: boolean } = {}, @@ -55,11 +70,12 @@ function runWithLib( stdio: ["pipe", "pipe", "pipe"], }); return { stdout: result.trim(), stderr: "" }; - } catch (e: any) { + } catch (e) { if (opts.expectFail) { + const errorObject: ExecFailureShape | null = typeof e === "object" && e !== null ? e : null; return { - stdout: (e.stdout || "").toString().trim(), - stderr: (e.stderr || "").toString().trim(), + stdout: readExecFileSyncOutput(errorObject, "stdout"), + stderr: readExecFileSyncOutput(errorObject, "stderr"), }; } throw e; @@ -179,11 +195,7 @@ EOF describe("validate_tmp_permissions", () => { let workDir: string; let tmpBackups: Record; - const TMP_ARTIFACTS = [ - "/tmp/nemoclaw-proxy-env.sh", - "/tmp/gateway.log", - "/tmp/auto-pair.log", - ]; + const TMP_ARTIFACTS = ["/tmp/nemoclaw-proxy-env.sh", "/tmp/gateway.log", "/tmp/auto-pair.log"]; beforeEach(() => { workDir = mkdtempSync(join(tmpdir(), "sandbox-init-validate-")); @@ -209,10 +221,9 @@ EOF writeFileSync(testFile, "# bad permissions"); chmodSync(testFile, 0o644); // writable — should fail - const { stderr } = runWithLib( - `validate_tmp_permissions ${JSON.stringify(testFile)}`, - { expectFail: true }, - ); + const { stderr } = runWithLib(`validate_tmp_permissions ${JSON.stringify(testFile)}`, { + expectFail: true, + }); expect(stderr).toContain("unsafe permissions"); }); @@ -240,10 +251,9 @@ EOF }); it("fails when hash file is missing", () => { - const { stderr } = runWithLib( - `verify_config_integrity ${JSON.stringify(workDir)}`, - { expectFail: true }, - ); + const { stderr } = runWithLib(`verify_config_integrity ${JSON.stringify(workDir)}`, { + expectFail: true, + }); expect(stderr).toContain("Config hash file missing"); }); @@ -272,10 +282,9 @@ EOF // Tamper with config writeFileSync(configFile, '{"test": false, "injected": "malicious"}'); - const { stderr } = runWithLib( - `verify_config_integrity ${JSON.stringify(workDir)}`, - { expectFail: true }, - ); + const { stderr } = runWithLib(`verify_config_integrity ${JSON.stringify(workDir)}`, { + expectFail: true, + }); expect(stderr).toContain("integrity check FAILED"); }); }); @@ -451,36 +460,24 @@ EOF describe("both entrypoints source the shared library", () => { it("nemoclaw-start.sh sources sandbox-init.sh", () => { - const src = readFileSync( - join(import.meta.dirname, "../scripts/nemoclaw-start.sh"), - "utf-8", - ); + const src = readFileSync(join(import.meta.dirname, "../scripts/nemoclaw-start.sh"), "utf-8"); expect(src).toContain("source"); expect(src).toContain("sandbox-init.sh"); }); it("hermes start.sh sources sandbox-init.sh", () => { - const src = readFileSync( - join(import.meta.dirname, "../agents/hermes/start.sh"), - "utf-8", - ); + const src = readFileSync(join(import.meta.dirname, "../agents/hermes/start.sh"), "utf-8"); expect(src).toContain("source"); expect(src).toContain("sandbox-init.sh"); }); it("hermes start.sh calls lock_rc_files (vulnerability fix)", () => { - const src = readFileSync( - join(import.meta.dirname, "../agents/hermes/start.sh"), - "utf-8", - ); + const src = readFileSync(join(import.meta.dirname, "../agents/hermes/start.sh"), "utf-8"); expect(src).toContain("lock_rc_files"); }); it("hermes start.sh uses emit_sandbox_sourced_file for proxy config", () => { - const src = readFileSync( - join(import.meta.dirname, "../agents/hermes/start.sh"), - "utf-8", - ); + const src = readFileSync(join(import.meta.dirname, "../agents/hermes/start.sh"), "utf-8"); expect(src).toContain("emit_sandbox_sourced_file"); // Should NOT contain the old inline _write_proxy_snippet pattern expect(src).not.toContain("_write_proxy_snippet"); @@ -488,28 +485,19 @@ EOF }); it("hermes start.sh calls validate_tmp_permissions", () => { - const src = readFileSync( - join(import.meta.dirname, "../agents/hermes/start.sh"), - "utf-8", - ); + const src = readFileSync(join(import.meta.dirname, "../agents/hermes/start.sh"), "utf-8"); expect(src).toContain("validate_tmp_permissions"); }); it("nemoclaw-start.sh uses emit_sandbox_sourced_file for proxy-env.sh", () => { - const src = readFileSync( - join(import.meta.dirname, "../scripts/nemoclaw-start.sh"), - "utf-8", - ); + const src = readFileSync(join(import.meta.dirname, "../scripts/nemoclaw-start.sh"), "utf-8"); expect(src).toContain("emit_sandbox_sourced_file"); // Should NOT contain old chmod 644 for proxy-env expect(src).not.toMatch(/chmod 644.*\$_PROXY_ENV_FILE/); }); it("nemoclaw-start.sh uses parameterized verify_config_integrity", () => { - const src = readFileSync( - join(import.meta.dirname, "../scripts/nemoclaw-start.sh"), - "utf-8", - ); + const src = readFileSync(join(import.meta.dirname, "../scripts/nemoclaw-start.sh"), "utf-8"); expect(src).toContain("verify_config_integrity /sandbox/.openclaw"); }); }); diff --git a/test/secret-redaction.test.ts b/test/secret-redaction.test.ts index 1d29dbf58d0..3cf38bbe38a 100644 --- a/test/secret-redaction.test.ts +++ b/test/secret-redaction.test.ts @@ -4,10 +4,7 @@ import { describe, it, expect } from "vitest"; import { readFileSync } from "node:fs"; import { join } from "node:path"; -import { - SECRET_PATTERNS, - EXPECTED_SHELL_PREFIXES, -} from "../src/lib/secret-patterns"; +import { SECRET_PATTERNS, EXPECTED_SHELL_PREFIXES } from "../src/lib/secret-patterns"; import { redact as debugRedact } from "../src/lib/debug"; import { redactSensitiveText } from "../src/lib/onboard-session"; // runner.ts uses CJS exports — import via dist @@ -16,20 +13,19 @@ import { createRequire } from "node:module"; const require = createRequire(import.meta.url); const { redact: runnerRedact } = require("../dist/lib/runner"); -const DEBUG_SH = readFileSync( - join(import.meta.dirname, "..", "scripts", "debug.sh"), - "utf-8", -); +const DEBUG_SH = readFileSync(join(import.meta.dirname, "..", "scripts", "debug.sh"), "utf-8"); -const RUNNER_TS = readFileSync( - join(import.meta.dirname, "..", "src", "lib", "runner.ts"), - "utf-8", -); +const RUNNER_TS = readFileSync(join(import.meta.dirname, "..", "src", "lib", "runner.ts"), "utf-8"); -const DEBUG_TS = readFileSync( - join(import.meta.dirname, "..", "src", "lib", "debug.ts"), - "utf-8", -); +function requireMatch(match: RegExpMatchArray | null): RegExpMatchArray { + expect(match).toBeTruthy(); + if (!match) { + throw new Error("Expected regex match to be present"); + } + return match; +} + +const DEBUG_TS = readFileSync(join(import.meta.dirname, "..", "src", "lib", "debug.ts"), "utf-8"); describe("secret redaction consistency (#1736)", () => { // Tokens whose prefix is a literal string that must appear in debug.sh. @@ -62,9 +58,7 @@ describe("secret redaction consistency (#1736)", () => { describe("runner.ts redacts all token types", () => { for (const { name, token } of TEST_TOKENS) { it(`redacts ${name}`, () => { - const text = runnerRedact( - `error: authentication failed with ${token}`, - ); + const text = runnerRedact(`error: authentication failed with ${token}`); expect(text).not.toContain(token); }); } @@ -73,9 +67,7 @@ describe("secret redaction consistency (#1736)", () => { describe("debug.ts redacts all token types", () => { for (const { name, token } of TEST_TOKENS) { it(`redacts ${name}`, () => { - const text = debugRedact( - `error: authentication failed with ${token}`, - ); + const text = debugRedact(`error: authentication failed with ${token}`); expect(text).not.toContain(token); }); } @@ -111,9 +103,7 @@ describe("secret redaction consistency (#1736)", () => { describe("onboard-session redactSensitiveText (#2336)", () => { for (const { name, token } of TEST_TOKENS) { it(`redacts ${name} from persisted failure messages`, () => { - const text = redactSensitiveText( - `onboard step failed: provider returned ${token}`, - ); + const text = redactSensitiveText(`onboard step failed: provider returned ${token}`); expect(text).not.toContain(token); }); } @@ -127,9 +117,7 @@ describe("secret redaction consistency (#1736)", () => { }); it("redacts Slack env-var assignments", () => { - const text = redactSensitiveText( - "SLACK_BOT_TOKEN=xoxb-notreal SLACK_APP_TOKEN=xapp-notreal", - ); + const text = redactSensitiveText("SLACK_BOT_TOKEN=xoxb-notreal SLACK_APP_TOKEN=xapp-notreal"); expect(text).not.toContain("xoxb-notreal"); expect(text).not.toContain("xapp-notreal"); }); diff --git a/test/security-binaries-restriction.test.ts b/test/security-binaries-restriction.test.ts index fadcc6d3765..648fc468951 100644 --- a/test/security-binaries-restriction.test.ts +++ b/test/security-binaries-restriction.test.ts @@ -1,4 +1,3 @@ -// @ts-nocheck // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 diff --git a/test/security-c2-dockerfile-injection.test.ts b/test/security-c2-dockerfile-injection.test.ts index 76d02751da3..611e61fff1e 100644 --- a/test/security-c2-dockerfile-injection.test.ts +++ b/test/security-c2-dockerfile-injection.test.ts @@ -1,4 +1,3 @@ -// @ts-nocheck // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 // @@ -18,7 +17,7 @@ import { spawnSync } from "node:child_process"; const DOCKERFILE = path.join(import.meta.dirname, "..", "Dockerfile"); -function runPython(src, env = {}) { +function runPython(src: string, env: Record = {}) { return spawnSync("python3", ["-c", src], { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"], @@ -28,7 +27,7 @@ function runPython(src, env = {}) { } // Simulate what Docker ARG substitution produces (the VULNERABLE pattern) -function vulnerableSource(chatUiUrlValue) { +function vulnerableSource(chatUiUrlValue: string): string { return ( "import json, os, secrets; " + "from urllib.parse import urlparse; " + @@ -39,7 +38,7 @@ function vulnerableSource(chatUiUrlValue) { } // Simulate the FIXED pattern (env var, no source interpolation) -function fixedSource() { +function fixedSource(): string { return ( "import json, os, secrets; " + "from urllib.parse import urlparse; " + diff --git a/test/security-c4-manifest-traversal.test.ts b/test/security-c4-manifest-traversal.test.ts index fa6cdc87f22..d969d2efe64 100644 --- a/test/security-c4-manifest-traversal.test.ts +++ b/test/security-c4-manifest-traversal.test.ts @@ -1,4 +1,3 @@ -// @ts-nocheck // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 // @@ -23,7 +22,7 @@ import path from "node:path"; * normalizeHostPath — mirrors migration-state.ts:115-118 * On Windows, lowercases the resolved path for case-insensitive comparison. */ -function normalizeHostPath(p) { +function normalizeHostPath(p: string): string { const resolved = path.resolve(p); if (process.platform === "win32") { return resolved.toLowerCase(); @@ -34,7 +33,7 @@ function normalizeHostPath(p) { /** * isWithinRoot — same logic as migration-state.ts:120-125 */ -function isWithinRoot(candidatePath, rootPath) { +function isWithinRoot(candidatePath: string, rootPath: string): boolean { const candidate = normalizeHostPath(candidatePath); const root = normalizeHostPath(rootPath); const relative = path.relative(root, candidate); @@ -44,14 +43,32 @@ function isWithinRoot(candidatePath, rootPath) { /** * copyDirectory — minimal recursive copy matching migration-state.ts:476 */ -function copyDirectory(src, dest) { +function copyDirectory(src: string, dest: string): void { fs.cpSync(src, dest, { recursive: true }); } /** * Build a minimal snapshot directory with a tampered manifest. */ -function buildSnapshotDir(parentDir, manifest) { +type SnapshotManifest = { + version?: number; + createdAt?: string; + homeDir: string; + stateDir: string; + configPath: string | null; + hasExternalConfig: boolean; + externalRoots?: object[]; + warnings?: string[]; +}; + +function readSnapshotManifest(snapshotDir: string): SnapshotManifest { + const manifest: SnapshotManifest = JSON.parse( + fs.readFileSync(path.join(snapshotDir, "snapshot.json"), "utf-8"), + ); + return manifest; +} + +function buildSnapshotDir(parentDir: string, manifest: SnapshotManifest): string { const snapshotDir = path.join(parentDir, "snapshot"); fs.mkdirSync(path.join(snapshotDir, "openclaw"), { recursive: true }); fs.writeFileSync( @@ -71,10 +88,14 @@ function buildSnapshotDir(parentDir, manifest) { * Simulate restoreSnapshotToHost WITHOUT the fix (vulnerable). * Returns { result, errors, written }. */ -function restoreVulnerable(snapshotDir) { - const manifest = JSON.parse(fs.readFileSync(path.join(snapshotDir, "snapshot.json"), "utf-8")); +function restoreVulnerable(snapshotDir: string): { + result: boolean; + errors: string[]; + written: boolean; +} { + const manifest = readSnapshotManifest(snapshotDir); const snapshotStateDir = path.join(snapshotDir, "openclaw"); - const errors = []; + const errors: string[] = []; let written = false; try { @@ -90,7 +111,7 @@ function restoreVulnerable(snapshotDir) { } return { result: true, errors, written }; } catch (err) { - errors.push(err.message); + errors.push(err instanceof Error ? err.message : String(err)); return { result: false, errors, written }; } } @@ -102,10 +123,13 @@ function restoreVulnerable(snapshotDir) { * @param {string} snapshotDir * @param {string} [trustedRoot] - trusted host root (defaults to os.homedir()) */ -function restoreFixed(snapshotDir, trustedRoot) { - const manifest = JSON.parse(fs.readFileSync(path.join(snapshotDir, "snapshot.json"), "utf-8")); +function restoreFixed( + snapshotDir: string, + trustedRoot?: string, +): { result: boolean; errors: string[]; written: boolean } { + const manifest = readSnapshotManifest(snapshotDir); const snapshotStateDir = path.join(snapshotDir, "openclaw"); - const errors = []; + const errors: string[] = []; let written = false; const root = trustedRoot || os.homedir(); @@ -161,7 +185,7 @@ function restoreFixed(snapshotDir, trustedRoot) { } return { result: true, errors, written }; } catch (err) { - errors.push(err.message); + errors.push(err instanceof Error ? err.message : String(err)); return { result: false, errors, written }; } } diff --git a/test/security-method-wildcards.test.ts b/test/security-method-wildcards.test.ts index b98368c1baa..17ead734628 100644 --- a/test/security-method-wildcards.test.ts +++ b/test/security-method-wildcards.test.ts @@ -1,4 +1,3 @@ -// @ts-nocheck // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 diff --git a/test/security-sandbox-tar-traversal.test.ts b/test/security-sandbox-tar-traversal.test.ts index 21712ab7f75..5052d6efca1 100644 --- a/test/security-sandbox-tar-traversal.test.ts +++ b/test/security-sandbox-tar-traversal.test.ts @@ -1,4 +1,3 @@ -// @ts-nocheck // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 // @@ -115,11 +114,31 @@ function buildTar( /** * Import the actual validation/extraction functions from the source. */ -async function loadSandboxState() { +type SandboxStateModule = Pick< + typeof import("../dist/lib/sandbox-state.js"), + "validateTarEntries" | "safeTarExtract" | "rejectHardLinks" +>; + +function isSandboxStateModule( + value: object | null, +): value is typeof import("../dist/lib/sandbox-state.js") { + return ( + value !== null && + typeof Reflect.get(value, "validateTarEntries") === "function" && + typeof Reflect.get(value, "safeTarExtract") === "function" && + typeof Reflect.get(value, "rejectHardLinks") === "function" + ); +} + +async function loadSandboxState(): Promise { // The CLI compiles to dist/lib/ — import from there - const mod = await import( + const loaded = await import( path.join(import.meta.dirname, "..", "dist", "lib", "sandbox-state.js") ); + const mod = typeof loaded === "object" && loaded !== null ? loaded : null; + if (!isSandboxStateModule(mod)) { + throw new Error("Expected sandbox-state module exports to be available"); + } return { validateTarEntries: mod.validateTarEntries, safeTarExtract: mod.safeTarExtract, @@ -138,9 +157,7 @@ async function loadSandboxState() { // ═══════════════════════════════════════════════════════════════════ describe("PoC: malicious tar archives contain path traversal entries", () => { it("tar archive contains a ../../ traversal entry", () => { - const tar = buildTar([ - { path: "../../evil.txt", content: "attacker-payload" }, - ]); + const tar = buildTar([{ path: "../../evil.txt", content: "attacker-payload" }]); // Verify the archive actually contains the traversal entry const list = spawnSync("tar", ["-tf", "-"], { @@ -155,9 +172,7 @@ describe("PoC: malicious tar archives contain path traversal entries", () => { }); it("tar archive contains an absolute path entry", () => { - const tar = buildTar([ - { path: "/etc/cron.d/backdoor", content: "malicious" }, - ]); + const tar = buildTar([{ path: "/etc/cron.d/backdoor", content: "malicious" }]); const list = spawnSync("tar", ["-tf", "-"], { input: tar, @@ -177,9 +192,7 @@ describe("Fix: validateTarEntries rejects malicious tar entries", () => { it("rejects relative path traversal (../../.ssh/authorized_keys)", async () => { const { validateTarEntries } = await loadSandboxState(); const targetDir = "/tmp/nemoclaw-test-target"; - const tar = buildTar([ - { path: "../../.ssh/authorized_keys", content: "ssh-rsa ATTACKER_KEY" }, - ]); + const tar = buildTar([{ path: "../../.ssh/authorized_keys", content: "ssh-rsa ATTACKER_KEY" }]); const result = validateTarEntries(tar, targetDir); @@ -204,9 +217,7 @@ describe("Fix: validateTarEntries rejects malicious tar entries", () => { it("rejects hidden traversal (safe-dir/../../escape.txt)", async () => { const { validateTarEntries } = await loadSandboxState(); const targetDir = "/tmp/nemoclaw-test-target"; - const tar = buildTar([ - { path: "safe-dir/../../escape.txt", content: "hidden-traversal" }, - ]); + const tar = buildTar([{ path: "safe-dir/../../escape.txt", content: "hidden-traversal" }]); const result = validateTarEntries(tar, targetDir); @@ -234,7 +245,7 @@ describe("Fix: validateTarEntries rejects malicious tar entries", () => { const { validateTarEntries } = await loadSandboxState(); const targetDir = "/tmp/nemoclaw-test-target"; const tar = buildTar([ - { path: "legitimate/config.json", content: '{}' }, + { path: "legitimate/config.json", content: "{}" }, { path: "../../.bashrc", content: 'echo "pwned"' }, { path: "legitimate/data.txt", content: "safe" }, ]); @@ -255,9 +266,7 @@ describe("Fix: safeTarExtract blocks malicious archives and extracts safe ones", const targetDir = path.join(workDir, "backup"); fs.mkdirSync(targetDir, { recursive: true }); - const tar = buildTar([ - { path: "../../evil.txt", content: "attacker-payload" }, - ]); + const tar = buildTar([{ path: "../../evil.txt", content: "attacker-payload" }]); const result = safeTarExtract(tar, targetDir); @@ -277,9 +286,7 @@ describe("Fix: safeTarExtract blocks malicious archives and extracts safe ones", const targetDir = path.join(workDir, "backup"); fs.mkdirSync(targetDir, { recursive: true }); - const tar = buildTar([ - { path: "config.json", content: '{"model": "test"}' }, - ]); + const tar = buildTar([{ path: "config.json", content: '{"model": "test"}' }]); const result = safeTarExtract(tar, targetDir); @@ -313,9 +320,7 @@ describe("Fix: safeTarExtract blocks malicious archives and extracts safe ones", expect(result.success).toBe(false); expect(result.error).toContain("symlink"); // Target dir should be cleaned after symlink violation - const entries = fs.existsSync(targetDir) - ? fs.readdirSync(targetDir) - : []; + const entries = fs.existsSync(targetDir) ? fs.readdirSync(targetDir) : []; expect(entries.length).toBe(0); } finally { fs.rmSync(workDir, { recursive: true, force: true }); diff --git a/test/service-env.test.ts b/test/service-env.test.ts index da3dd7b122b..8b5822b0d1c 100644 --- a/test/service-env.test.ts +++ b/test/service-env.test.ts @@ -1,9 +1,12 @@ -// @ts-nocheck // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 import { describe, it, expect } from "vitest"; -import { execSync, execFileSync } from "node:child_process"; +import { + execSync, + execFileSync, + type ExecFileSyncOptionsWithStringEncoding, +} from "node:child_process"; import { mkdtempSync, writeFileSync, unlinkSync, readFileSync, lstatSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -419,7 +422,7 @@ describe("service environment", () => { .replaceAll("/tmp/nemoclaw-proxy-env.sh", `${fakeDataDir}/proxy-env.sh`), ].join("\n"); writeFileSync(tmpFile, wrapper, { mode: 0o700 }); - const runOpts = { encoding: /** @type {const} */ "utf-8" }; + const runOpts: ExecFileSyncOptionsWithStringEncoding = { encoding: "utf-8" }; execFileSync("bash", [tmpFile], runOpts); execFileSync("bash", [tmpFile], runOpts); execFileSync("bash", [tmpFile], runOpts); @@ -461,7 +464,7 @@ describe("service environment", () => { ); } const toolRedirects = extractToolRedirects(); - const makeWrapper = (host) => + const makeWrapper = (host: string) => [ "#!/usr/bin/env bash", sandboxInitSource, diff --git a/test/setup-jetson.test.ts b/test/setup-jetson.test.ts index 834bcc0e656..b1a9f7874c9 100644 --- a/test/setup-jetson.test.ts +++ b/test/setup-jetson.test.ts @@ -26,7 +26,7 @@ function runDaemonJsonPatcher(daemonPath: string): void { }); } -function getExecErrorOutput(error: unknown): string { +function getExecErrorOutput(error: Error | string | null | undefined): string { if (!(error instanceof Error)) { return String(error); } @@ -69,7 +69,15 @@ describe("setup-jetson daemon.json patcher", () => { runDaemonJsonPatcher(daemonPath); const patched = readFileSync(daemonPath, "utf-8"); - const parsed = JSON.parse(patched) as Record; + const parsed: { + "default-runtime": string; + runtimes: { + nvidia: { + path: string; + runtimeArgs: []; + }; + }; + } = JSON.parse(patched); expect(parsed).toEqual({ "default-runtime": "nvidia", @@ -115,7 +123,7 @@ describe("setup-jetson daemon.json patcher", () => { try { runDaemonJsonPatcher(daemonPath); } catch (error) { - output = getExecErrorOutput(error); + output = getExecErrorOutput(error instanceof Error ? error : String(error)); } expect(output).toContain("daemon.json must contain a top-level JSON object"); diff --git a/test/shellquote-sandbox.test.ts b/test/shellquote-sandbox.test.ts index 534a2f4f3a1..92adcc718fd 100644 --- a/test/shellquote-sandbox.test.ts +++ b/test/shellquote-sandbox.test.ts @@ -1,4 +1,3 @@ -// @ts-nocheck // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 @@ -25,8 +24,8 @@ describe("sandboxName command hardening in onboard.js", () => { // Regression guard: runOpenshell and runCaptureOpenshell must pass opts // through to openshellArgv. Without this, callers that supply // { openshellBinary: customPath } silently fall back to the default binary. - expect(src).toMatch(/function runOpenshell\(args, opts[^)]*\)\s*\{[^}]*openshellArgv\(args,\s*opts\)/s); - expect(src).toMatch(/function runCaptureOpenshell\(args, opts[^)]*\)\s*\{[^}]*openshellArgv\(args,\s*opts\)/s); + expect(src).toMatch(/function runOpenshell\([\s\S]*?openshellArgv\(args,\s*opts\)/s); + expect(src).toMatch(/function runCaptureOpenshell\([\s\S]*?openshellArgv\(args,\s*opts\)/s); }); it("does not have raw sandboxName interpolation in run or runCapture template literals", () => { diff --git a/test/shields-audit.test.ts b/test/shields-audit.test.ts index 0af301b2c0e..7e86abc5c62 100644 --- a/test/shields-audit.test.ts +++ b/test/shields-audit.test.ts @@ -1,4 +1,3 @@ -// @ts-nocheck // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 @@ -10,8 +9,12 @@ import os from "node:os"; // Test the audit entry format and JSONL structure using the same logic // as the production module but with a controllable output path. -let tmpDir; -let auditPath; +type AuditScalar = string | number | boolean | null | undefined; +type AuditValue = AuditScalar | AuditRecord | AuditValue[]; +type AuditRecord = { [key: string]: AuditValue }; + +let tmpDir: string; +let auditPath: string; beforeEach(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "shields-audit-test-")); @@ -26,7 +29,7 @@ afterEach(() => { * Inline audit append — mirrors the production appendAuditEntry() but writes * to our test-controlled path instead of ~/.nemoclaw/state/. */ -function appendAuditEntry(entry) { +function appendAuditEntry(entry: AuditRecord) { fs.appendFileSync(auditPath, JSON.stringify(entry) + "\n", { mode: 0o600 }); } diff --git a/test/shields.test.ts b/test/shields.test.ts index fbc885d1d82..e0c58b2aee8 100644 --- a/test/shields.test.ts +++ b/test/shields.test.ts @@ -1,4 +1,3 @@ -// @ts-nocheck // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 @@ -21,9 +20,15 @@ vi.mock("../../src/lib/runner", () => ({ vi.mock("../../src/lib/policies", () => ({ buildPolicyGetCommand: vi.fn((name) => ["openshell", "policy", "get", "--full", name]), - buildPolicySetCommand: vi.fn( - (file, name) => ["openshell", "policy", "set", "--policy", file, "--wait", name], - ), + buildPolicySetCommand: vi.fn((file, name) => [ + "openshell", + "policy", + "set", + "--policy", + file, + "--wait", + name, + ]), parseCurrentPolicy: vi.fn((raw) => raw || ""), PERMISSIVE_POLICY_PATH: "/mock/permissive.yaml", })); @@ -47,7 +52,7 @@ vi.mock("child_process", () => ({ execFileSync: vi.fn(), })); -let tmpDir; +let tmpDir: string; beforeEach(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "shields-test-")); @@ -106,8 +111,14 @@ describe("shields — unit logic", () => { // Write state for two different sandboxes const alphaState = { shieldsDown: true, updatedAt: new Date().toISOString() }; const betaState = { shieldsDown: false, updatedAt: new Date().toISOString() }; - fs.writeFileSync(path.join(stateDir, "shields-alpha.json"), JSON.stringify(alphaState, null, 2)); - fs.writeFileSync(path.join(stateDir, "shields-beta.json"), JSON.stringify(betaState, null, 2)); + fs.writeFileSync( + path.join(stateDir, "shields-alpha.json"), + JSON.stringify(alphaState, null, 2), + ); + fs.writeFileSync( + path.join(stateDir, "shields-beta.json"), + JSON.stringify(betaState, null, 2), + ); const alpha = JSON.parse(fs.readFileSync(path.join(stateDir, "shields-alpha.json"), "utf-8")); const beta = JSON.parse(fs.readFileSync(path.join(stateDir, "shields-beta.json"), "utf-8")); @@ -134,9 +145,14 @@ describe("shields — unit logic", () => { shieldsPolicySnapshotPath: snapshotPath, updatedAt: new Date().toISOString(), }; - fs.writeFileSync(path.join(stateDir, "shields-openclaw.json"), JSON.stringify(state, null, 2)); - - const loaded = JSON.parse(fs.readFileSync(path.join(stateDir, "shields-openclaw.json"), "utf-8")); + fs.writeFileSync( + path.join(stateDir, "shields-openclaw.json"), + JSON.stringify(state, null, 2), + ); + + const loaded = JSON.parse( + fs.readFileSync(path.join(stateDir, "shields-openclaw.json"), "utf-8"), + ); expect(loaded.shieldsDown).toBe(true); expect(loaded.shieldsDownTimeout).toBe(300); expect(loaded.shieldsDownPolicy).toBe("permissive"); @@ -159,7 +175,10 @@ describe("shields — unit logic", () => { shieldsPolicySnapshotPath: snapshotPath, updatedAt: new Date().toISOString(), }; - fs.writeFileSync(path.join(stateDir, "shields-openclaw.json"), JSON.stringify(downState, null, 2)); + fs.writeFileSync( + path.join(stateDir, "shields-openclaw.json"), + JSON.stringify(downState, null, 2), + ); const cleared = { ...downState, @@ -170,9 +189,14 @@ describe("shields — unit logic", () => { shieldsDownPolicy: null, updatedAt: new Date().toISOString(), }; - fs.writeFileSync(path.join(stateDir, "shields-openclaw.json"), JSON.stringify(cleared, null, 2)); - - const loaded = JSON.parse(fs.readFileSync(path.join(stateDir, "shields-openclaw.json"), "utf-8")); + fs.writeFileSync( + path.join(stateDir, "shields-openclaw.json"), + JSON.stringify(cleared, null, 2), + ); + + const loaded = JSON.parse( + fs.readFileSync(path.join(stateDir, "shields-openclaw.json"), "utf-8"), + ); expect(loaded.shieldsDown).toBe(false); expect(loaded.shieldsDownAt).toBeNull(); expect(loaded.shieldsPolicySnapshotPath).toBe(snapshotPath); diff --git a/test/skills-frontmatter.test.ts b/test/skills-frontmatter.test.ts index 0aecb87bda8..5cdf17c0f79 100644 --- a/test/skills-frontmatter.test.ts +++ b/test/skills-frontmatter.test.ts @@ -1,4 +1,3 @@ -// @ts-nocheck // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 @@ -19,8 +18,8 @@ const spdxHeader = [ // SKILL.md: frontmatter first, then SPDX after the closing --- const skillFrontmatterRe = /^---\r?\n([\s\S]*?)\r?\n---\r?\n/; -function listMarkdownFiles(root) { - const files = []; +function listMarkdownFiles(root: string): string[] { + const files: string[] = []; for (const entry of fs.readdirSync(root, { withFileTypes: true })) { const fullPath = path.join(root, entry.name); @@ -40,7 +39,7 @@ function listMarkdownFiles(root) { describe("repo skill markdown files", () => { const markdownFiles = listMarkdownFiles(skillsRoot); - const generatedUserSkillFiles = markdownFiles.filter((file) => + const generatedUserSkillFiles = markdownFiles.filter((file: string) => path.relative(skillsRoot, file).startsWith("nemoclaw-user-"), ); @@ -64,7 +63,9 @@ describe("repo skill markdown files", () => { }); } - const skillFiles = generatedUserSkillFiles.filter((file) => path.basename(file) === "SKILL.md"); + const skillFiles = generatedUserSkillFiles.filter( + (file: string) => path.basename(file) === "SKILL.md", + ); for (const skillFile of skillFiles) { const relPath = path.relative(repoRoot, skillFile); @@ -73,6 +74,9 @@ describe("repo skill markdown files", () => { const match = raw.match(skillFrontmatterRe); expect(match, `${relPath} must start with YAML frontmatter`).not.toBeNull(); + if (!match) { + throw new Error(`${relPath} must start with YAML frontmatter`); + } const frontmatterText = match[1]; const doc = YAML.parseDocument(frontmatterText, { prettyErrors: true }); diff --git a/test/smoke-macos-install.test.ts b/test/smoke-macos-install.test.ts index a2179c5696f..a7910739a7b 100644 --- a/test/smoke-macos-install.test.ts +++ b/test/smoke-macos-install.test.ts @@ -1,4 +1,3 @@ -// @ts-nocheck // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 diff --git a/test/snapshot.test.ts b/test/snapshot.test.ts index 6812c33341e..a8cb468aca4 100644 --- a/test/snapshot.test.ts +++ b/test/snapshot.test.ts @@ -1,4 +1,3 @@ -// @ts-nocheck // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 // @@ -10,6 +9,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import { pathToFileURL } from "node:url"; import { describe, it, expect, afterAll, beforeEach } from "vitest"; // Override HOME BEFORE importing sandbox-state — it reads process.env.HOME @@ -21,12 +21,41 @@ const TMP_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-snap-naming-")) process.env.HOME = TMP_HOME; const REPO_ROOT = path.join(import.meta.dirname, ".."); -const sandboxState = await import(path.join(REPO_ROOT, "dist", "lib", "sandbox-state.js")); + +type BackupScalar = string | number | boolean | null | undefined; +type BackupValue = BackupScalar | BackupManifestOverrides | BackupValue[]; + +type SandboxStateModule = typeof import("../dist/lib/sandbox-state.js"); +type SandboxStateModuleCandidate = Partial | null; + +function isSandboxStateModule(value: SandboxStateModuleCandidate): value is SandboxStateModule { + return ( + value !== null && + typeof value.listBackups === "function" && + typeof value.findBackup === "function" && + typeof value.validateSnapshotName === "function" && + typeof value.parseRestoreArgs === "function" + ); +} + +const loadedSandboxState = await import( + pathToFileURL(path.join(REPO_ROOT, "dist", "lib", "sandbox-state.js")).href +); +if (!isSandboxStateModule(loadedSandboxState)) { + throw new Error("Expected sandbox-state module exports to be available"); +} +const sandboxState = loadedSandboxState; const { parseRestoreArgs } = sandboxState; const BACKUPS_ROOT = path.join(TMP_HOME, ".nemoclaw", "rebuild-backups"); -function writeBackup(sandboxName, dirName, overrides = {}) { +type BackupManifestOverrides = { [key: string]: BackupValue }; + +function writeBackup( + sandboxName: string, + dirName: string, + overrides: BackupManifestOverrides = {}, +): BackupManifestOverrides { const dir = path.join(BACKUPS_ROOT, sandboxName, dirName); fs.mkdirSync(dir, { recursive: true }); const manifest = { @@ -42,10 +71,7 @@ function writeBackup(sandboxName, dirName, overrides = {}) { blueprintDigest: null, ...overrides, }; - fs.writeFileSync( - path.join(dir, "rebuild-manifest.json"), - JSON.stringify(manifest, null, 2), - ); + fs.writeFileSync(path.join(dir, "rebuild-manifest.json"), JSON.stringify(manifest, null, 2)); return manifest; } @@ -115,6 +141,52 @@ describe("listBackups computes virtual versions", () => { expect(entry.name).toBe("before-upgrade"); expect(entry.snapshotVersion).toBe(1); }); + + it("preserves legacy manifests created before blueprintDigest existed", () => { + const dir = path.join(BACKUPS_ROOT, "test-sandbox", "2026-04-21T13-59-00-000Z"); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync( + path.join(dir, "rebuild-manifest.json"), + JSON.stringify({ + version: 1, + sandboxName: "test-sandbox", + timestamp: "2026-04-21T13-59-00-000Z", + agentType: "openclaw", + agentVersion: null, + expectedVersion: null, + stateDirs: [], + writableDir: "/sandbox/.openclaw-data", + backupPath: dir, + }), + ); + + const [entry] = sandboxState.listBackups("test-sandbox"); + expect(entry?.timestamp).toBe("2026-04-21T13-59-00-000Z"); + expect(entry?.blueprintDigest).toBeNull(); + }); + + it("ignores rebuild manifests with invalid typed fields", () => { + const dir = path.join(BACKUPS_ROOT, "test-sandbox", "2026-04-21T14-00-00-000Z"); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync( + path.join(dir, "rebuild-manifest.json"), + JSON.stringify({ + version: 1, + sandboxName: "test-sandbox", + timestamp: "2026-04-21T14-00-00-000Z", + agentType: "openclaw", + agentVersion: null, + expectedVersion: null, + stateDirs: [], + writableDir: "/sandbox/.openclaw-data", + backupPath: dir, + blueprintDigest: null, + policyPresets: [1], + }), + ); + + expect(sandboxState.listBackups("test-sandbox")).toEqual([]); + }); }); describe("findBackup", () => { @@ -221,23 +293,21 @@ describe("parseRestoreArgs", () => { }); it("preserves timestamp-shaped selectors alongside --to", () => { - expect( - parseRestoreArgs("src", [ - "restore", - "2026-04-21T14-00-00-000Z", - "--to", - "dst", - ]), - ).toEqual({ - ok: true, - targetSandbox: "dst", - selector: "2026-04-21T14-00-00-000Z", - }); + expect(parseRestoreArgs("src", ["restore", "2026-04-21T14-00-00-000Z", "--to", "dst"])).toEqual( + { + ok: true, + targetSandbox: "dst", + selector: "2026-04-21T14-00-00-000Z", + }, + ); }); it("rejects --to at end-of-args with no value", () => { const result = parseRestoreArgs("src", ["restore", "--to"]); expect(result.ok).toBe(false); + if (result.ok) { + throw new Error("Expected parseRestoreArgs() to reject a trailing --to flag"); + } expect(result.error).toMatch(/--to requires a target sandbox name/); }); @@ -246,6 +316,9 @@ describe("parseRestoreArgs", () => { // name and confuse validateName with an error about a weird name. const result = parseRestoreArgs("src", ["restore", "--to", "--other"]); expect(result.ok).toBe(false); + if (result.ok) { + throw new Error("Expected parseRestoreArgs() to reject --to without a target name"); + } expect(result.error).toMatch(/--to requires a target sandbox name/); }); diff --git a/test/ssh-known-hosts.test.ts b/test/ssh-known-hosts.test.ts index 736dbda424c..b85f9c94a3f 100644 --- a/test/ssh-known-hosts.test.ts +++ b/test/ssh-known-hosts.test.ts @@ -1,9 +1,33 @@ -// @ts-nocheck // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 import { describe, it, expect } from "vitest"; -import { pruneKnownHostsEntries } from "../dist/lib/onboard"; + +type OnboardKnownHostsInternals = { + pruneKnownHostsEntries: (contents: string) => string; +}; + +type OnboardKnownHostsCandidate = { + pruneKnownHostsEntries?: unknown; + default?: unknown; +} | null; + +function isOnboardKnownHostsInternals( + value: OnboardKnownHostsCandidate, +): value is OnboardKnownHostsInternals { + return value !== null && typeof value.pruneKnownHostsEntries === "function"; +} + +const loadedOnboardKnownHostsModule = await import("../dist/lib/onboard.js"); +const onboardKnownHostsInternals = isOnboardKnownHostsInternals(loadedOnboardKnownHostsModule) + ? loadedOnboardKnownHostsModule + : isOnboardKnownHostsInternals(loadedOnboardKnownHostsModule.default) + ? loadedOnboardKnownHostsModule.default + : null; +if (!isOnboardKnownHostsInternals(onboardKnownHostsInternals)) { + throw new Error("Expected onboard internals to expose pruneKnownHostsEntries"); +} +const { pruneKnownHostsEntries } = onboardKnownHostsInternals; describe("pruneKnownHostsEntries", () => { it("removes lines with openshell- hostnames", () => { diff --git a/test/stale-dist-check.test.ts b/test/stale-dist-check.test.ts index a2c494be6e1..6d6c8774c3f 100644 --- a/test/stale-dist-check.test.ts +++ b/test/stale-dist-check.test.ts @@ -20,7 +20,15 @@ function writeFile(p: string, content: string, mtimeMs: number) { fs.utimesSync(p, t, t); } -type Stream = { write(chunk: string): unknown }; +type Stream = { write(chunk: string): void | boolean }; + +function requireStaleResult(result: ReturnType) { + expect(result).not.toBeNull(); + if (!result) { + throw new Error("Expected stale dist result to be present"); + } + return result; +} describe("stale-dist-check", () => { let root = ""; @@ -42,9 +50,8 @@ describe("stale-dist-check", () => { it("flags stale when src is newer than dist", () => { writeFile(path.join(root, "dist", "lib", "foo.js"), "x", 1_000_000); writeFile(path.join(root, "src", "lib", "foo.ts"), "x", 5_000_000); - const result = checkStaleDist(root); - expect(result).not.toBeNull(); - expect(result!.srcMtime).toBeGreaterThan(result!.distMtime); + const result = requireStaleResult(checkStaleDist(root)); + expect(result.srcMtime).toBeGreaterThan(result.distMtime); }); it("ignores .test.ts files (they do not ship to dist/)", () => { @@ -77,7 +84,11 @@ describe("stale-dist-check", () => { writeFile(path.join(root, "dist", "lib", "foo.js"), "x", 1_000_000); writeFile(path.join(root, "src", "lib", "foo.ts"), "x", 5_000_000); const chunks: string[] = []; - const stream: Stream = { write: (chunk: string) => chunks.push(chunk) }; + const stream: Stream = { + write: (chunk: string) => { + chunks.push(chunk); + }, + }; expect(warnIfStale(root, stream)).toBe(true); const output = chunks.join(""); expect(output).toContain("npm run build:cli"); diff --git a/test/type-safety-hotspots.test.ts b/test/type-safety-hotspots.test.ts index a740a2363a7..453b2cf06fc 100644 --- a/test/type-safety-hotspots.test.ts +++ b/test/type-safety-hotspots.test.ts @@ -7,7 +7,11 @@ import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; -import { analyzeTypeSafetyHotspots, renderTextReport } from "../scripts/type-safety-hotspots"; +import { + analyzeTypeSafetyHotspots, + parseArgs, + renderTextReport, +} from "../scripts/type-safety-hotspots"; const tempDirs: string[] = []; @@ -169,4 +173,12 @@ export const configB = normalizeConfig("{}"); expect(textReport).toContain("src/config.ts"); expect(textReport).toContain("normalizeConfig"); }); + + it("rejects another flag in place of a --root value", () => { + expect(() => parseArgs(["--root", "--json"])).toThrow(/Missing value for --root/); + }); + + it("rejects another flag in place of a --project value", () => { + expect(() => parseArgs(["--project", "--json"])).toThrow(/Missing value for --project/); + }); }); diff --git a/test/uninstall.test.ts b/test/uninstall.test.ts index 6696e8bcbb6..5e7b407a90b 100644 --- a/test/uninstall.test.ts +++ b/test/uninstall.test.ts @@ -1,4 +1,3 @@ -// @ts-nocheck // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 @@ -10,7 +9,7 @@ import { spawnSync } from "node:child_process"; const UNINSTALL_SCRIPT = path.join(import.meta.dirname, "..", "uninstall.sh"); -function createFakeNpmEnv(tmp) { +function createFakeNpmEnv(tmp: string): Record { const fakeBin = path.join(tmp, "bin"); const npmPath = path.join(fakeBin, "npm"); fs.mkdirSync(fakeBin, { recursive: true }); diff --git a/test/usage-notice.test.ts b/test/usage-notice.test.ts index dc608e683cf..e1320e77db9 100644 --- a/test/usage-notice.test.ts +++ b/test/usage-notice.test.ts @@ -1,4 +1,3 @@ -// @ts-nocheck // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 @@ -21,7 +20,7 @@ const { describe("usage notice", () => { const originalIsTTY = process.stdin.isTTY; const originalHome = process.env.HOME; - let testHome = null; + let testHome: string | null = null; beforeEach(() => { testHome = fs.mkdtempSync(path.join(import.meta.dirname, "usage-notice-home-")); @@ -54,11 +53,11 @@ describe("usage notice", () => { }); it("requires the non-interactive acceptance flag", async () => { - const lines = []; + const lines: string[] = []; const ok = await ensureUsageNoticeConsent({ nonInteractive: true, acceptedByFlag: false, - writeLine: (line) => lines.push(line), + writeLine: (line: string) => lines.push(line), }); expect(ok).toBe(false); @@ -78,11 +77,11 @@ describe("usage notice", () => { }); it("cancels interactive onboarding unless the user types yes", async () => { - const lines = []; + const lines: string[] = []; const ok = await ensureUsageNoticeConsent({ nonInteractive: false, promptFn: async () => "no", - writeLine: (line) => lines.push(line), + writeLine: (line: string) => lines.push(line), }); expect(ok).toBe(false); @@ -102,7 +101,7 @@ describe("usage notice", () => { }); it("fails interactive mode without a tty", async () => { - const lines = []; + const lines: string[] = []; Object.defineProperty(process.stdin, "isTTY", { configurable: true, value: false, @@ -111,7 +110,7 @@ describe("usage notice", () => { const ok = await ensureUsageNoticeConsent({ nonInteractive: false, promptFn: async () => "yes", - writeLine: (line) => lines.push(line), + writeLine: (line: string) => lines.push(line), }); expect(ok).toBe(false); @@ -119,7 +118,7 @@ describe("usage notice", () => { }); it("renders url lines as terminal hyperlinks when tty output is available", () => { - const lines = []; + const lines: string[] = []; const originalStdoutIsTTY = process.stdout.isTTY; const originalStderrIsTTY = process.stderr.isTTY; const originalNoColor = process.env.NO_COLOR; @@ -136,7 +135,7 @@ describe("usage notice", () => { delete process.env.NO_COLOR; process.env.TERM = "xterm-256color"; - printUsageNotice(loadUsageNoticeConfig(), (line) => lines.push(line)); + printUsageNotice(loadUsageNoticeConfig(), (line: string) => lines.push(line)); } finally { Object.defineProperty(process.stdout, "isTTY", { configurable: true, diff --git a/test/validate-blueprint.test.ts b/test/validate-blueprint.test.ts index 4034568bae2..74d678503b3 100644 --- a/test/validate-blueprint.test.ts +++ b/test/validate-blueprint.test.ts @@ -17,15 +17,61 @@ const BASE_POLICY_PATH = new URL( "../nemoclaw-blueprint/policies/openclaw-sandbox.yaml", import.meta.url, ); -const REQUIRED_PROFILE_FIELDS = ["provider_type", "endpoint"] as const; - -const bp = YAML.parse(readFileSync(BLUEPRINT_PATH, "utf-8")) as Record; -const declared = Array.isArray(bp?.profiles) ? (bp.profiles as string[]) : []; -const defined = - (bp?.components as Record | undefined)?.inference != null - ? ((bp.components as Record).inference as Record).profiles as - Record> | undefined - : undefined; +const REQUIRED_PROFILE_FIELDS: ReadonlyArray = [ + "provider_type", + "endpoint", +]; + +type BlueprintProfile = { + provider_type?: string; + endpoint?: string; + dynamic_endpoint?: boolean; +}; + +type Blueprint = { + version?: string; + digest?: string; + profiles?: string[]; + components?: { + sandbox?: { image?: string | null }; + inference?: { profiles?: Record }; + }; +}; + +type Rule = { allow?: { method?: string; path?: string } }; +type Endpoint = { + host?: string; + port?: number; + protocol?: string; + enforcement?: string; + access?: string; + rules?: Rule[]; + binaries?: Array<{ path: string }>; +}; + +type PolicyEntry = { + name?: string; + endpoints?: Endpoint[]; + binaries?: Array<{ path: string }>; +}; + +type SandboxPolicy = { + version?: number; + network_policies?: Record; +}; + +type PolicyPreset = { + preset?: { name?: string; description?: string }; + network_policies?: Record; +}; + +function loadYaml(path: URL): T { + return YAML.parse(readFileSync(path, "utf-8")); +} + +const bp = loadYaml(BLUEPRINT_PATH); +const declared = Array.isArray(bp.profiles) ? bp.profiles : []; +const defined = bp.components?.inference?.profiles; describe("blueprint.yaml", () => { it("parses as a YAML mapping", () => { @@ -38,7 +84,7 @@ describe("blueprint.yaml", () => { it("has a non-empty components.inference.profiles mapping", () => { expect(defined).toBeDefined(); - expect(Object.keys(defined!).length).toBeGreaterThan(0); + expect(Object.keys(defined ?? {}).length).toBeGreaterThan(0); }); it("regression #1438: sandbox image is pinned by digest, not by mutable tag", () => { @@ -46,9 +92,7 @@ describe("blueprint.yaml", () => { // ":latest" — a registry compromise or accidental force-push could // silently swap the image. Pin via @sha256:... so the image cannot // change without a corresponding blueprint update. - const sandbox = (bp.components as Record | undefined)?.sandbox as - | { image?: unknown } - | undefined; + const sandbox = bp.components?.sandbox; const image = typeof sandbox?.image === "string" ? sandbox.image : ""; expect(image.length).toBeGreaterThan(0); expect(image).toContain("@sha256:"); @@ -73,9 +117,7 @@ describe("blueprint.yaml", () => { // Must be a sha256:<64-hex> string. expect(topLevelDigest).toMatch(/^sha256:[0-9a-f]{64}$/); - const sandbox = (bp.components as Record | undefined)?.sandbox as - | { image?: unknown } - | undefined; + const sandbox = bp.components?.sandbox; const image = typeof sandbox?.image === "string" ? sandbox.image : ""; const imageDigestMatch = image.match(/@sha256:([0-9a-f]{64})$/); expect(imageDigestMatch).not.toBeNull(); @@ -90,7 +132,7 @@ describe("blueprint.yaml", () => { describe(`profile '${name}'`, () => { it("has a definition", () => { expect(defined).toBeDefined(); - expect(name in defined!).toBe(true); + expect(name in (defined ?? {})).toBe(true); }); for (const field of REQUIRED_PROFILE_FIELDS) { @@ -115,7 +157,7 @@ describe("blueprint.yaml", () => { }); describe("base sandbox policy", () => { - const policy = YAML.parse(readFileSync(BASE_POLICY_PATH, "utf-8")) as Record; + const policy = loadYaml(BASE_POLICY_PATH); it("parses as a YAML mapping", () => { expect(policy).toEqual(expect.objectContaining({})); @@ -130,13 +172,13 @@ describe("base sandbox policy", () => { }); it("no endpoint rule uses wildcard method", () => { - const np = policy.network_policies as Record>; + const np = policy.network_policies ?? {}; const violations: string[] = []; for (const [policyName, cfg] of Object.entries(np)) { - const endpoints = cfg.endpoints as Array> | undefined; + const endpoints = cfg.endpoints; if (!endpoints) continue; for (const ep of endpoints) { - const rules = ep.rules as Array>> | undefined; + const rules = ep.rules; if (!rules) continue; for (const rule of rules) { const method = rule.allow?.method; @@ -150,10 +192,10 @@ describe("base sandbox policy", () => { }); it("every endpoint with rules has protocol: rest and enforcement: enforce", () => { - const np = policy.network_policies as Record>; + const np = policy.network_policies ?? {}; const violations: string[] = []; for (const [policyName, cfg] of Object.entries(np)) { - const endpoints = cfg.endpoints as Array> | undefined; + const endpoints = cfg.endpoints; if (!endpoints) continue; for (const ep of endpoints) { if (!ep.rules) continue; @@ -169,17 +211,13 @@ describe("base sandbox policy", () => { }); it("allows NVIDIA embeddings on both NVIDIA inference hosts", () => { - const np = policy.network_policies as Record>; - const endpoints = - np.nvidia?.endpoints as - | Array<{ host?: string; rules?: Array<{ allow?: { method?: string; path?: string } }> }> - | undefined; + const np = policy.network_policies ?? {}; + const endpoints = np.nvidia?.endpoints; const missingHosts: string[] = []; for (const host of ["integrate.api.nvidia.com", "inference-api.nvidia.com"]) { const endpoint = endpoints?.find((entry) => entry.host === host); const hasEmbeddingsRule = endpoint?.rules?.some( - (rule) => - rule.allow?.method === "POST" && rule.allow?.path === "/v1/embeddings", + (rule) => rule.allow?.method === "POST" && rule.allow?.path === "/v1/embeddings", ); if (!hasEmbeddingsRule) { missingHosts.push(host); @@ -190,21 +228,16 @@ describe("base sandbox policy", () => { // Walk every endpoint in every network_policies entry and return the // entries whose host matches `hostMatcher`. Used by the regressions below. - type Rule = { allow?: { method?: string; path?: string } }; - type Endpoint = { host?: string; rules?: Rule[] }; function findEndpoints(hostMatcher: (h: string) => boolean): Endpoint[] { const out: Endpoint[] = []; - const np = (policy as Record).network_policies; - if (!np || typeof np !== "object") return out; - for (const value of Object.values(np as Record)) { - if (!value || typeof value !== "object") continue; - const endpoints = (value as { endpoints?: unknown }).endpoints; + const np = policy.network_policies; + if (!np) return out; + for (const value of Object.values(np)) { + const endpoints = value.endpoints; if (!Array.isArray(endpoints)) continue; for (const ep of endpoints) { - if (ep && typeof ep === "object" && typeof (ep as Endpoint).host === "string") { - if (hostMatcher((ep as Endpoint).host as string)) { - out.push(ep as Endpoint); - } + if (typeof ep.host === "string" && hostMatcher(ep.host)) { + out.push(ep); } } } @@ -217,7 +250,11 @@ describe("base sandbox policy", () => { for (const ep of sentryEndpoints) { const rules = Array.isArray(ep.rules) ? ep.rules : []; const hasPost = rules.some( - (r) => r && r.allow && typeof r.allow.method === "string" && r.allow.method.toUpperCase() === "POST", + (r) => + r && + r.allow && + typeof r.allow.method === "string" && + r.allow.method.toUpperCase() === "POST", ); expect(hasPost).toBe(false); } @@ -228,7 +265,11 @@ describe("base sandbox policy", () => { for (const ep of sentryEndpoints) { const rules = Array.isArray(ep.rules) ? ep.rules : []; const hasGet = rules.some( - (r) => r && r.allow && typeof r.allow.method === "string" && r.allow.method.toUpperCase() === "GET", + (r) => + r && + r.allow && + typeof r.allow.method === "string" && + r.allow.method.toUpperCase() === "GET", ); expect(hasGet).toBe(true); } @@ -242,23 +283,21 @@ describe("base sandbox policy", () => { // assertion blocks the regression where someone re-adds a github // entry to the base policy and silently re-grants every sandbox // unscoped GitHub access. - const np = policy.network_policies as Record | undefined; - expect(np && typeof np === "object" && "github" in np).toBe(false); + const np = policy.network_policies; + expect(np && "github" in np).toBe(false); // Belt and braces: also assert no endpoint in any base-policy // entry references github.com or api.github.com, so the // regression can't be smuggled in under a renamed key. - const githubHosts = findEndpoints( - (h) => h === "github.com" || h === "api.github.com", - ); + const githubHosts = findEndpoints((h) => h === "github.com" || h === "api.github.com"); expect(githubHosts).toEqual([]); }); it("regression #1458: baseline npm_registry must not include npm or node binaries", () => { - const np = policy.network_policies as Record>; + const np = policy.network_policies ?? {}; const npmRegistry = np.npm_registry; expect(npmRegistry).toBeDefined(); - const binaries = npmRegistry.binaries as Array<{ path: string }> | undefined; + const binaries = npmRegistry?.binaries; expect(Array.isArray(binaries)).toBe(true); const paths = (binaries ?? []).map((b) => b.path).sort(); // Only openclaw CLI should reach the npm registry by default. @@ -278,12 +317,11 @@ describe("github preset", () => { ); it("regression #1583: github preset file exists and parses", () => { - const raw = readFileSync(PRESET_PATH, "utf-8"); - const parsed = YAML.parse(raw) as Record; + const parsed = loadYaml(PRESET_PATH); expect(parsed).toEqual(expect.objectContaining({})); - const meta = parsed.preset as { name?: unknown } | undefined; + const meta = parsed.preset; expect(meta?.name).toBe("github"); - const np = parsed.network_policies as Record | undefined; + const np = parsed.network_policies; expect(np && "github" in np).toBe(true); }); }); @@ -302,18 +340,13 @@ describe("huggingface preset", () => { "../nemoclaw-blueprint/policies/presets/huggingface.yaml", import.meta.url, ); - const huggingfacePreset = YAML.parse( - readFileSync(HUGGINGFACE_PRESET_PATH, "utf-8"), - ) as Record; - - type Rule = { allow?: { method?: string; path?: string } }; - type Endpoint = { host?: string; rules?: Rule[] }; + const huggingfacePreset = loadYaml(HUGGINGFACE_PRESET_PATH); function presetEndpoints(): Endpoint[] { - const np = huggingfacePreset.network_policies as Record | undefined; + const np = huggingfacePreset.network_policies; if (!np) return []; - const hf = np.huggingface as { endpoints?: unknown } | undefined; - return Array.isArray(hf?.endpoints) ? (hf!.endpoints as Endpoint[]) : []; + const hf = np.huggingface; + return Array.isArray(hf?.endpoints) ? hf.endpoints : []; } it("regression #1432: huggingface.co has no POST allow rule", () => { @@ -323,7 +356,10 @@ describe("huggingface preset", () => { const rules = Array.isArray(ep.rules) ? ep.rules : []; const hasPost = rules.some( (r) => - r && r.allow && typeof r.allow.method === "string" && r.allow.method.toUpperCase() === "POST", + r && + r.allow && + typeof r.allow.method === "string" && + r.allow.method.toUpperCase() === "POST", ); expect(hasPost).toBe(false); } @@ -335,7 +371,10 @@ describe("huggingface preset", () => { const rules = Array.isArray(ep.rules) ? ep.rules : []; const hasGet = rules.some( (r) => - r && r.allow && typeof r.allow.method === "string" && r.allow.method.toUpperCase() === "GET", + r && + r.allow && + typeof r.allow.method === "string" && + r.allow.method.toUpperCase() === "GET", ); expect(hasGet).toBe(true); } diff --git a/test/validate-config-schemas.test.ts b/test/validate-config-schemas.test.ts index 1b802eaa981..f810f988093 100644 --- a/test/validate-config-schemas.test.ts +++ b/test/validate-config-schemas.test.ts @@ -22,26 +22,68 @@ function repoPath(...segments: string[]): string { return join(REPO_ROOT, ...segments); } -function loadYAML(path: string): unknown { - return YAML.parse(readFileSync(path, "utf-8")); +type LooseScalar = string | number | boolean | null; +type LooseValue = LooseScalar | LooseObject | LooseValue[]; +type LooseObject = { [key: string]: LooseValue }; + +function parseJson(text: string): T { + return JSON.parse(text); +} + +function isLooseValue(value: LooseValue | object | undefined): value is LooseValue { + if (value === null) return true; + if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") { + return true; + } + if (Array.isArray(value)) { + return value.every((entry) => isLooseValue(entry)); + } + return isLooseObject(value); } -function loadJSON(path: string): unknown { - return JSON.parse(readFileSync(path, "utf-8")); +function isLooseObject(value: LooseValue | object | undefined): value is LooseObject { + return ( + typeof value === "object" && + value !== null && + !Array.isArray(value) && + Object.values(value).every((entry) => isLooseValue(entry)) + ); +} + +function loadYAML(path: string): LooseObject { + const parsed = YAML.parse(readFileSync(path, "utf-8")); + if (!isLooseObject(parsed)) { + throw new Error(`Expected YAML object in ${path}`); + } + return parsed; +} + +function loadJSON(path: string): LooseObject { + const parsed = parseJson(readFileSync(path, "utf-8")); + if (!isLooseObject(parsed)) { + throw new Error(`Expected JSON object in ${path}`); + } + return parsed; } function compileSchema(schemaRelPath: string): ValidateFunction { const ajv = new Ajv({ allErrors: true, strict: false }); const schema = loadJSON(repoPath(schemaRelPath)); - return ajv.compile(schema as object); + return ajv.compile(schema); +} + +function asRecord(value: LooseValue | undefined): LooseObject { + return isLooseObject(value) ? value : {}; +} + +function cloneObject(value: LooseObject | undefined): LooseObject { + return { ...asRecord(value) }; } -function expectValid(validate: ValidateFunction, data: unknown, label: string): void { +function expectValid(validate: ValidateFunction, data: object, label: string): void { const valid = validate(data); if (!valid) { - const messages = (validate.errors ?? []).map( - (e) => ` ${e.instancePath || "/"}: ${e.message}`, - ); + const messages = (validate.errors ?? []).map((e) => ` ${e.instancePath || "/"}: ${e.message}`); expect.unreachable(`${label} failed schema validation:\n${messages.join("\n")}`); } } @@ -57,28 +99,31 @@ describe("blueprint.schema.json", () => { }); it("rejects blueprint with missing required field", () => { - const bad = { ...(data as object) }; - delete (bad as Record).version; + const bad = cloneObject(data); + delete bad.version; expect(validate(bad)).toBe(false); }); it("rejects blueprint with wrong type for version", () => { - const bad = { ...(data as object), version: 123 }; + const bad = { ...cloneObject(data), version: 123 }; expect(validate(bad)).toBe(false); }); it("rejects blueprint with unknown top-level property", () => { - const bad = { ...(data as object), unknownField: true }; + const bad = { ...cloneObject(data), unknownField: true }; expect(validate(bad)).toBe(false); }); it("rejects blueprint with unknown nested component property", () => { + const root = asRecord(data); + const components = asRecord(root.components); + const inference = asRecord(components.inference); const bad = { - ...(data as object), + ...root, components: { - ...((data as Record).components), + ...components, inference: { - ...((data as Record).components.inference), + ...inference, extraField: true, }, }, @@ -87,16 +132,21 @@ describe("blueprint.schema.json", () => { }); it("rejects blueprint inference profile with unknown property", () => { + const root = asRecord(data); + const components = asRecord(root.components); + const inference = asRecord(components.inference); + const profiles = asRecord(inference.profiles); + const defaultProfile = asRecord(profiles.default); const bad = { - ...(data as object), + ...root, components: { - ...((data as Record).components), + ...components, inference: { - ...((data as Record).components.inference), + ...inference, profiles: { - ...((data as Record).components.inference.profiles), + ...profiles, default: { - ...((data as Record).components.inference.profiles.default), + ...defaultProfile, typoField: true, }, }, @@ -136,22 +186,20 @@ describe("blueprint.schema.json", () => { describe("sandbox-policy.schema.json", () => { const validate = compileSchema("schemas/sandbox-policy.schema.json"); - const data = loadYAML( - repoPath("nemoclaw-blueprint/policies/openclaw-sandbox.yaml"), - ); + const data = loadYAML(repoPath("nemoclaw-blueprint/policies/openclaw-sandbox.yaml")); it("openclaw-sandbox.yaml passes schema validation", () => { expectValid(validate, data, "openclaw-sandbox.yaml"); }); it("rejects policy with missing network_policies", () => { - const bad = { ...(data as object) }; - delete (bad as Record).network_policies; + const bad = cloneObject(data); + delete bad.network_policies; expect(validate(bad)).toBe(false); }); it("rejects policy with unknown top-level property", () => { - const bad = { ...(data as object), extra: true }; + const bad = { ...cloneObject(data), extra: true }; expect(validate(bad)).toBe(false); }); @@ -179,7 +227,8 @@ describe("policy-preset.schema.json", () => { try { presetFiles = readdirSync(presetsDir).filter((f) => f.endsWith(".yaml") || f.endsWith(".yml")); } catch (err) { - if ((err as { code?: string }).code !== "ENOENT") throw err; + const code = typeof err === "object" && err !== null && "code" in err ? err.code : undefined; + if (code !== "ENOENT") throw err; // directory may not exist } @@ -191,7 +240,11 @@ describe("policy-preset.schema.json", () => { } it("rejects preset without preset metadata", () => { - const bad = { network_policies: { test: { name: "test", endpoints: [{ host: "a.com", port: 443, access: "full" }] } } }; + const bad = { + network_policies: { + test: { name: "test", endpoints: [{ host: "a.com", port: 443, access: "full" }] }, + }, + }; expect(validate(bad)).toBe(false); }); @@ -225,13 +278,13 @@ describe("openclaw-plugin.schema.json", () => { }); it("rejects plugin with missing id", () => { - const bad = { ...(data as object) }; - delete (bad as Record).id; + const bad = cloneObject(data); + delete bad.id; expect(validate(bad)).toBe(false); }); it("rejects plugin with invalid version format", () => { - const bad = { ...(data as object), version: "not-semver" }; + const bad = { ...cloneObject(data), version: "not-semver" }; expect(validate(bad)).toBe(false); }); }); diff --git a/test/wsl2-probe-timeout.test.ts b/test/wsl2-probe-timeout.test.ts index 75f3811a620..d9fea4d3e00 100644 --- a/test/wsl2-probe-timeout.test.ts +++ b/test/wsl2-probe-timeout.test.ts @@ -1,4 +1,3 @@ -// @ts-nocheck // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 @@ -6,7 +5,31 @@ import { describe, it, expect } from "vitest"; import fs from "node:fs"; import path from "node:path"; -import { getValidationProbeCurlArgs } from "../dist/lib/onboard"; +type OnboardValidationInternals = { + getValidationProbeCurlArgs: (opts?: { isWsl?: boolean }) => string[]; +}; + +type OnboardValidationCandidate = { + getValidationProbeCurlArgs?: unknown; + default?: unknown; +} | null; + +function isOnboardValidationInternals( + value: OnboardValidationCandidate, +): value is OnboardValidationInternals { + return value !== null && typeof value.getValidationProbeCurlArgs === "function"; +} + +const loadedOnboardValidationModule = await import("../dist/lib/onboard.js"); +const onboardValidationInternals = isOnboardValidationInternals(loadedOnboardValidationModule) + ? loadedOnboardValidationModule + : isOnboardValidationInternals(loadedOnboardValidationModule.default) + ? loadedOnboardValidationModule.default + : null; +if (!isOnboardValidationInternals(onboardValidationInternals)) { + throw new Error("Expected onboard validation internals to expose getValidationProbeCurlArgs"); +} +const { getValidationProbeCurlArgs } = onboardValidationInternals; describe("WSL2 inference verification timeouts (issue #987)", () => { describe("getValidationProbeCurlArgs", () => {