diff --git a/.github/workflows/e2e.yaml b/.github/workflows/e2e.yaml index f92698316d..8d0a63ab29 100644 --- a/.github/workflows/e2e.yaml +++ b/.github/workflows/e2e.yaml @@ -4040,6 +4040,33 @@ jobs: - *dockerhub-auth + # The #6194 terminal regression uses Expect as its PTY driver. Keep this + # privileged host setup inline in the reviewed workflow: the selected + # target ref may consume expect but cannot expand the package allowlist. + # invalidState: hosted-runner Ubuntu mirrors can fail transiently during update. + # sourceBoundary: only this trusted workflow chooses the exact root-installed package. + # whyNotSourceFix: GitHub's Ubuntu image and configured repository move together, so a + # fixed package version would make the target brittle across routine runner refreshes. + # The runner's configured Ubuntu repository is therefore an accepted trust source. + # regressionTest: e2e-host-dependency-workflow-boundary rejects package or order drift. + # removalCondition: remove this step when the hosted image provides Expect itself. + - name: Install OpenClaw TUI host dependencies + shell: bash + run: | + set -euo pipefail + for attempt in 1 2 3; do + if sudo apt-get update; then + break + fi + if [ "$attempt" -eq 3 ]; then + echo "::error::apt-get update failed after 3 attempts." >&2 + exit 1 + fi + echo "::warning::apt-get update attempt ${attempt} failed; retrying." >&2 + sleep $((attempt * 5)) + done + sudo apt-get install -y --no-install-recommends expect + - name: Prepare E2E workspace uses: NVIDIA/NemoClaw/.github/actions/prepare-e2e@50281ee84c4a6fc759da95ea28fc0b7d9c378a28 diff --git a/test/e2e/live/issue-6194-tui-expect.ts b/test/e2e/live/issue-6194-tui-expect.ts new file mode 100644 index 0000000000..6439104458 --- /dev/null +++ b/test/e2e/live/issue-6194-tui-expect.ts @@ -0,0 +1,343 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { readFileSync, writeFileSync } from "node:fs"; + +export const ISSUE6194_TUI_TIMEOUT_SEC = 240; +export const ISSUE6194_TUI_EXIT_TIMEOUT_SEC = 10; +export const ISSUE6194_OPENSHELL_DASHBOARD_TIMEOUT_SEC = 30; +export const ISSUE6194_OPENSHELL_APPROVAL_TIMEOUT_BUFFER_SEC = 120; +export const ISSUE6194_TUI_SESSION_PREFIX = "issue-6194-tui"; +export const ISSUE6194_NETWORK_APPROVAL_ENDPOINT = + "https://api.atlassian.com/oauth/token/accessible-resources"; +export const ISSUE6194_NETWORK_APPROVAL_HOST = "api.atlassian.com"; + +export type Issue6194Capture = { + exists: boolean; + contents: string; +}; + +export function precreateIssue6194Capture(path: string): void { + writeFileSync(path, "", { mode: 0o600 }); +} + +export function readIssue6194Capture(path: string): Issue6194Capture { + try { + return { exists: true, contents: readFileSync(path, "utf8") }; + } catch (error) { + if ((error as { code?: unknown }).code === "ENOENT") { + return { exists: false, contents: "" }; + } + throw error; + } +} + +export function buildIssue6194TuiExpectScript(): string { + return `set timeout $env(NEMOCLAW_ISSUE_6194_TUI_TIMEOUT) +set sandbox $env(NEMOCLAW_ISSUE_6194_SANDBOX) +set capture $env(NEMOCLAW_ISSUE_6194_CAPTURE) +set session $env(NEMOCLAW_ISSUE_6194_SESSION) +log_file -noappend $capture +proc mark {name} { + puts "ISSUE6194_MARK $name" + send_log "ISSUE6194_MARK $name\\n" +} +proc expect_or_exit {pattern markName timeoutExit eofExit} { + expect { + -nocase -re $pattern { mark $markName } + timeout { + send "\\003" + exit $timeoutExit + } + eof { exit $eofExit } + } +} +spawn openshell sandbox exec --name $sandbox --tty -- sh -lc "export TERM=xterm-256color; cd /sandbox; openclaw tui --session $session" +expect_or_exit {connected[^\\r\\n]*idle} connected_idle_initial 10 11 +send -- "Reply with the three fragments joined by underscores: NEMOCLAW6194, CHAT, OK. Put only that joined token on its own line. Do not use tools.\\r" +expect_or_exit {NEMOCLAW6194_CHAT_OK} chat_reply 20 21 +expect_or_exit {connected[^\\r\\n]*idle} connected_idle_after_chat 22 23 +send -- "/nemoclaw status\\r" +expect_or_exit {NemoClaw Status} slash_status_output 30 31 +expect_or_exit {connected[^\\r\\n]*idle} connected_idle_after_status 32 33 +# Network-rule approvals belong to the separate OpenShell terminal UI. Keep +# this OpenClaw TUI regression scoped to inputs it can perform directly so a +# tool-less hosted model cannot turn assistant prose into a test oracle. +send "\\003" +set savedTimeout $timeout +set timeout ${ISSUE6194_TUI_EXIT_TIMEOUT_SEC} +expect { + eof {} + -nocase -re {press ctrl\\+c again to exit} { + mark exit_confirmation + send "\\003" + expect { + eof {} + timeout { exit 40 } + } + } + timeout { exit 39 } +} +set timeout $savedTimeout +mark clean_exit +exit 0 +`; +} + +export function buildIssue6194OpenShellApprovalExpectScript(): string { + return `set timeout $env(NEMOCLAW_ISSUE_6194_TUI_TIMEOUT) +set sandbox $env(NEMOCLAW_ISSUE_6194_SANDBOX) +set capture $env(NEMOCLAW_ISSUE_6194_CAPTURE) +set triggerCapture $env(NEMOCLAW_ISSUE_6194_TRIGGER_CAPTURE) +set ruleCapture $env(NEMOCLAW_ISSUE_6194_RULE_CAPTURE) +set policyCapture $env(NEMOCLAW_ISSUE_6194_POLICY_CAPTURE) +set networkEndpoint $env(NEMOCLAW_ISSUE_6194_NETWORK_ENDPOINT) +set networkHost $env(NEMOCLAW_ISSUE_6194_NETWORK_HOST) +log_file -noappend $capture +proc mark {name} { + puts "ISSUE6194_MARK $name" + send_log "ISSUE6194_MARK $name\\n" +} +proc stop_spawn {target} { + catch {send -i $target "\\003"} + catch {close -i $target} + catch {wait -i $target -nowait} +} +proc write_capture {path value} { + set handle [open $path w] + puts -nonewline $handle $value + close $handle +} +proc expect_or_exit {target pattern markName timeoutExit eofExit} { + expect { + -i $target + -nocase -re $pattern { mark $markName } + timeout { + stop_spawn $target + exit $timeoutExit + } + eof { + catch {wait -i $target} + exit $eofExit + } + } +} +proc expect_exact_or_exit {target value markName timeoutExit eofExit} { + expect { + -i $target + -nocase -ex $value { mark $markName } + timeout { + stop_spawn $target + exit $timeoutExit + } + eof { + catch {wait -i $target} + exit $eofExit + } + } +} +# The OpenShell TUI starts on Gateways. Refuse to navigate by position unless +# this ephemeral target owns exactly one sandbox. +if {[catch {exec openshell sandbox list --names} sandboxNames]} { + puts "ISSUE6194_DIAGNOSTIC sandbox listing failed: $sandboxNames" + exit 60 +} +if {[string trim $sandboxNames] ne $sandbox} { + puts "ISSUE6194_DIAGNOSTIC expected sole sandbox '$sandbox', got: $sandboxNames" + exit 61 +} +mark sole_sandbox_verified +# Do not clear unexpected rules: a pre-existing or concurrent pending request +# must fail this target instead of being hidden or accidentally approved. +if {[catch {exec openshell rule get $sandbox --status pending} pendingBefore]} { + puts "ISSUE6194_DIAGNOSTIC pending-rule preflight failed: $pendingBefore" + exit 62 +} +set expectedEmpty "No network rules for sandbox '$sandbox'" +if {[string trim $pendingBefore] ne $expectedEmpty} { + puts "ISSUE6194_DIAGNOSTIC pending-rule queue was not empty: $pendingBefore" + exit 63 +} +mark pending_queue_empty +# ShellProbe has no controlling TTY. Pin the terminal type and Expect-owned +# PTY geometry before OpenShell performs its first full-screen render. +set env(TERM) xterm-256color +spawn openshell term +set termSpawn $spawn_id +set termPty $spawn_out(slave,name) +stty rows 40 columns 120 < $termPty +set dashboardTimeout $timeout +set timeout ${ISSUE6194_OPENSHELL_DASHBOARD_TIMEOUT_SEC} +expect_exact_or_exit $termSpawn {Sandboxes} openshell_dashboard 64 65 +set timeout $dashboardTimeout +# Gateways -> Providers -> Sandboxes. +send -i $termSpawn -- "\\t" +after 200 +send -i $termSpawn -- "\\t" +after 200 +expect_exact_or_exit $termSpawn $sandbox openshell_sandbox_listed 66 67 +send -i $termSpawn -- "\\r" +expect_exact_or_exit $termSpawn {Name:} openshell_sandbox_detail 68 69 +expect_exact_or_exit $termSpawn $sandbox openshell_sandbox_detail_name 70 71 +# OpenShell documents 'r' as the Network Rules focus key in sandbox detail. +send -i $termSpawn -- "r" +expect_exact_or_exit $termSpawn {Network Rules} network_rules_focused 72 73 +# The request uses argv boundaries and hard time limits. Its output belongs to +# a separate spawn, so it cannot satisfy any openshell term UI assertion. +spawn -noecho openshell sandbox exec --name $sandbox --no-tty --timeout 40 -- /usr/bin/curl -sS --connect-timeout 5 --max-time 30 -o /dev/null $networkEndpoint +set curlSpawn $spawn_id +mark network_request_triggered +set savedTimeout $timeout +set timeout 45 +expect { + -i $curlSpawn + eof { set triggerOutput $expect_out(buffer) } + timeout { + stop_spawn $curlSpawn + stop_spawn $termSpawn + exit 74 + } +} +set timeout $savedTimeout +catch {wait -i $curlSpawn} curlWait +write_capture $triggerCapture $triggerOutput +mark network_request_completed +# Poll the supported rule CLI until it proves there is exactly one pending +# chunk and that it belongs to this exact curl/endpoint pair. +set pendingOutput "" +set pendingReady 0 +for {set attempt 0} {$attempt < 20} {incr attempt} { + set pendingGetFailed [catch {exec openshell rule get $sandbox --status pending} candidate] + # OpenShell may color labels even when output is captured. Strip SGR before + # parsing and publishing the rule evidence so label/value matches remain + # deterministic across runner terminals and OpenShell render modes. + regsub -all {\\x1b\\[[0-9;]*m} $candidate "" pendingOutput + if {!$pendingGetFailed} { + set chunkCount [regexp -all -line {^[[:space:]]*Chunk:} $pendingOutput] + set oneChunk [regexp -nocase {Network Rules:[^\\r\\n]*1 chunk} $pendingOutput] + set pendingStatus [regexp -nocase {Status:[[:space:]]*pending} $pendingOutput] + set curlBinary [regexp {Binary:[[:space:]]*/usr/bin/curl} $pendingOutput] + set expectedEndpoint [expr {[string first $networkHost $pendingOutput] >= 0}] + if {$chunkCount == 1 && $oneChunk && $pendingStatus && $curlBinary && $expectedEndpoint} { + set pendingReady 1 + break + } + } + after 500 +} +write_capture $ruleCapture $pendingOutput +if {!$pendingReady} { + puts "ISSUE6194_DIAGNOSTIC expected one curl pending rule, got: $pendingOutput" + stop_spawn $termSpawn + exit 75 +} +mark network_rule_singleton +# Only the OpenShell term spawn can satisfy these patterns. The trigger output +# and assistant transcript are isolated from this buffer. +expect_exact_or_exit $termSpawn $networkHost network_rule_endpoint 76 77 +send -i $termSpawn -- "\\r" +expect_or_exit $termSpawn {Status:[^\\r\\n]*pending} network_rule_detail 78 79 +expect_or_exit $termSpawn {Binary:[^\\r\\n]*/usr/bin/curl} network_rule_detail_binary 80 81 +expect_exact_or_exit $termSpawn $networkHost network_rule_detail_endpoint 82 83 +expect_or_exit $termSpawn {\\[a\\][^\\r\\n]*Approve} network_rule_approve_action 84 85 +send -i $termSpawn -- "a" +expect { + -i $termSpawn + -nocase -re {Approved[^\\r\\n]*'[^']+'[^\\r\\n]*policy v([0-9]+)} { + set approvedPolicyVersion $expect_out(1,string) + mark network_approval_processed + } + timeout { + stop_spawn $termSpawn + exit 86 + } + eof { + catch {wait -i $termSpawn} + exit 87 + } +} +# The approval RPC assigns a policy revision before the sandbox loads it. +# Poll that exact revision through the read-only policy API until both its +# status and the active version prove convergence. Preserve every bounded +# attempt so timeout and failed-revision diagnostics remain reviewable. +set policyStatusOutput "ISSUE6194_APPROVED_POLICY_VERSION=$approvedPolicyVersion\\n" +set policyLoaded 0 +set policyTerminalStatus timeout +set policyLoadDeadline [expr {[clock milliseconds] + 60000}] +set attempt 0 +while {[clock milliseconds] < $policyLoadDeadline} { + incr attempt + set policyGetFailed [catch {exec timeout 2 openshell policy get $sandbox --rev $approvedPolicyVersion --output json} candidate] + append policyStatusOutput "ISSUE6194_POLICY_STATUS_ATTEMPT=$attempt\\n$candidate\\n" + set versionPattern [format {"version"[[:space:]]*:[[:space:]]*%s([[:space:]]|,)} $approvedPolicyVersion] + set activePattern [format {"active_version"[[:space:]]*:[[:space:]]*%s([[:space:]]|,)} $approvedPolicyVersion] + set versionMatches [regexp $versionPattern $candidate] + set statusLoaded [regexp {"status"[[:space:]]*:[[:space:]]*"loaded"} $candidate] + set activeMatches [regexp $activePattern $candidate] + if {!$policyGetFailed && $versionMatches && $statusLoaded && $activeMatches} { + append policyStatusOutput "ISSUE6194_ACTIVE_POLICY_VERSION=$approvedPolicyVersion\\n" + append policyStatusOutput "ISSUE6194_POLICY_STATUS=loaded\\n" + set policyLoaded 1 + break + } + if {!$policyGetFailed && [regexp {"status"[[:space:]]*:[[:space:]]*"(failed|superseded)"} $candidate _ terminalStatus]} { + set policyTerminalStatus $terminalStatus + break + } + after 1000 +} +if {!$policyLoaded} { + append policyStatusOutput "ISSUE6194_POLICY_STATUS=$policyTerminalStatus\\n" + write_capture $policyCapture $policyStatusOutput + puts "ISSUE6194_DIAGNOSTIC approved policy revision did not become active: $policyStatusOutput" + stop_spawn $termSpawn + exit 90 +} +mark network_policy_loaded +# Retry the exact documented Atlassian probe once the acknowledged policy +# revision is active. An unauthenticated 401 is the expected success signal. +spawn -noecho openshell sandbox exec --name $sandbox --no-tty --timeout 20 -- /usr/bin/curl -sS --connect-timeout 5 --max-time 10 -o /dev/null -w {ISSUE6194_POLICY_HTTP_STATUS=%{http_code}\\n} $networkEndpoint +set policySpawn $spawn_id +set policyOutput "" +set savedTimeout $timeout +set timeout 25 +expect { + -i $policySpawn + eof { set policyOutput $expect_out(buffer) } + timeout { + write_capture $policyCapture $policyStatusOutput + stop_spawn $policySpawn + stop_spawn $termSpawn + exit 88 + } +} +set timeout $savedTimeout +catch {wait -i $policySpawn} policyWait +write_capture $policyCapture "$policyStatusOutput$policyOutput" +if {![regexp {ISSUE6194_POLICY_HTTP_STATUS=401(\\r?\\n|$)} $policyOutput]} { + puts "ISSUE6194_DIAGNOSTIC approved policy did not permit the exact endpoint: $policyOutput" + stop_spawn $termSpawn + exit 91 +} +mark network_policy_updated +send -i $termSpawn -- "q" +expect { + -i $termSpawn + eof {} + timeout { + send -i $termSpawn "\\003" + expect { + -i $termSpawn + eof {} + timeout { + stop_spawn $termSpawn + exit 89 + } + } + } +} +catch {wait -i $termSpawn} termWait +mark openshell_clean_exit +exit 0 +`; +} diff --git a/test/e2e/live/openclaw-tui-chat-correlation.test.ts b/test/e2e/live/openclaw-tui-chat-correlation.test.ts index 264948da0f..8f7cd1441a 100644 --- a/test/e2e/live/openclaw-tui-chat-correlation.test.ts +++ b/test/e2e/live/openclaw-tui-chat-correlation.test.ts @@ -2,27 +2,44 @@ // SPDX-License-Identifier: Apache-2.0 /** - * Live E2E: OpenClaw TUI/chat correlation regression guard (#2603 + #3145). + * Live E2E: OpenClaw TUI/chat correlation regression guards (#2603 + #3145 + #6194). * * Focused coverage slice for the protocol/history assertions migrated from * entrypoint now hands off to this live target. * * Covered here: ordered, non-empty, correlated replies plus ordered, - * non-duplicated user turns against a real cloud OpenClaw sandbox. TUI - * rendering indicators and visible tool-call status stay out of scope. + * non-duplicated user turns against a real cloud OpenClaw sandbox, then + * terminal TUI input after the visible `connected idle` state. */ import { randomUUID } from "node:crypto"; -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { containsReplyTokenAllowingWhitespace } from "../../helpers/e2e-answer-assertions.ts"; import { buildAvailabilityProbeEnv } from "../fixtures/availability-env.ts"; -import { type SandboxClient, trustedSandboxShellScript } from "../fixtures/clients/sandbox.ts"; +import { resultText } from "../fixtures/clients/command.ts"; +import { + type SandboxClient, + sandboxAccessEnv, + trustedSandboxShellScript, +} from "../fixtures/clients/sandbox.ts"; import { expect, test } from "../fixtures/e2e-test.ts"; import type { NemoClawInstance } from "../fixtures/phases/onboarding.ts"; import { ubuntuRepoDocker } from "../registry/matrix.ts"; +import { stripTerminalControl } from "../support/issue-4434-tui-capture.ts"; +import { + buildIssue6194OpenShellApprovalExpectScript, + buildIssue6194TuiExpectScript, + ISSUE6194_NETWORK_APPROVAL_ENDPOINT, + ISSUE6194_NETWORK_APPROVAL_HOST, + ISSUE6194_OPENSHELL_APPROVAL_TIMEOUT_BUFFER_SEC, + ISSUE6194_TUI_SESSION_PREFIX, + ISSUE6194_TUI_TIMEOUT_SEC, + precreateIssue6194Capture, + readIssue6194Capture, +} from "./issue-6194-tui-expect.ts"; // Reuses the standard ubuntu-repo-docker environment with the // `cloud-openclaw` onboarding profile (already in @@ -486,16 +503,22 @@ async function runLiveIssue2603ReproWithEventCaptureRetry( // ─── The live regression guard ───────────────────────────────────── test( - "openclaw-tui-chat-correlation keeps rapid TUI and webchat sends correlated on a real OpenClaw sandbox (#2603, #3145)", - async ({ artifacts, environment, onboard, sandbox, secrets }) => { - secrets.required("NVIDIA_INFERENCE_API_KEY"); + "openclaw-tui-chat-correlation keeps rapid sends correlated and accepts terminal input after connected idle (#2603, #3145, #6194)", + async ({ artifacts, environment, host, onboard, sandbox, secrets }) => { + const apiKey = secrets.required("NVIDIA_INFERENCE_API_KEY"); await artifacts.target.declare({ id: "openclaw-tui-chat-correlation", - boundary: "openclaw-gateway-websocket", - issues: ["#2603", "#3145"], + boundary: [ + "openclaw-gateway-websocket", + "openclaw-tui-terminal-after-connected-idle", + "openshell-network-rule-terminal-approval", + ], + issues: ["#2603", "#3145", "#6194"], ownerIssue: "#4347", pinnedOpenClawVersion: EXPECTED_OPENCLAW_VERSION, + historicalReproScope: + "#6194 reported NemoClaw v0.0.72 as the known-bad release; this live target guards the current branch against the same post-idle TUI regression instead of reinstalling the old bad version.", }); // Setup ──────────────────────────────────────────────────────── @@ -504,9 +527,14 @@ test( sandboxName: SANDBOX_NAME, }); - // Assertion: openclaw-version-pinned. The regression target only - // reproduces against the bundled OpenClaw build; if the sandbox installed - // a different version, the rest of the test is meaningless. + // Assertion: openclaw-version-pinned. The issue reporter used NemoClaw + // v0.0.72 to demonstrate the historical failure. Reinstalling that known + // bad release in PR CI would prove the old bug, not the proposed guard. + // This target provisions the current branch and validates the bundled + // OpenClaw build before exercising the same post-connected-idle terminal + // paths so future changes cannot reintroduce #6194. OpenShell's separate + // terminal UI owns network-rule approvals; this OpenClaw TUI flow must not + // rely on a hosted model choosing a network tool that may not exist. // // Every sandbox.* call must pass `env: buildAvailabilityProbeEnv()`: // ShellProbe.run spawns with an empty env when none is provided, @@ -526,6 +554,247 @@ test( `actual: ${versionResult.stdout}`, ).toContain(EXPECTED_OPENCLAW_VERSION); + // Drive the #6194 terminal flow before websocket correlation so the + // post-idle TUI regression guard is independent of any gateway/session + // state created by the #2603/#3145 websocket replay below. Keeping both + // flows in this target reuses the same provisioned sandbox and avoids a + // second long cloud setup for a tests-only PR. + const captureDir = mkdtempSync(join(tmpdir(), "nemoclaw-issue6194-tui-")); + const captureFile = join(captureDir, "openclaw-tui-capture.log"); + const expectScript = artifacts.pathFor("issue6194-openclaw-tui.expect"); + const tuiSession = `${ISSUE6194_TUI_SESSION_PREFIX}-${instance.sandboxName}-${Date.now()}-${randomUUID()}`; + precreateIssue6194Capture(captureFile); + writeFileSync(expectScript, buildIssue6194TuiExpectScript(), { mode: 0o700 }); + try { + const tui = await host.command("expect", [expectScript], { + artifactName: "issue6194-openclaw-tui-post-idle", + env: { + ...sandboxAccessEnv(), + NEMOCLAW_ISSUE_6194_SANDBOX: instance.sandboxName, + NEMOCLAW_ISSUE_6194_CAPTURE: captureFile, + NEMOCLAW_ISSUE_6194_SESSION: tuiSession, + NEMOCLAW_ISSUE_6194_TUI_TIMEOUT: String(ISSUE6194_TUI_TIMEOUT_SEC), + }, + redactionValues: [apiKey], + timeoutMs: (ISSUE6194_TUI_TIMEOUT_SEC + 30) * 1000, + }); + const tuiCapture = readIssue6194Capture(captureFile); + const rawCapture = tuiCapture.contents; + const redactedCapture = secrets.redact(rawCapture, [apiKey]); + const plainCapture = stripTerminalControl(redactedCapture); + const combined = `${resultText(tui)}\n${plainCapture}`; + const redactedArtifact = await artifacts.writeText( + "issue6194-openclaw-tui-capture.log", + redactedCapture, + ); + const plainArtifact = await artifacts.writeText( + "issue6194-openclaw-tui-capture.plain.log", + plainCapture, + ); + await artifacts.writeJson("issue6194-target-result.json", { + id: "issue-6194-tui-post-connected-idle", + expectExitCode: tui.exitCode, + captureExists: tuiCapture.exists, + captureNonEmpty: plainCapture.length > 0, + captureHasMarkers: plainCapture.includes("ISSUE6194_MARK"), + connectedIdleInitial: combined.includes("ISSUE6194_MARK connected_idle_initial"), + chatReply: combined.includes("ISSUE6194_MARK chat_reply"), + connectedIdleAfterChat: combined.includes("ISSUE6194_MARK connected_idle_after_chat"), + slashStatusOutput: combined.includes("ISSUE6194_MARK slash_status_output"), + connectedIdleAfterStatus: combined.includes("ISSUE6194_MARK connected_idle_after_status"), + cleanExit: combined.includes("ISSUE6194_MARK clean_exit"), + }); + + expect(tuiCapture.exists, "TUI expect capture must exist").toBe(true); + expect(plainCapture.length, "TUI expect capture must not be empty").toBeGreaterThan(0); + expect(plainCapture, "TUI expect capture must include expect-script markers").toContain( + "ISSUE6194_MARK", + ); + expect( + readFileSync(redactedArtifact, "utf8"), + "published ANSI capture must redact API key", + ).not.toContain(apiKey); + expect( + readFileSync(plainArtifact, "utf8"), + "published plain capture must redact API key", + ).not.toContain(apiKey); + expect(tui.exitCode, combined).toBe(0); + expect(combined, "TUI must reach connected idle before post-idle input").toContain( + "ISSUE6194_MARK connected_idle_initial", + ); + expect(combined, "post-idle chat must return a visible reply before timeout").toContain( + "ISSUE6194_MARK chat_reply", + ); + expect( + combined, + "TUI must return to connected idle after the post-idle chat reply", + ).toContain("ISSUE6194_MARK connected_idle_after_chat"); + expect( + combined, + "post-idle slash command must render status output before timeout", + ).toContain("ISSUE6194_MARK slash_status_output"); + expect(combined, "rendered status output must include its sandbox field").toMatch( + /NemoClaw Status[\s\S]*Sandbox:/u, + ); + expect(combined, "TUI must return to connected idle after /nemoclaw status").toContain( + "ISSUE6194_MARK connected_idle_after_status", + ); + expect(combined, "post-idle Ctrl+C must close the TUI session").toContain( + "ISSUE6194_MARK clean_exit", + ); + + // OpenShell's terminal UI owns network-rule approval. Exercise that + // boundary separately with a direct sandbox curl so hosted models that + // expose no network tools cannot make this assertion nondeterministic. + const approvalCaptureFile = join(captureDir, "openshell-approval-capture.log"); + const triggerCaptureFile = join(captureDir, "openshell-network-trigger.log"); + const ruleCaptureFile = join(captureDir, "openshell-pending-rule.log"); + const policyCaptureFile = join(captureDir, "openshell-policy-retry.log"); + const approvalExpectScript = artifacts.pathFor("issue6194-openshell-approval.expect"); + precreateIssue6194Capture(approvalCaptureFile); + precreateIssue6194Capture(triggerCaptureFile); + precreateIssue6194Capture(ruleCaptureFile); + precreateIssue6194Capture(policyCaptureFile); + writeFileSync(approvalExpectScript, buildIssue6194OpenShellApprovalExpectScript(), { + mode: 0o700, + }); + const approval = await host.command("expect", [approvalExpectScript], { + artifactName: "issue6194-openshell-network-approval", + env: { + ...sandboxAccessEnv(), + NEMOCLAW_ISSUE_6194_SANDBOX: instance.sandboxName, + NEMOCLAW_ISSUE_6194_CAPTURE: approvalCaptureFile, + NEMOCLAW_ISSUE_6194_TRIGGER_CAPTURE: triggerCaptureFile, + NEMOCLAW_ISSUE_6194_RULE_CAPTURE: ruleCaptureFile, + NEMOCLAW_ISSUE_6194_POLICY_CAPTURE: policyCaptureFile, + NEMOCLAW_ISSUE_6194_NETWORK_ENDPOINT: ISSUE6194_NETWORK_APPROVAL_ENDPOINT, + NEMOCLAW_ISSUE_6194_NETWORK_HOST: ISSUE6194_NETWORK_APPROVAL_HOST, + NEMOCLAW_ISSUE_6194_TUI_TIMEOUT: String(ISSUE6194_TUI_TIMEOUT_SEC), + }, + redactionValues: [apiKey], + timeoutMs: + (ISSUE6194_TUI_TIMEOUT_SEC + ISSUE6194_OPENSHELL_APPROVAL_TIMEOUT_BUFFER_SEC) * 1000, + }); + const approvalCapture = readIssue6194Capture(approvalCaptureFile); + const triggerCapture = readIssue6194Capture(triggerCaptureFile); + const ruleCapture = readIssue6194Capture(ruleCaptureFile); + const policyCapture = readIssue6194Capture(policyCaptureFile); + const redactedApprovalCapture = secrets.redact(approvalCapture.contents, [apiKey]); + const redactedTriggerCapture = secrets.redact(triggerCapture.contents, [apiKey]); + const redactedRuleCapture = secrets.redact(ruleCapture.contents, [apiKey]); + const redactedPolicyCapture = secrets.redact(policyCapture.contents, [apiKey]); + const plainApprovalCapture = stripTerminalControl(redactedApprovalCapture); + const approvedPolicyVersion = + redactedPolicyCapture.match(/ISSUE6194_APPROVED_POLICY_VERSION=([0-9]+)/u)?.[1] ?? null; + const activePolicyVersion = + redactedPolicyCapture.match(/ISSUE6194_ACTIVE_POLICY_VERSION=([0-9]+)/u)?.[1] ?? null; + const observedPolicyStatus = + redactedPolicyCapture.match(/ISSUE6194_POLICY_STATUS=([a-z]+)/u)?.[1] ?? null; + const policyStatusAttempts = + redactedPolicyCapture.match(/ISSUE6194_POLICY_STATUS_ATTEMPT=/gu)?.length ?? 0; + const approvalCombined = `${resultText(approval)}\n${plainApprovalCapture}\n${redactedTriggerCapture}\n${redactedRuleCapture}\n${redactedPolicyCapture}`; + await artifacts.writeText( + "issue6194-openshell-approval-capture.log", + redactedApprovalCapture, + ); + await artifacts.writeText( + "issue6194-openshell-approval-capture.plain.log", + plainApprovalCapture, + ); + await artifacts.writeText("issue6194-openshell-network-trigger.log", redactedTriggerCapture); + await artifacts.writeText("issue6194-openshell-pending-rule.log", redactedRuleCapture); + await artifacts.writeText("issue6194-openshell-policy-retry.log", redactedPolicyCapture); + await artifacts.writeJson("issue6194-approval-result.json", { + id: "issue-6194-openshell-network-approval", + expectExitCode: approval.exitCode, + approvalCaptureExists: approvalCapture.exists, + approvalCaptureNonEmpty: plainApprovalCapture.length > 0, + triggerCaptureExists: triggerCapture.exists, + ruleCaptureExists: ruleCapture.exists, + ruleCaptureNonEmpty: redactedRuleCapture.length > 0, + policyCaptureExists: policyCapture.exists, + policyCaptureNonEmpty: redactedPolicyCapture.length > 0, + approvedPolicyVersion, + activePolicyVersion, + observedPolicyStatus, + policyStatusAttempts, + policyStatusLoaded: redactedPolicyCapture.includes("ISSUE6194_POLICY_STATUS=loaded"), + policyVersionActive: + approvedPolicyVersion !== null && approvedPolicyVersion === activePolicyVersion, + postApprovalEndpoint: ISSUE6194_NETWORK_APPROVAL_ENDPOINT, + postApprovalExpectedHttpStatus: 401, + postApprovalHttpStatus401: redactedPolicyCapture.includes( + "ISSUE6194_POLICY_HTTP_STATUS=401", + ), + pendingQueueEmpty: approvalCombined.includes("ISSUE6194_MARK pending_queue_empty"), + requestTriggered: approvalCombined.includes("ISSUE6194_MARK network_request_triggered"), + requestCompleted: approvalCombined.includes("ISSUE6194_MARK network_request_completed"), + singletonRule: approvalCombined.includes("ISSUE6194_MARK network_rule_singleton"), + rulesFocused: approvalCombined.includes("ISSUE6194_MARK network_rules_focused"), + endpointRendered: approvalCombined.includes("ISSUE6194_MARK network_rule_endpoint"), + detailBinary: approvalCombined.includes("ISSUE6194_MARK network_rule_detail_binary"), + approvalProcessed: approvalCombined.includes("ISSUE6194_MARK network_approval_processed"), + policyLoaded: approvalCombined.includes("ISSUE6194_MARK network_policy_loaded"), + policyUpdated: approvalCombined.includes("ISSUE6194_MARK network_policy_updated"), + cleanExit: approvalCombined.includes("ISSUE6194_MARK openshell_clean_exit"), + }); + + expect(approvalCapture.exists, "OpenShell approval capture must exist").toBe(true); + expect( + plainApprovalCapture.length, + "OpenShell approval capture must not be empty", + ).toBeGreaterThan(0); + expect(policyCapture.exists, "post-approval policy retry capture must exist").toBe(true); + expect( + redactedPolicyCapture.length, + "post-approval policy retry capture must not be empty", + ).toBeGreaterThan(0); + expect(approval.exitCode, approvalCombined).toBe(0); + expect( + approvalCombined, + "direct curl must create exactly one matching pending rule", + ).toContain("ISSUE6194_MARK network_rule_singleton"); + expect(approvalCombined, "OpenShell must render the exact blocked endpoint").toContain( + "ISSUE6194_MARK network_rule_detail_endpoint", + ); + expect( + approvalCombined, + "OpenShell must attribute the rule to the direct curl binary", + ).toContain("ISSUE6194_MARK network_rule_detail_binary"); + expect(approvalCombined, "OpenShell approval input must be processed").toContain( + "ISSUE6194_MARK network_approval_processed", + ); + expect( + approvedPolicyVersion, + "approval acknowledgement must identify its assigned policy revision", + ).not.toBeNull(); + expect( + redactedPolicyCapture, + "acknowledged policy revision must reach loaded status before the retry", + ).toContain("ISSUE6194_POLICY_STATUS=loaded"); + expect( + activePolicyVersion, + "loaded active policy revision must match the approval acknowledgement", + ).toBe(approvedPolicyVersion); + expect( + approvalCombined, + "post-approval probe must wait for the acknowledged policy revision to become active", + ).toContain("ISSUE6194_MARK network_policy_loaded"); + expect( + redactedPolicyCapture, + "approved running policy must allow the exact post-approval Atlassian probe", + ).toContain("ISSUE6194_POLICY_HTTP_STATUS=401"); + expect( + approvalCombined, + "post-approval probe must independently prove the running policy was updated", + ).toContain("ISSUE6194_MARK network_policy_updated"); + expect(approvalCombined, "OpenShell terminal must exit cleanly after approval").toContain( + "ISSUE6194_MARK openshell_clean_exit", + ); + } finally { + rmSync(captureDir, { recursive: true, force: true }); + } + // Drive the websocket repro and capture the trace ────────────── const { repro, attempts } = await runLiveIssue2603ReproWithEventCaptureRetry( sandbox, diff --git a/test/e2e/support/e2e-host-dependency-workflow-boundary.test.ts b/test/e2e/support/e2e-host-dependency-workflow-boundary.test.ts index 49652ba57b..cacacf9053 100644 --- a/test/e2e/support/e2e-host-dependency-workflow-boundary.test.ts +++ b/test/e2e/support/e2e-host-dependency-workflow-boundary.test.ts @@ -60,6 +60,12 @@ describe("inline E2E host dependency boundary", () => { expected: "issue-4434-tui-unreachable-inference host dependency install must be exactly 'sudo apt-get install -y --no-install-recommends expect iptables'", }, + { + jobName: "openclaw-tui-chat-correlation", + stepName: "Install OpenClaw TUI host dependencies", + expected: + "openclaw-tui-chat-correlation host dependency install must be exactly 'sudo apt-get install -y --no-install-recommends expect'", + }, ])("rejects package allowlist drift in $jobName", ({ jobName, stepName, expected }) => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "e2e-workflow-apt-allowlist-")); const workflowPath = path.join(tmp, "workflow.yaml"); @@ -75,6 +81,25 @@ describe("inline E2E host dependency boundary", () => { } }); + it("rejects installing the OpenClaw TUI host dependency after workspace preparation", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "e2e-workflow-host-dependency-order-")); + const workflowPath = path.join(tmp, "workflow.yaml"); + const workflow = readWorkflow(); + const steps = workflow.jobs["openclaw-tui-chat-correlation"].steps; + const installIndex = requireStepIndex(steps, "Install OpenClaw TUI host dependencies"); + const prepareIndex = requireStepIndex(steps, "Prepare E2E workspace"); + [steps[installIndex], steps[prepareIndex]] = [steps[prepareIndex]!, steps[installIndex]!]; + fs.writeFileSync(workflowPath, YAML.stringify(workflow)); + + try { + expect(validateE2eWorkflowBoundary(workflowPath)).toContain( + "openclaw-tui-chat-correlation host dependencies must be installed before workspace prep", + ); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); + it("keeps cloud-onboard host dependencies before workspace preparation", () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "e2e-workflow-host-order-")); const workflowPath = path.join(tmp, "workflow.yaml"); diff --git a/test/e2e/support/issue-6194-tui-post-idle-contract.test.ts b/test/e2e/support/issue-6194-tui-post-idle-contract.test.ts new file mode 100644 index 0000000000..d27c528721 --- /dev/null +++ b/test/e2e/support/issue-6194-tui-post-idle-contract.test.ts @@ -0,0 +1,419 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { SecretStore } from "../fixtures/secrets.ts"; +import { + buildIssue6194OpenShellApprovalExpectScript, + buildIssue6194TuiExpectScript, + ISSUE6194_NETWORK_APPROVAL_ENDPOINT, + ISSUE6194_NETWORK_APPROVAL_HOST, + ISSUE6194_OPENSHELL_APPROVAL_TIMEOUT_BUFFER_SEC, + ISSUE6194_OPENSHELL_DASHBOARD_TIMEOUT_SEC, + ISSUE6194_TUI_EXIT_TIMEOUT_SEC, + ISSUE6194_TUI_SESSION_PREFIX, + ISSUE6194_TUI_TIMEOUT_SEC, + precreateIssue6194Capture, + readIssue6194Capture, +} from "../live/issue-6194-tui-expect.ts"; +import { stripTerminalControl } from "./issue-4434-tui-capture.ts"; + +describe("live TUI post-idle coverage contract (#6194)", () => { + it("declares every combined OpenClaw and OpenShell live boundary", () => { + const liveSource = readFileSync( + new URL("../live/openclaw-tui-chat-correlation.test.ts", import.meta.url), + "utf8", + ); + const declarationStart = liveSource.indexOf("await artifacts.target.declare({"); + const declarationEnd = liveSource.indexOf(" });", declarationStart); + const declaration = liveSource.slice(declarationStart, declarationEnd); + + expect(declarationStart).toBeGreaterThanOrEqual(0); + expect(declarationEnd).toBeGreaterThan(declarationStart); + expect(declaration).toContain('"openclaw-gateway-websocket"'); + expect(declaration).toContain('"openclaw-tui-terminal-after-connected-idle"'); + expect(declaration).toContain('"openshell-network-rule-terminal-approval"'); + expect(liveSource).toContain('artifactName: "issue6194-openshell-network-approval"'); + expect(liveSource).toContain('artifacts.writeJson("issue6194-approval-result.json"'); + expect(liveSource).toContain('artifacts.writeText("issue6194-openshell-policy-retry.log"'); + expect(liveSource).toContain("postApprovalEndpoint: ISSUE6194_NETWORK_APPROVAL_ENDPOINT"); + expect(liveSource).toContain("postApprovalExpectedHttpStatus: 401"); + expect(liveSource).toContain("approvedPolicyVersion,"); + expect(liveSource).toContain("activePolicyVersion,"); + expect(liveSource).toContain("observedPolicyStatus,"); + expect(liveSource).toContain("policyStatusAttempts,"); + expect(liveSource).toContain("policyStatusLoaded:"); + expect(liveSource).toContain("policyVersionActive:"); + expect(liveSource).toContain('artifactName: "live-issue2603-repro"'); + expect(liveSource).toContain('artifacts.writeJson("issue2603-trace.json"'); + }); + + it("builds an expect flow for chat, slash status, return to idle, and clean exit", () => { + const script = buildIssue6194TuiExpectScript(); + + expect(ISSUE6194_TUI_TIMEOUT_SEC).toBe(240); + expect(ISSUE6194_TUI_SESSION_PREFIX).toBe("issue-6194-tui"); + expect(script).toContain("log_file -noappend $capture"); + expect(script).toContain("set session $env(NEMOCLAW_ISSUE_6194_SESSION)"); + expect(script).toContain("spawn openshell sandbox exec --name $sandbox --tty"); + expect(script).toContain("openclaw tui --session $session"); + expect(script).toContain('puts "ISSUE6194_MARK $name"'); + expect(script).toContain('send_log "ISSUE6194_MARK $name\\n"'); + expect(script).toContain("proc expect_or_exit"); + expect(script).toContain( + "expect_or_exit {connected[^\\r\\n]*idle} connected_idle_initial 10 11", + ); + expect(script).toContain("expect_or_exit {NEMOCLAW6194_CHAT_OK} chat_reply 20 21"); + expect(script).toContain( + "expect_or_exit {connected[^\\r\\n]*idle} connected_idle_after_chat 22 23", + ); + expect(script).toContain("/nemoclaw status"); + expect(script).toContain("expect_or_exit {NemoClaw Status} slash_status_output 30 31"); + expect(script).toContain( + "expect_or_exit {connected[^\\r\\n]*idle} connected_idle_after_status 32 33", + ); + expect(script).toContain("mark clean_exit"); + + const markers = [ + "connected_idle_initial", + "chat_reply", + "connected_idle_after_chat", + "slash_status_output", + "connected_idle_after_status", + "clean_exit", + ]; + for (const marker of markers) { + expect(script.match(new RegExp(`\\b${marker}\\b`, "gu")) ?? []).toHaveLength(1); + } + const order = markers.map((marker) => script.indexOf(marker)); + expect(order.every((index) => index >= 0)).toBe(true); + expect([...order].sort((a, b) => a - b)).toEqual(order); + }); + + it("confirms the two-step Ctrl+C exit without waiting for the global timeout", () => { + const script = buildIssue6194TuiExpectScript(); + const exitFlow = script.slice(script.indexOf("# Network-rule approvals belong")); + const firstCtrlC = exitFlow.indexOf('send "\\003"'); + const shortTimeout = exitFlow.indexOf(`set timeout ${ISSUE6194_TUI_EXIT_TIMEOUT_SEC}`); + const confirmation = exitFlow.indexOf("press ctrl\\+c again to exit"); + const secondCtrlC = exitFlow.indexOf('send "\\003"', firstCtrlC + 1); + + expect(ISSUE6194_TUI_EXIT_TIMEOUT_SEC).toBe(10); + expect(firstCtrlC).toBeGreaterThanOrEqual(0); + expect(shortTimeout).toBeGreaterThan(firstCtrlC); + expect(confirmation).toBeGreaterThan(shortTimeout); + expect(secondCtrlC).toBeGreaterThan(confirmation); + expect(exitFlow.split('send "\\003"')).toHaveLength(3); + expect(exitFlow).toContain("eof {}"); + expect(exitFlow).toContain("timeout { exit 39 }"); + expect(exitFlow).toContain("timeout { exit 40 }"); + expect(exitFlow).toContain("set timeout $savedTimeout"); + }); + + it("stabilizes the OpenShell PTY before a bounded dashboard wait", () => { + const script = buildIssue6194OpenShellApprovalExpectScript(); + const term = script.indexOf("set env(TERM) xterm-256color"); + const spawn = script.indexOf("spawn openshell term"); + const spawnId = script.indexOf("set termSpawn $spawn_id"); + const termPty = script.indexOf("set termPty $spawn_out(slave,name)"); + const geometry = script.indexOf("stty rows 40 columns 120 < $termPty"); + const saveTimeout = script.indexOf("set dashboardTimeout $timeout"); + const boundedTimeout = script.indexOf( + `set timeout ${ISSUE6194_OPENSHELL_DASHBOARD_TIMEOUT_SEC}`, + ); + const dashboard = script.indexOf( + "expect_exact_or_exit $termSpawn {Sandboxes} openshell_dashboard 64 65", + ); + const restoreTimeout = script.indexOf("set timeout $dashboardTimeout"); + const stopSpawn = script.slice( + script.indexOf("proc stop_spawn"), + script.indexOf("proc write_capture"), + ); + const setupOrder = [ + term, + spawn, + spawnId, + termPty, + geometry, + saveTimeout, + boundedTimeout, + dashboard, + ]; + + expect(ISSUE6194_OPENSHELL_DASHBOARD_TIMEOUT_SEC).toBe(30); + expect(setupOrder.every((index) => index >= 0)).toBe(true); + expect([...setupOrder].sort((a, b) => a - b)).toEqual(setupOrder); + expect(restoreTimeout).toBeGreaterThan(dashboard); + expect(script).not.toContain("stty -i"); + expect(stopSpawn).toContain("catch {wait -i $target -nowait}"); + expect(stopSpawn).not.toContain("catch {wait -i $target}"); + expect(script).toContain("catch {wait -i $curlSpawn} curlWait"); + expect(script).toContain("catch {wait -i $termSpawn} termWait"); + }); + + it("drives a direct blocked request through the real OpenShell approval surface", () => { + const script = buildIssue6194OpenShellApprovalExpectScript(); + + expect(ISSUE6194_NETWORK_APPROVAL_ENDPOINT).toBe( + "https://api.atlassian.com/oauth/token/accessible-resources", + ); + expect(ISSUE6194_NETWORK_APPROVAL_HOST).toBe("api.atlassian.com"); + expect(script).toContain("spawn openshell term"); + expect(script).not.toContain("openshell rule clear"); + expect(script).toContain("exec openshell sandbox list --names"); + expect(script).toContain("exec openshell rule get $sandbox --status pending"); + expect(script).toContain("set expectedEmpty \"No network rules for sandbox '$sandbox'\""); + expect(script).toContain("set termSpawn $spawn_id"); + expect(script).toContain( + "expect_exact_or_exit $termSpawn {Sandboxes} openshell_dashboard 64 65", + ); + expect(script.match(/send -i \$termSpawn -- "\\t"/gu) ?? []).toHaveLength(2); + expect(script).toContain( + "expect_exact_or_exit $termSpawn {Name:} openshell_sandbox_detail 68 69", + ); + expect(script).not.toContain("(Dashboard-)?Sandbox:"); + expect(script).toContain('send -i $termSpawn -- "r"'); + expect(script).toContain( + "expect_exact_or_exit $termSpawn {Network Rules} network_rules_focused", + ); + expect(script).toContain( + "spawn -noecho openshell sandbox exec --name $sandbox --no-tty --timeout 40 -- /usr/bin/curl -sS --connect-timeout 5 --max-time 30 -o /dev/null $networkEndpoint", + ); + expect(script).toContain("set curlSpawn $spawn_id"); + expect(script).toContain("expect {\n -i $curlSpawn\n"); + expect(script).toContain("catch {wait -i $curlSpawn} curlWait"); + expect(script).toContain( + "set chunkCount [regexp -all -line {^[[:space:]]*Chunk:} $pendingOutput]", + ); + const stripRuleSgr = script.indexOf( + 'regsub -all {\\x1b\\[[0-9;]*m} $candidate "" pendingOutput', + ); + const parseRuleLabels = script.indexOf( + "set chunkCount [regexp -all -line {^[[:space:]]*Chunk:} $pendingOutput]", + ); + expect(stripRuleSgr).toBeGreaterThanOrEqual(0); + expect(parseRuleLabels).toBeGreaterThan(stripRuleSgr); + expect(script).toContain("Network Rules:[^\\r\\n]*1 chunk"); + expect(script).toContain("Status:[[:space:]]*pending"); + expect(script).toContain("Binary:[[:space:]]*/usr/bin/curl"); + expect(script).toContain( + "expect_or_exit $termSpawn {Status:[^\\r\\n]*pending} network_rule_detail", + ); + expect(script).toContain( + "expect_or_exit $termSpawn {Binary:[^\\r\\n]*/usr/bin/curl} network_rule_detail_binary", + ); + expect(script).toContain( + "expect_or_exit $termSpawn {\\[a\\][^\\r\\n]*Approve} network_rule_approve_action", + ); + expect(script).toContain('send -i $termSpawn -- "a"'); + expect(script).toContain("-nocase -re {Approved[^\\r\\n]*'[^']+'[^\\r\\n]*policy v([0-9]+)}"); + expect(script).toContain("set approvedPolicyVersion $expect_out(1,string)"); + expect(script).toContain( + 'set policyStatusOutput "ISSUE6194_APPROVED_POLICY_VERSION=$approvedPolicyVersion\\n"', + ); + expect(script).toContain("set policyLoadDeadline [expr {[clock milliseconds] + 60000}]"); + expect(script).toContain("while {[clock milliseconds] < $policyLoadDeadline}"); + expect(script).toContain("incr attempt"); + expect(script).toContain( + "exec timeout 2 openshell policy get $sandbox --rev $approvedPolicyVersion --output json", + ); + expect(script).toContain( + 'append policyStatusOutput "ISSUE6194_POLICY_STATUS_ATTEMPT=$attempt\\n$candidate\\n"', + ); + expect(script).toContain("set policyTerminalStatus timeout"); + expect(script).toContain( + 'set versionPattern [format {"version"[[:space:]]*:[[:space:]]*%s([[:space:]]|,)} $approvedPolicyVersion]', + ); + expect(script).toContain( + 'set activePattern [format {"active_version"[[:space:]]*:[[:space:]]*%s([[:space:]]|,)} $approvedPolicyVersion]', + ); + expect(script).toContain( + 'append policyStatusOutput "ISSUE6194_ACTIVE_POLICY_VERSION=$approvedPolicyVersion\\n"', + ); + expect(script).toContain('append policyStatusOutput "ISSUE6194_POLICY_STATUS=loaded\\n"'); + expect(script).toContain( + 'append policyStatusOutput "ISSUE6194_POLICY_STATUS=$policyTerminalStatus\\n"', + ); + expect(script).toContain('regexp {"status"[[:space:]]*:[[:space:]]*"loaded"} $candidate'); + expect(script).toContain("write_capture $policyCapture $policyStatusOutput"); + expect(script).toContain("ISSUE6194_DIAGNOSTIC approved policy revision did not become active"); + expect(ISSUE6194_OPENSHELL_APPROVAL_TIMEOUT_BUFFER_SEC).toBe(120); + expect(script).toContain( + "spawn -noecho openshell sandbox exec --name $sandbox --no-tty --timeout 20 -- /usr/bin/curl -sS --connect-timeout 5 --max-time 10 -o /dev/null -w {ISSUE6194_POLICY_HTTP_STATUS=%{http_code}\\n} $networkEndpoint", + ); + expect(script).toContain("set policySpawn $spawn_id"); + expect(script).toContain("expect {\n -i $policySpawn\n"); + expect(script).toContain("catch {wait -i $policySpawn} policyWait"); + expect(script).toContain('write_capture $policyCapture "$policyStatusOutput$policyOutput"'); + expect(script).toContain("regexp {ISSUE6194_POLICY_HTTP_STATUS=401(\\r?\\n|$)} $policyOutput"); + expect(script).not.toContain("{Approved '[^']+'"); + expect(script).not.toContain('send -i $termSpawn -- "A"'); + expect(script).not.toContain('send -i $termSpawn -- "y"'); + + const approvalAcknowledged = script.indexOf("network_approval_processed"); + const postApprovalStatus = script.indexOf( + "exec timeout 2 openshell policy get $sandbox --rev $approvedPolicyVersion --output json", + ); + const postApprovalLoaded = script.indexOf("mark network_policy_loaded"); + const postApprovalRetry = script.indexOf("ISSUE6194_POLICY_HTTP_STATUS=%{http_code}"); + const postApprovalCapture = script.indexOf( + 'write_capture $policyCapture "$policyStatusOutput$policyOutput"', + ); + const postApprovalVerified = script.indexOf("mark network_policy_updated"); + const postApprovalOrder = [ + approvalAcknowledged, + postApprovalStatus, + postApprovalLoaded, + postApprovalRetry, + postApprovalCapture, + postApprovalVerified, + ]; + expect(postApprovalOrder.every((index) => index >= 0)).toBe(true); + expect([...postApprovalOrder].sort((a, b) => a - b)).toEqual(postApprovalOrder); + + const markers = [ + "sole_sandbox_verified", + "pending_queue_empty", + "openshell_dashboard", + "openshell_sandbox_listed", + "openshell_sandbox_detail", + "openshell_sandbox_detail_name", + "network_rules_focused", + "network_request_triggered", + "network_request_completed", + "network_rule_singleton", + "network_rule_endpoint", + "network_rule_detail", + "network_rule_detail_binary", + "network_rule_detail_endpoint", + "network_rule_approve_action", + "network_approval_processed", + "network_policy_loaded", + "network_policy_updated", + "openshell_clean_exit", + ]; + const order = markers.map((marker) => script.indexOf(marker)); + expect(order.every((index) => index >= 0)).toBe(true); + expect([...order].sort((a, b) => a - b)).toEqual(order); + }); + + it("binds multi-pattern waits to their intended Expect spawn (#6194)", () => { + const script = buildIssue6194OpenShellApprovalExpectScript(); + const spawnScopedWaits = script.match( + /expect \{\n\s+-i \$(?:target|curlSpawn|policySpawn|termSpawn)\n/gu, + ); + + expect(spawnScopedWaits).toHaveLength(7); + expect(script).not.toMatch(/\bexpect -i \$/u); + expect(script).toContain("-nocase -ex $value { mark $markName }"); + expect(script).not.toContain("-nocase -exact"); + }); + + it.each([ + "blocked", + "denied", + "rejected", + ])("does not map assistant prose containing '%s' to the former refusal exit", (word) => { + const tuiScript = buildIssue6194TuiExpectScript(); + const approvalScript = buildIssue6194OpenShellApprovalExpectScript(); + const assistantTranscript = `The request was ${word} because this model has no network tools.`; + + expect(assistantTranscript).toContain(word); + expect(tuiScript).not.toContain("Use an available tool"); + expect(tuiScript).not.toContain("NEMOCLAW_ISSUE_6194_NETWORK_ENDPOINT"); + expect(tuiScript).not.toContain("(blocked|denied|rejected)"); + expect(`${tuiScript}\n${approvalScript}`).not.toMatch(/exit 50\b/u); + expect(approvalScript).not.toContain("assistantTranscript"); + }); + + it("redacts secrets from ANSI terminal captures before artifact publication", () => { + const secret = "nvapi-secret-issue-6194"; + const secrets = new SecretStore({ NVIDIA_INFERENCE_API_KEY: secret }, (note?: string) => { + throw new Error(note ?? "unexpected skip"); + }); + const capture = `before \u001b[32m${secret}\u001b[0m after`; + + const redactedCapture = secrets.redact(capture, [secret]); + const plainCapture = stripTerminalControl(redactedCapture); + + expect(redactedCapture).not.toContain(secret); + expect(plainCapture).not.toContain(secret); + expect(plainCapture).toContain("[REDACTED]"); + }); + + it("precreates captures and writes structured diagnostics before capture assertions", () => { + const captureDir = mkdtempSync(join(tmpdir(), "nemoclaw-issue6194-contract-")); + const captureFile = join(captureDir, "capture.log"); + const missingFile = join(captureDir, "missing.log"); + + try { + expect(readIssue6194Capture(missingFile)).toEqual({ exists: false, contents: "" }); + expect(() => readIssue6194Capture("\0")).toThrow(); + + precreateIssue6194Capture(captureFile); + expect(readIssue6194Capture(captureFile)).toEqual({ exists: true, contents: "" }); + + writeFileSync(captureFile, "ISSUE6194_MARK diagnostic"); + expect(statSync(captureFile).mode & 0o777).toBe(0o600); + expect(readIssue6194Capture(captureFile)).toEqual({ + exists: true, + contents: "ISSUE6194_MARK diagnostic", + }); + + const liveSource = readFileSync( + new URL("../live/openclaw-tui-chat-correlation.test.ts", import.meta.url), + "utf8", + ); + const tuiPrecreate = liveSource.indexOf("precreateIssue6194Capture(captureFile)"); + const tuiCommand = liveSource.indexOf('host.command("expect", [expectScript]'); + const tuiResult = liveSource.indexOf('artifacts.writeJson("issue6194-target-result.json"'); + const tuiCaptureAssertion = liveSource.indexOf( + 'expect(tuiCapture.exists, "TUI expect capture must exist")', + ); + const approvalPrecreate = liveSource.indexOf( + "precreateIssue6194Capture(approvalCaptureFile)", + ); + const policyPrecreate = liveSource.indexOf("precreateIssue6194Capture(policyCaptureFile)"); + const approvalCommand = liveSource.indexOf('host.command("expect", [approvalExpectScript]'); + const approvalTimeout = liveSource.indexOf( + "ISSUE6194_OPENSHELL_APPROVAL_TIMEOUT_BUFFER_SEC", + approvalCommand, + ); + const approvalResult = liveSource.indexOf( + 'artifacts.writeJson("issue6194-approval-result.json"', + ); + const approvalCaptureAssertion = liveSource.indexOf( + 'expect(approvalCapture.exists, "OpenShell approval capture must exist")', + ); + const websocketCommand = liveSource.indexOf( + "const { repro, attempts } = await runLiveIssue2603ReproWithEventCaptureRetry", + approvalCaptureAssertion, + ); + const websocketResult = liveSource.indexOf( + 'artifacts.writeJson("issue2603-trace.json"', + websocketCommand, + ); + const websocketAssertion = liveSource.indexOf("if (repro.error)", websocketResult); + + expect(tuiPrecreate).toBeGreaterThanOrEqual(0); + expect(tuiCommand).toBeGreaterThan(tuiPrecreate); + expect(tuiResult).toBeGreaterThan(tuiCommand); + expect(tuiCaptureAssertion).toBeGreaterThan(tuiResult); + expect(approvalPrecreate).toBeGreaterThan(tuiCaptureAssertion); + expect(policyPrecreate).toBeGreaterThan(approvalPrecreate); + expect(approvalCommand).toBeGreaterThan(policyPrecreate); + expect(approvalTimeout).toBeGreaterThan(approvalCommand); + expect(approvalTimeout).toBeLessThan(approvalResult); + expect(approvalResult).toBeGreaterThan(approvalCommand); + expect(approvalCaptureAssertion).toBeGreaterThan(approvalResult); + expect(websocketCommand).toBeGreaterThan(approvalCaptureAssertion); + expect(websocketResult).toBeGreaterThan(websocketCommand); + expect(websocketAssertion).toBeGreaterThan(websocketResult); + } finally { + rmSync(captureDir, { recursive: true, force: true }); + } + }); +}); diff --git a/tools/e2e/workflow-boundary.mts b/tools/e2e/workflow-boundary.mts index 77a574f312..825a758644 100644 --- a/tools/e2e/workflow-boundary.mts +++ b/tools/e2e/workflow-boundary.mts @@ -885,6 +885,31 @@ function validateIssue4434HostDependencies(errors: string[], jobs: WorkflowRecor ); } +function validateOpenclawTuiChatCorrelationHostDependencies( + errors: string[], + jobs: WorkflowRecord, +): void { + const jobName = "openclaw-tui-chat-correlation"; + const job = asRecord(jobs[jobName]); + if (Object.keys(job).length === 0) { + errors.push(`workflow missing ${jobName} job`); + return; + } + const steps = asSteps(job.steps); + validateInlineHostDependencyInstall( + errors, + jobName, + steps, + "Install OpenClaw TUI host dependencies", + ["expect"], + ); + const install = requireJobStep(errors, jobName, steps, "Install OpenClaw TUI host dependencies"); + const prepare = requireJobStep(errors, jobName, steps, "Prepare E2E workspace"); + if (install && prepare && steps.indexOf(install) >= steps.indexOf(prepare)) { + errors.push(`${jobName} host dependencies must be installed before workspace prep`); + } +} + function validateCommonEgressAgentJob(errors: string[], jobs: WorkflowRecord): void { const jobName = "common-egress-agent"; const job = asRecord(jobs[jobName]); @@ -3931,6 +3956,7 @@ export function validateE2eWorkflowBoundary(workflowPath = DEFAULT_E2E_WORKFLOW_ "issue-4434-tui-unreachable-inference", ); validateIssue4434HostDependencies(errors, jobs); + validateOpenclawTuiChatCorrelationHostDependencies(errors, jobs); validateDiagnosticsJob(errors, jobs); validateModelRouterProviderRoutedInferenceJob(errors, jobs); validateSnapshotCommandsJob(errors, jobs);