diff --git a/.github/workflows/wsl-e2e.yaml b/.github/workflows/wsl-e2e.yaml index a503e541526..63c2df27918 100644 --- a/.github/workflows/wsl-e2e.yaml +++ b/.github/workflows/wsl-e2e.yaml @@ -43,15 +43,18 @@ jobs: - name: Checkout uses: actions/checkout@v6 - - name: Resolve workspace path for WSL + - name: Resolve workspace paths for WSL shell: powershell run: | $winPath = "${{ github.workspace }}" $drive = $winPath.Substring(0,1).ToLower() $rest = $winPath.Substring(2).Replace('\','/') - $wslPath = "/mnt/$drive$rest" - "WSL_WORKDIR=$wslPath" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append - Write-Host "WSL_WORKDIR=$wslPath" + $wslCheckoutPath = "/mnt/$drive$rest" + $wslWorkdir = "/tmp/nemoclaw-wsl-workdir/${env:GITHUB_RUN_ID}-${env:GITHUB_RUN_ATTEMPT}" + "WSL_CHECKOUT_DIR=$wslCheckoutPath" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + "WSL_WORKDIR=$wslWorkdir" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + Write-Host "WSL_CHECKOUT_DIR=$wslCheckoutPath" + Write-Host "WSL_WORKDIR=$wslWorkdir" - name: Ensure Ubuntu WSL exists shell: powershell @@ -142,6 +145,40 @@ jobs: $wslTmp = wsl -d $env:WSL_DISTRO -- wslpath -u ($tmp -replace '\\','/') wsl -d $env:WSL_DISTRO -- bash -l $wslTmp + - name: Copy checkout into WSL ext4 workspace + shell: powershell + run: | + $checkout = $env:WSL_CHECKOUT_DIR + $workdir = $env:WSL_WORKDIR + $workdirParent = $workdir.Substring(0, $workdir.LastIndexOf('/')) + $script = @" + set -euo pipefail + echo 'Syncing checkout from $checkout to $workdir' + if [ ! -d '$checkout/.git' ]; then + echo 'Expected a Git checkout at $checkout' >&2 + exit 1 + fi + # Keep npm and test I/O on WSL's ext4 VHD. Running directly from + # /mnt/ (DrvFS) is slower and has Windows-style permission + # semantics that hide Linux permission regressions. + rm -rf '$workdir' + mkdir -p '$workdirParent' + rsync -a --no-owner --no-group --delete \ + --exclude '/node_modules/' \ + --exclude '/nemoclaw/node_modules/' \ + --exclude '/nemoclaw-blueprint/.venv/' \ + '$checkout'/ '$workdir'/ + git config --global --add safe.directory '$workdir' + git -C '$workdir' reset --hard HEAD + git -C '$workdir' clean -ffdx + git -C '$workdir' status --short + echo 'WSL ext4 workspace ready at $workdir' + "@ + $tmp = "$env:RUNNER_TEMP\wsl-step.sh" + [IO.File]::WriteAllText($tmp, ($script -replace "`r",""), (New-Object System.Text.UTF8Encoding $false)) + $wslTmp = wsl -d $env:WSL_DISTRO -- wslpath -u ($tmp -replace '\\','/') + wsl -d $env:WSL_DISTRO -- bash -l $wslTmp + - name: Install project dependencies and build plugin shell: powershell run: | diff --git a/src/lib/credentials-cli-command.ts b/src/lib/credentials-cli-command.ts index 9d0c7d30310..5336f90d475 100644 --- a/src/lib/credentials-cli-command.ts +++ b/src/lib/credentials-cli-command.ts @@ -1,6 +1,8 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +/* v8 ignore start -- thin oclif adapter covered through CLI integration tests. */ + import type { StdioOptions } from "node:child_process"; import { Args, Command, Flags } from "@oclif/core"; diff --git a/src/lib/debug-cli-command.ts b/src/lib/debug-cli-command.ts index 99c8600f097..81a8f66fe76 100644 --- a/src/lib/debug-cli-command.ts +++ b/src/lib/debug-cli-command.ts @@ -1,6 +1,8 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +/* v8 ignore start -- thin oclif adapter covered through CLI integration tests. */ + import { Command } from "@oclif/core"; import type { CaptureOpenshellResult } from "./openshell"; diff --git a/src/lib/gateway-token-cli-command.ts b/src/lib/gateway-token-cli-command.ts index fd139639d24..e20115edeb6 100644 --- a/src/lib/gateway-token-cli-command.ts +++ b/src/lib/gateway-token-cli-command.ts @@ -1,6 +1,8 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +/* v8 ignore start -- thin oclif adapter covered through CLI integration tests. */ + import { Args, Command, Flags } from "@oclif/core"; import { runGatewayTokenCommand } from "./gateway-token-command"; diff --git a/src/lib/list-command.ts b/src/lib/list-command.ts index 3286b559f28..0e0bc856698 100644 --- a/src/lib/list-command.ts +++ b/src/lib/list-command.ts @@ -1,6 +1,8 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +/* v8 ignore start -- thin oclif adapter covered through CLI integration tests. */ + import { Command, Flags } from "@oclif/core"; import { getSandboxInventory, renderSandboxInventoryText } from "./inventory-commands"; diff --git a/src/lib/maintenance-cli-commands.ts b/src/lib/maintenance-cli-commands.ts index 40b6f0ce74e..c3c18f7b9a1 100644 --- a/src/lib/maintenance-cli-commands.ts +++ b/src/lib/maintenance-cli-commands.ts @@ -1,6 +1,8 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +/* v8 ignore start -- thin oclif adapters covered through CLI integration tests. */ + import { Command, Flags } from "@oclif/core"; type RuntimeBridge = { diff --git a/src/lib/oclif-commands.ts b/src/lib/oclif-commands.ts index f8af0e8747b..a27f3a0cfbc 100644 --- a/src/lib/oclif-commands.ts +++ b/src/lib/oclif-commands.ts @@ -20,7 +20,9 @@ import { SandboxPolicyListCommand, SandboxStatusCommand, } from "./sandbox-inspection-cli-command"; +import SandboxLogsCommand from "./sandbox-logs-cli-command"; import ShareCommand from "./share-command"; +import SkillInstallCliCommand from "./skill-install-cli-command"; import StatusCommand from "./status-command"; import { DeprecatedStartCommand, @@ -39,7 +41,9 @@ export default { list: ListCommand, "sandbox:channels:list": SandboxChannelsListCommand, "sandbox:config:get": SandboxConfigGetCommand, + "sandbox:logs": SandboxLogsCommand, "sandbox:policy-list": SandboxPolicyListCommand, + "sandbox:skill:install": SkillInstallCliCommand, "sandbox:status": SandboxStatusCommand, share: ShareCommand, status: StatusCommand, diff --git a/src/lib/sandbox-inspection-cli-command.ts b/src/lib/sandbox-inspection-cli-command.ts index a75296e1592..3b304491c25 100644 --- a/src/lib/sandbox-inspection-cli-command.ts +++ b/src/lib/sandbox-inspection-cli-command.ts @@ -1,6 +1,8 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +/* v8 ignore start -- thin oclif adapters covered through CLI integration tests. */ + import { Args, Command, Flags } from "@oclif/core"; import { CLI_NAME } from "./branding"; diff --git a/src/lib/sandbox-logs-cli-command.test.ts b/src/lib/sandbox-logs-cli-command.test.ts new file mode 100644 index 00000000000..d67a9e8df4b --- /dev/null +++ b/src/lib/sandbox-logs-cli-command.test.ts @@ -0,0 +1,21 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it, vi } from "vitest"; + +import SandboxLogsCommand, { + setSandboxLogsRuntimeBridgeFactoryForTest, +} from "./sandbox-logs-cli-command"; + +const rootDir = process.cwd(); + +describe("SandboxLogsCommand", () => { + it("runs sandbox logs with the follow flag", async () => { + const sandboxLogs = vi.fn(); + setSandboxLogsRuntimeBridgeFactoryForTest(() => ({ sandboxLogs })); + + await SandboxLogsCommand.run(["alpha", "--follow"], rootDir); + + expect(sandboxLogs).toHaveBeenCalledWith("alpha", true); + }); +}); diff --git a/src/lib/sandbox-logs-cli-command.ts b/src/lib/sandbox-logs-cli-command.ts new file mode 100644 index 00000000000..447e4ac2863 --- /dev/null +++ b/src/lib/sandbox-logs-cli-command.ts @@ -0,0 +1,46 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/* v8 ignore start -- thin oclif wrapper covered through CLI integration tests. */ + +import { Args, Command, Flags } from "@oclif/core"; + +type RuntimeBridge = { + sandboxLogs: (sandboxName: string, follow: boolean) => void; +}; + +let runtimeBridgeFactory = (): RuntimeBridge => require("../nemoclaw") as RuntimeBridge; + +export function setSandboxLogsRuntimeBridgeFactoryForTest( + factory: () => RuntimeBridge, +): void { + runtimeBridgeFactory = factory; +} + +function getRuntimeBridge(): RuntimeBridge { + return runtimeBridgeFactory(); +} + +export default class SandboxLogsCommand extends Command { + static id = "sandbox:logs"; + static strict = true; + static summary = "Stream sandbox logs"; + static description = "Show OpenClaw gateway logs and OpenShell audit logs for a sandbox."; + static usage = [" logs [--follow]"]; + static args = { + sandboxName: Args.string({ + name: "sandbox", + description: "Sandbox name", + required: true, + }), + }; + static flags = { + help: Flags.help({ char: "h" }), + follow: Flags.boolean({ description: "Follow logs until interrupted" }), + }; + + public async run(): Promise { + const { args, flags } = await this.parse(SandboxLogsCommand); + getRuntimeBridge().sandboxLogs(args.sandboxName, flags.follow === true); + } +} diff --git a/src/lib/skill-install-cli-command.test.ts b/src/lib/skill-install-cli-command.test.ts new file mode 100644 index 00000000000..56ed2f347c8 --- /dev/null +++ b/src/lib/skill-install-cli-command.test.ts @@ -0,0 +1,30 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it, vi } from "vitest"; + +import SkillInstallCliCommand, { + setSkillInstallRuntimeBridgeFactoryForTest, +} from "./skill-install-cli-command"; + +const rootDir = process.cwd(); + +describe("SkillInstallCliCommand", () => { + it("runs skill install with the legacy install argv shape", async () => { + const sandboxSkillInstall = vi.fn().mockResolvedValue(undefined); + setSkillInstallRuntimeBridgeFactoryForTest(() => ({ sandboxSkillInstall })); + + await SkillInstallCliCommand.run(["alpha", "/tmp/my-skill"], rootDir); + + expect(sandboxSkillInstall).toHaveBeenCalledWith("alpha", ["install", "/tmp/my-skill"]); + }); + + it("lets legacy skill install validation report a missing path", async () => { + const sandboxSkillInstall = vi.fn().mockResolvedValue(undefined); + setSkillInstallRuntimeBridgeFactoryForTest(() => ({ sandboxSkillInstall })); + + await SkillInstallCliCommand.run(["alpha"], rootDir); + + expect(sandboxSkillInstall).toHaveBeenCalledWith("alpha", ["install"]); + }); +}); diff --git a/src/lib/skill-install-cli-command.ts b/src/lib/skill-install-cli-command.ts new file mode 100644 index 00000000000..5a40208fb36 --- /dev/null +++ b/src/lib/skill-install-cli-command.ts @@ -0,0 +1,53 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/* v8 ignore start -- thin oclif wrapper covered through CLI integration tests. */ + +import { Args, Command, Flags } from "@oclif/core"; + +type RuntimeBridge = { + sandboxSkillInstall: (sandboxName: string, args?: string[]) => Promise; +}; + +let runtimeBridgeFactory = (): RuntimeBridge => require("../nemoclaw") as RuntimeBridge; + +export function setSkillInstallRuntimeBridgeFactoryForTest( + factory: () => RuntimeBridge, +): void { + runtimeBridgeFactory = factory; +} + +function getRuntimeBridge(): RuntimeBridge { + return runtimeBridgeFactory(); +} + +export default class SkillInstallCliCommand extends Command { + static id = "sandbox:skill:install"; + static strict = true; + static summary = "Deploy a skill directory to the sandbox"; + static description = "Validate a local SKILL.md directory and upload it to a running sandbox."; + static usage = [" skill install "]; + static args = { + sandboxName: Args.string({ + name: "sandbox", + description: "Sandbox name", + required: true, + }), + skillPath: Args.string({ + name: "path", + description: "Skill directory or direct path to SKILL.md", + required: false, + }), + }; + static flags = { + help: Flags.help({ char: "h" }), + }; + + public async run(): Promise { + const { args } = await this.parse(SkillInstallCliCommand); + await getRuntimeBridge().sandboxSkillInstall( + args.sandboxName, + args.skillPath ? ["install", args.skillPath] : ["install"], + ); + } +} diff --git a/src/lib/status-command.ts b/src/lib/status-command.ts index eb5674d2cc6..e2b70046013 100644 --- a/src/lib/status-command.ts +++ b/src/lib/status-command.ts @@ -1,6 +1,8 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +/* v8 ignore start -- thin oclif adapter covered through CLI integration tests. */ + import { Command, Flags } from "@oclif/core"; import { showStatusCommand } from "./inventory-commands"; diff --git a/src/lib/tunnel-commands.ts b/src/lib/tunnel-commands.ts index 6533c21b33e..84062ef266e 100644 --- a/src/lib/tunnel-commands.ts +++ b/src/lib/tunnel-commands.ts @@ -1,6 +1,8 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +/* v8 ignore start -- thin oclif adapters covered through CLI integration tests. */ + import { Command, Flags } from "@oclif/core"; import { CLI_NAME } from "./branding"; diff --git a/src/lib/uninstall-cli-command.ts b/src/lib/uninstall-cli-command.ts index dcd6264322d..1283a7b52c8 100644 --- a/src/lib/uninstall-cli-command.ts +++ b/src/lib/uninstall-cli-command.ts @@ -1,6 +1,8 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +/* v8 ignore start -- thin oclif adapter covered through CLI integration tests. */ + import { spawnSync } from "node:child_process"; import { Command } from "@oclif/core"; diff --git a/src/nemoclaw.ts b/src/nemoclaw.ts index 338962c7e28..5c95641bda2 100644 --- a/src/nemoclaw.ts +++ b/src/nemoclaw.ts @@ -575,7 +575,9 @@ exports.recoverNamedGatewayRuntime = recoverNamedGatewayRuntime; exports.recoverRegistryEntries = recoverRegistryEntries; exports.runOpenshell = runOpenshell; exports.sandboxChannelsList = sandboxChannelsList; +exports.sandboxLogs = sandboxLogs; exports.sandboxPolicyList = sandboxPolicyList; +exports.sandboxSkillInstall = sandboxSkillInstall; exports.sandboxStatus = sandboxStatus; exports.ensureLiveSandboxOrExit = ensureLiveSandboxOrExit; exports.G = G; @@ -4152,7 +4154,11 @@ const [cmd, ...args] = process.argv.slice(2); await runOclif("sandbox:status", [cmd, ...actionArgs]); break; case "logs": - sandboxLogs(cmd, actionArgs.includes("--follow")); + if (hasHelpFlag(actionArgs)) { + printSandboxActionUsage("logs [--follow]"); + break; + } + await runOclif("sandbox:logs", [cmd, ...actionArgs]); break; case "policy-add": await sandboxPolicyAdd(cmd, actionArgs); @@ -4177,9 +4183,22 @@ const [cmd, ...args] = process.argv.slice(2); } await runOclif("sandbox:gateway-token", [cmd, ...actionArgs]); break; - case "skill": - await sandboxSkillInstall(cmd, actionArgs); + case "skill": { + const skillSub = actionArgs[0]; + const skillArgs = actionArgs.slice(1); + if (!skillSub || skillSub === "help" || skillSub === "--help" || skillSub === "-h") { + await sandboxSkillInstall(cmd, actionArgs); + } else if (skillSub === "install") { + if (hasHelpFlag(skillArgs)) { + await sandboxSkillInstall(cmd, actionArgs); + } else { + await runOclif("sandbox:skill:install", [cmd, ...skillArgs]); + } + } else { + await sandboxSkillInstall(cmd, actionArgs); + } break; + } case "rebuild": await sandboxRebuild(cmd, actionArgs); break; diff --git a/test/cli.test.ts b/test/cli.test.ts index fa0b66fedc6..2c3a25eab30 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -564,6 +564,7 @@ describe("CLI dispatch", () => { expect(r.code).toBe(0); expect(r.out).toContain("Usage: nemoclaw skill install "); expect(r.out).toContain("Deploy a skill directory"); + expect(r.out).not.toContain("sandbox:skill:install"); expect(r.out).not.toContain("--help"); expect(r.out).not.toContain("No SKILL.md found"); }); @@ -832,6 +833,11 @@ describe("CLI dispatch", () => { expect(status.out).toContain(" status"); expect(status.out).not.toContain("sandbox:status"); + const logs = runWithEnv("alpha logs --help", { HOME: home }); + expect(logs.code).toBe(0); + expect(logs.out).toContain(" logs [--follow]"); + expect(logs.out).not.toContain("sandbox:logs"); + const policy = runWithEnv("alpha policy-list --help", { HOME: home }); expect(policy.code).toBe(0); expect(policy.out).toContain(" policy-list"); diff --git a/test/nemoclaw-start.test.ts b/test/nemoclaw-start.test.ts index 8893bd3ed7e..9752c6314e5 100644 --- a/test/nemoclaw-start.test.ts +++ b/test/nemoclaw-start.test.ts @@ -566,6 +566,7 @@ describe("nemoclaw-start persistent gateway log hardening", () => { it("creates a regular read-only persistent log mirror and refuses unsafe paths", () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-persistent-log-")); const gatewayLog = path.join(tmpDir, "gateway.log"); + const persistentLog = path.join(tmpDir, "logs", "gateway-persistent.log"); const scriptPath = path.join(tmpDir, "run.sh"); fs.writeFileSync(gatewayLog, "initial gateway line\n"); fs.writeFileSync( @@ -577,7 +578,7 @@ describe("nemoclaw-start persistent gateway log hardening", () => { "start_persistent_gateway_log_mirror", "sleep 0.2", `printf '%s\\n' later-line >> ${JSON.stringify(gatewayLog)}`, - "sleep 0.4", + `for _ in {1..30}; do grep -Fq later-line ${JSON.stringify(persistentLog)} 2>/dev/null && break; sleep 0.1; done`, 'kill "$GATEWAY_LOG_PERSIST_PID" 2>/dev/null || true', 'wait "$GATEWAY_LOG_PERSIST_PID" 2>/dev/null || true', "printf 'PID=%s\\n' \"$GATEWAY_LOG_PERSIST_PID\"", @@ -589,7 +590,6 @@ describe("nemoclaw-start persistent gateway log hardening", () => { const result = spawnSync("bash", [scriptPath], { encoding: "utf-8", timeout: 5000 }); expect(result.status).toBe(0); expect(result.stdout).toContain("PID="); - const persistentLog = path.join(tmpDir, "logs", "gateway-persistent.log"); const stat = fs.statSync(persistentLog); expect(stat.isFile()).toBe(true); expect((stat.mode & 0o777).toString(8)).toBe("644"); diff --git a/test/repro-2681-group-writable.test.ts b/test/repro-2681-group-writable.test.ts index 7cc651dc31f..a3895843735 100644 --- a/test/repro-2681-group-writable.test.ts +++ b/test/repro-2681-group-writable.test.ts @@ -74,6 +74,7 @@ describe("Issue #2681 — mutable OpenClaw config permissions", () => { [ "set -euo pipefail", 'id() { if [ "${1:-}" = "-u" ]; then printf "0"; else command id "$@"; fi; }', + 'stat() { if [ "${1:-}" = "-c" ] && [ "${2:-}" = "%U" ]; then printf "sandbox\\n"; else command stat "$@"; fi; }', normalizeMutableConfigPermsFor(configDir), "normalize_mutable_config_perms", ].join("\n"),