From 347c39bd11da76b1fff8efc8c4f3b4af968c8dc5 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sun, 11 Oct 2026 20:43:51 +0100 Subject: [PATCH 1/2] Keep Compose warnings out of MSP startup readback The exact-head hosted startup check reaches Docker exec with readback taken from CombinedOutput. Separate stdout from successful stderr warnings for identities, endpoints, mount hashes, bootstrap facts and cleanup, while retaining warnings and both failed-command streams. Add real-child-process controls and the matching deployment acceptance contract; hosted Compose acceptance remains required. Change-source: pulse-maintainer --- .../subsystems/deployment-installability.md | 10 +++ .../provider_msp_startup_command_test.go | 83 +++++++++++++++++++ .../installtests/provider_msp_startup_test.go | 14 +++- 3 files changed, 104 insertions(+), 3 deletions(-) create mode 100644 scripts/installtests/provider_msp_startup_command_test.go diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index cc76794db9..02b4695e2a 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -1515,6 +1515,16 @@ router, customer data, production Docker socket, licence service or release publisher. The offline process transport verifies the daemon but is not proof of the native Compose mount or a full provider installation/tenant lifecycle. +The startup acceptance helper treats only command stdout as machine readback. +Successful Compose stderr warnings remain in the test log, never in container +identities, published loopback ports, mount hashes, bootstrap facts or teardown +inventories. Failed commands still fail acceptance and retain both output streams. +`TestProviderMSPStartupCommandReadbackSeparatesStderr` exercises all six readback +shapes with a real child process that emits an unset-variable warning; +`TestProviderMSPStartupCommandFailureRetainsBothStreams` verifies non-zero exit +and diagnostic retention. These offline controls do not establish hosted Compose +readiness, mounted-secret identity, persisted restart or clean owned teardown. + ## Canonical Files 1. `internal/updates/` diff --git a/scripts/installtests/provider_msp_startup_command_test.go b/scripts/installtests/provider_msp_startup_command_test.go new file mode 100644 index 0000000000..f17834258b --- /dev/null +++ b/scripts/installtests/provider_msp_startup_command_test.go @@ -0,0 +1,83 @@ +package installtests + +import ( + "fmt" + "os" + "os/exec" + "strings" + "testing" +) + +// Exercise the real command helper with separate child-process output streams. +// Compose emits unset-variable warnings on stderr even when a read succeeds. +func TestProviderMSPStartupCommandReadbackSeparatesStderr(t *testing.T) { + binary, err := os.Executable() + if err != nil { + t.Fatal(err) + } + for _, tc := range []struct { + name, stdout string + }{ + {"container_identity", strings.Repeat("a", 64) + "\n"}, + {"mapped_port", "127.0.0.1:38443\n"}, + {"mount_digest", strings.Repeat("b", 64) + " /run/secrets/provider_msp_license\n"}, + {"bootstrap_facts", "provider_msp_bootstrap_ok=true\naccount_id=synthetic\n"}, + {"stopped_state", "false 0\n"}, + {"no_owned_resources", ""}, + } { + t.Run(tc.name, func(t *testing.T) { + env := append(os.Environ(), + "PULSE_TEST_MSP_COMMAND_HELPER=emit", + "PULSE_TEST_MSP_COMMAND_STDOUT="+tc.stdout, + `PULSE_TEST_MSP_COMMAND_STDERR=time="synthetic" level=warning msg="The RESEND_API_KEY variable is not set. Defaulting to a blank string."`+"\n", + ) + got := startupCommand(t, env, t.TempDir(), binary, "-test.run=^TestProviderMSPStartupCommandHelper$") + if got != tc.stdout { + t.Fatalf("machine readback contains stderr or loses stdout: got %q, want %q", got, tc.stdout) + } + }) + } +} + +func TestProviderMSPStartupCommandFailureRetainsBothStreams(t *testing.T) { + binary, err := os.Executable() + if err != nil { + t.Fatal(err) + } + cmd := exec.Command(binary, "-test.run=^TestProviderMSPStartupCommandHelper$") + cmd.Env = append(os.Environ(), "PULSE_TEST_MSP_COMMAND_HELPER=fail-wrapper") + output, err := cmd.CombinedOutput() + if err == nil { + t.Fatal("a failed command must fail acceptance") + } + for _, want := range []string{"command failed: exit status 23", "synthetic stdout diagnostic", "synthetic stderr diagnostic"} { + if !strings.Contains(string(output), want) { + t.Fatalf("failed command loses diagnostic %q: %s", want, output) + } + } +} + +func TestProviderMSPStartupCommandHelper(t *testing.T) { + switch os.Getenv("PULSE_TEST_MSP_COMMAND_HELPER") { + case "": + return + case "emit": + fmt.Fprint(os.Stdout, os.Getenv("PULSE_TEST_MSP_COMMAND_STDOUT")) + fmt.Fprint(os.Stderr, os.Getenv("PULSE_TEST_MSP_COMMAND_STDERR")) + os.Exit(0) + case "emit-failure": + fmt.Fprintln(os.Stdout, "synthetic stdout diagnostic") + fmt.Fprintln(os.Stderr, "synthetic stderr diagnostic") + os.Exit(23) + case "fail-wrapper": + binary, err := os.Executable() + if err != nil { + t.Fatal(err) + } + env := append(os.Environ(), "PULSE_TEST_MSP_COMMAND_HELPER=emit-failure") + startupCommand(t, env, t.TempDir(), binary, "-test.run=^TestProviderMSPStartupCommandHelper$") + t.Fatal("failed command was accepted") + default: + t.Fatal("unknown synthetic command helper mode") + } +} diff --git a/scripts/installtests/provider_msp_startup_test.go b/scripts/installtests/provider_msp_startup_test.go index 47ece0c5c5..1c968faf29 100644 --- a/scripts/installtests/provider_msp_startup_test.go +++ b/scripts/installtests/provider_msp_startup_test.go @@ -265,12 +265,20 @@ func startupCommand(t *testing.T, env []string, dir, program string, args ...str defer cancel() cmd := exec.CommandContext(ctx, program, args...) cmd.Dir, cmd.Env = dir, env - output, err := cmd.CombinedOutput() + // Compose may warn on stderr even for a successful ps/port/config read. + // Only stdout is machine readback: a warning must never become a container + // identity, URL, mount digest, bootstrap fact or owned-resource observation. + var stdout, stderr bytes.Buffer + cmd.Stdout, cmd.Stderr = &stdout, &stderr + err := cmd.Run() if err != nil { // Environment and licence contents are never diagnostic output. - t.Fatalf("%s command failed: %v\n%s", filepath.Base(program), err, output) + t.Fatalf("%s command failed: %v\nstdout:\n%s\nstderr:\n%s", filepath.Base(program), err, stdout.String(), stderr.String()) } - return string(output) + if stderr.Len() != 0 { + t.Logf("%s stderr: %s", filepath.Base(program), stderr.String()) + } + return stdout.String() } func startupRefusal(t *testing.T, env []string, dir, want, program string, args ...string) { From 00680c9c5a6ab2955bc4f34bf83e7afd4876cea1 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sun, 11 Oct 2026 20:51:38 +0100 Subject: [PATCH 2/2] Restore native installer compilation and frontend style gates The refreshed proposal compiles the Linux-only control-plane startup fixture during Windows installer tests, failing on Unix-only dependencies before lifecycle assertions run. Exclude the fixture and its controls at compile time outside Linux, check Go file selection, and preserve required native Windows/macOS execution. Format only the three test files rejected by the exact containing CI, with Prettier AST-equivalence checks; no assertion, timeout, production source or workflow gate changes. Change-source: pulse-maintainer --- .../subsystems/deployment-installability.md | 8 +++++++ .../__tests__/agentTokenFileGuide.test.ts | 13 ++++++++--- .../docs/__tests__/alertOffFAQ.test.ts | 23 ++++++++++++++----- .../src/utils/__tests__/docsLinks.test.ts | 4 +--- .../provider_msp_startup_command_test.go | 21 +++++++++++++++++ .../installtests/provider_msp_startup_test.go | 2 ++ 6 files changed, 59 insertions(+), 12 deletions(-) diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index 02b4695e2a..8d69373b9d 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -1525,6 +1525,14 @@ shapes with a real child process that emits an unset-variable warning; and diagnostic retention. These offline controls do not establish hosted Compose readiness, mounted-secret identity, persisted restart or clean owned teardown. +The Linux-only startup fixture and its command controls must be excluded at +compile time on other platforms. A runtime platform skip cannot prevent the +control-plane's Unix-only dependencies from breaking Windows installer checks. +`TestProviderMSPStartupFixturesStayLinuxOnly` exercises Go's file-selection +rules for Linux, Windows and macOS; cross-compilation of the complete installer +test package must also succeed. This does not substitute for execution of the +Windows lifecycle controls or macOS installer and logging checks on native CI. + ## Canonical Files 1. `internal/updates/` diff --git a/frontend-modern/src/features/docs/__tests__/agentTokenFileGuide.test.ts b/frontend-modern/src/features/docs/__tests__/agentTokenFileGuide.test.ts index 73d72364e2..eeff5e1ae2 100644 --- a/frontend-modern/src/features/docs/__tests__/agentTokenFileGuide.test.ts +++ b/frontend-modern/src/features/docs/__tests__/agentTokenFileGuide.test.ts @@ -25,7 +25,9 @@ describe('agent token-file help in the shipped Markdown renderer', () => { expect(link?.getAttribute('href')).toBe(`#${id}`); expect(link?.hasAttribute('data-doc-link')).toBe(false); expect(link?.hasAttribute('target')).toBe(false); - expect(article.querySelector(`#${id}`)?.textContent).toBe('Token-file format is not shell syntax'); + expect(article.querySelector(`#${id}`)?.textContent).toBe( + 'Token-file format is not shell syntax', + ); expect(guide).toBe( readFileSync(path.join(repoRoot, 'frontend-modern/public/docs/UNIFIED_AGENT.md'), 'utf8'), ); @@ -55,10 +57,15 @@ describe('agent token-file help in the shipped Markdown renderer', () => { ].entries()) { expect(rows[index].textContent).toContain(phrase); } - const text = parts.map((element) => element.textContent).join(' ').replace(/\s+/g, ' '); + const text = parts + .map((element) => element.textContent) + .join(' ') + .replace(/\s+/g, ' '); expect(text).toContain('not the value, length, hint, screenshot or file'); expect(text).toContain('Do not print, source, upload or edit the installed token file'); expect(text).toContain('not proof of accepted reporting'); - expect(parts.some((element) => element.matches('pre') || element.querySelector('pre'))).toBe(false); + expect(parts.some((element) => element.matches('pre') || element.querySelector('pre'))).toBe( + false, + ); }); }); diff --git a/frontend-modern/src/features/docs/__tests__/alertOffFAQ.test.ts b/frontend-modern/src/features/docs/__tests__/alertOffFAQ.test.ts index 75c24c7492..e481b4523e 100644 --- a/frontend-modern/src/features/docs/__tests__/alertOffFAQ.test.ts +++ b/frontend-modern/src/features/docs/__tests__/alertOffFAQ.test.ts @@ -5,7 +5,8 @@ import { describe, expect, it } from 'vitest'; import { renderDocMarkdown } from '../docMarkdown'; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../../../..'); -const guide = (name: string): string => readFileSync(path.join(root, 'frontend-modern/public/docs', `${name}.md`), 'utf8'); +const guide = (name: string): string => + readFileSync(path.join(root, 'frontend-modern/public/docs', `${name}.md`), 'utf8'); const article = (name: string): HTMLElement => { const element = document.createElement('article'); element.innerHTML = renderDocMarkdown(guide(name), name); @@ -15,7 +16,11 @@ const section = (): HTMLElement => { const heading = article('FAQ').querySelector('#can-i-disable-alerts-for-specific-metrics'); expect(heading).not.toBeNull(); const element = document.createElement('section'); - for (let next = heading!.nextElementSibling; next && !/^H[1-6]$/.test(next.tagName); next = next.nextElementSibling) { + for ( + let next = heading!.nextElementSibling; + next && !/^H[1-6]$/.test(next.tagName); + next = next.nextElementSibling + ) { element.appendChild(next.cloneNode(true)); } return element; @@ -28,11 +33,14 @@ describe('shipped alert Off FAQ', () => { expect(text).toContain('not every default has a per-resource control'); expect(text).toContain('save, then reopen the editor'); expect(element.querySelectorAll('li')).toHaveLength(4); - for (const phrase of ['does not hide its readings, stop collection', - 'explicit threshold can remain active', 'Already queued notifications', + for (const phrase of [ + 'does not hide its readings, stop collection', + 'explicit threshold can remain active', + 'Already queued notifications', 'Patrol findings are separate from threshold alerts', 'when the corresponding alert default is Off', - 'Do not induce an alert, send another Test or retry the queue']) { + 'Do not induce an alert, send another Test or retry the queue', + ]) { expect(text).toContain(phrase); } expect(element.querySelector('pre')).toBeNull(); @@ -42,7 +50,10 @@ describe('shipped alert Off FAQ', () => { it('routes the two independent warning paths to real shipped headings', () => { const links = [...section().querySelectorAll('a')]; expect(links).toHaveLength(2); - expect(links.map((link) => link.textContent)).toEqual(['how Patrol works', 'ordinary notification checks']); + expect(links.map((link) => link.textContent)).toEqual([ + 'how Patrol works', + 'ordinary notification checks', + ]); for (const link of links) { const [name, fragment] = link.getAttribute('href')!.replace('/docs/', '').split('#'); expect(article(name).querySelector(`#${fragment}`)).not.toBeNull(); diff --git a/frontend-modern/src/utils/__tests__/docsLinks.test.ts b/frontend-modern/src/utils/__tests__/docsLinks.test.ts index 729b1c65b8..411703e438 100644 --- a/frontend-modern/src/utils/__tests__/docsLinks.test.ts +++ b/frontend-modern/src/utils/__tests__/docsLinks.test.ts @@ -657,9 +657,7 @@ describe('docsLinks', () => { /if !reportCallerMayUseAI\(ctx\) \{\s*return nil, nil, h\.resolveExistingFindings\(ctx\)\s*\}\s*return h\.narratorResolver\(ctx\)/, ); expect(resolver.match(/h\.narratorResolver\(/g)).toHaveLength(1); - expect(fleet).toMatch( - /_, fleetNarrator, findings := h\.resolveNarrator\(ctx\)/, - ); + expect(fleet).toMatch(/_, fleetNarrator, findings := h\.resolveNarrator\(ctx\)/); expect(fleet.match(/h\.resolveNarrator\(/g)).toHaveLength(1); }); diff --git a/scripts/installtests/provider_msp_startup_command_test.go b/scripts/installtests/provider_msp_startup_command_test.go index f17834258b..5d99adde72 100644 --- a/scripts/installtests/provider_msp_startup_command_test.go +++ b/scripts/installtests/provider_msp_startup_command_test.go @@ -1,13 +1,34 @@ +//go:build linux + package installtests import ( "fmt" + "go/build" "os" "os/exec" "strings" "testing" ) +func TestProviderMSPStartupFixturesStayLinuxOnly(t *testing.T) { + for _, platform := range []string{"linux", "windows", "darwin"} { + for _, name := range []string{"provider_msp_startup_test.go", "provider_msp_startup_command_test.go"} { + t.Run(platform+"/"+name, func(t *testing.T) { + ctx := build.Default + ctx.GOOS, ctx.GOARCH = platform, "amd64" + included, err := ctx.MatchFile(repoFile("scripts", "installtests"), name) + if err != nil { + t.Fatal(err) + } + if included != (platform == "linux") { + t.Fatalf("%s fixture inclusion on %s is %v, want %v", name, platform, included, platform == "linux") + } + }) + } + } +} + // Exercise the real command helper with separate child-process output streams. // Compose emits unset-variable warnings on stderr even when a read succeeds. func TestProviderMSPStartupCommandReadbackSeparatesStderr(t *testing.T) { diff --git a/scripts/installtests/provider_msp_startup_test.go b/scripts/installtests/provider_msp_startup_test.go index 1c968faf29..212e6113cd 100644 --- a/scripts/installtests/provider_msp_startup_test.go +++ b/scripts/installtests/provider_msp_startup_test.go @@ -1,3 +1,5 @@ +//go:build linux + package installtests import (