From b21f086e85ec422338c567fea68f4455f796dd97 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Thu, 1 Oct 2026 14:07:33 +0100 Subject: [PATCH 1/3] Keep Unix lifecycle credentials out of copied commands Replace literal credential bootstraps with the exercised bounded Core private-entry pattern, preserve a checked local private-file route for non-terminal FreeBSD command fields, and consolidate Unix repair, upgrade and removal transport without changing token issuance, TLS choice or host identity. Removal does not depend on a new binary preflight. Windows credential transport remains the next rework step. Add executable PTY/file/lifecycle proofs and the missing DOMPurify after-attributes detached-subtree regression, with parent-bound browser evidence. Change-source: pulse-maintainer --- .../v6/internal/subsystems/agent-lifecycle.md | 43 +- .../v6/internal/subsystems/api-contracts.md | 42 +- .../browser-tests/unix-private-install.cjs | 257 ++++++++ .../browser-tests/unix-private-install.html | 12 + .../browser-tests/unix-private-install.tsx | 103 ++++ frontend-modern/browser-verification.json | 63 +- .../AI/__tests__/aiChatUtils.test.ts | 10 +- .../InfrastructureAgentDoctorPage.tsx | 16 +- .../InfrastructureInstallerSection.tsx | 21 +- .../InfrastructureOperationsModel.test.tsx | 16 +- .../infrastructureAgentDoctorModel.test.ts | 77 ++- ...reOperationsState.branchcov0722pm.test.tsx | 47 +- .../infrastructureOperationsModel.tsx | 37 +- .../useInfrastructureInstallState.tsx | 11 +- .../useInfrastructureOperationsState.tsx | 113 ++-- .../__tests__/agentInstallCommand.test.ts | 558 ++++++++++++------ .../src/utils/agentInstallCommand.ts | 99 ++-- 17 files changed, 1119 insertions(+), 406 deletions(-) create mode 100644 frontend-modern/browser-tests/unix-private-install.cjs create mode 100644 frontend-modern/browser-tests/unix-private-install.html create mode 100644 frontend-modern/browser-tests/unix-private-install.tsx diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index 4c1a7267b1..3d70a8035d 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -8344,20 +8344,37 @@ Focused proof lives in `internal/hostagent/agent_new_test.go` `TestGetReliableMachineIDNormalizesWindowsBraces`); the Windows recovery file is cross-compiled with `GOOS=windows go build ./internal/hostagent/`. -### Unix bootstrap survives single-line command fields +### Private Unix lifecycle bootstrap -The shared frontend Unix installer command uses explicit shell statement -separators rather than literal newlines or backslash-newline continuations. -This preserves its grammar when copied into a single-line command field (#2123). -The builder rejects embedded CR/LF in its normalized inputs rather than silently -altering a quoted URL, credential, CA path or option. Shell quoting is unchanged. -Download and unprivileged preflight still precede token-file creation and sudo -installation. Root and sudo paths retain private token directories, file modes, -TLS options, non-interactive operation and cleanup on success or failure. -`agentInstallCommand.test.ts` covers normalized-input syntax and -synthetic root/sudo execution under sh and Bash, including download, preflight -and install failures. A clipboard fixture proves input compatibility, not native -FreeBSD installation, offline dependency availability or service persistence. +The shared frontend Unix installer preserves single-line paste grammar, +quoted destinations, explicit custom CA/insecure continuity and profile flags. +Issued credentials are separate from copied commands. A privileged Bash child +owns the Core-aligned silent bounded reader and temporary 0700/0600 handoff; +maximum-size input must survive unchanged, overflow must not reach the caller's +history, and every exit must restore terminal modes and remove temporary files. +Download/preflight failures never reach credential entry or installation. +Sudo validation occurs before download; download and unprivileged preflight +still precede privileged installation. Token-optional and saved-state update +paths need no terminal or replacement credential. + +For non-terminal FreeBSD command fields, the existing page offers an absolute +local private-token-file variant, with ownership/regular-file/symlink/mode +checks and the installer's bounded trusted reader. The operator provisions and +removes that file through a trusted file path; the command neither contains its +contents nor deletes it. A GUI without private file provisioning requires a +console/SSH session; native pfSense/FreeBSD installation is not inferred. + +Unix upgrades, explicit credential repairs and uninstall now share this +complete-download transport rather than executing a partial fetch pipe or +putting tokens in argv. Repairs/removal retain canonical ID and hostname; +ordinary updates preserve their saved-state credential/identity route. Removal +skips new-binary preflight, which is not a detachment prerequisite. Issuance, +scopes, tenant/host binding, TLS and command-execution opt-in are unchanged. +Windows credential handling is deliberately unchanged pending its own complete +validated step. `agentInstallCommand.test.ts` is the executable helper proof; +`infrastructureAgentDoctorModel.test.ts` verifies the real operations closures +and existing credential/identity choices. Browser receipts cover real settings +components with synthetic APIs, not installed outcome or reporter acceptance. ### Agent Doctor handoff stays readable on a phone diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 1fcbf79804..7c10b4f07d 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -11015,19 +11015,37 @@ status-only or unrelated test for the setup contract. The registry routes only ` setup proof policy; unrelated API runtime paths retain their existing verification requirements. No runtime paths or required contract updates are exempted. -### Single-line Unix client install transport +### Single-line private Unix client install transport -`frontend-modern/src/utils/agentInstallCommand.ts` emits a single-line Unix -bootstrap with explicit shell separators so text-input paste normalization -cannot join shell statements (#2123). It retains the canonical URL, quoted -credential-to-private-file transport, custom CA/insecure continuity, and -preflight-before-install ordering; no API request, response or auth scope changes. -Normalized values containing CR/LF are rejected, not rewritten. Regression -coverage in `agentInstallCommand.test.ts` checks shell syntax after -text-input normalization, literal quoted token bytes, root/sudo private-file -permissions, cleanup and early exit when download or preflight fails. This -client transport repair does not establish the reporter's native pfSense abort -cause or successful offline installation. +`frontend-modern/src/utils/agentInstallCommand.ts` retains explicit single-line +shell grammar for paste hosts (#2123), canonical URLs, custom CA/insecure +continuity and complete-download-before-execution. Token values select private +entry only and never become shell source, argv or environment. The privileged +Bash child uses the bounded Core bootstrap reader; 4096-character input is +preserved, overflow is drained before returning to the caller, and terminal +modes and temporary 0700/0600 files are restored/removed on exit. Download and +unprivileged preflight precede credential entry and installation; sudo +validation precedes the download so authentication cannot consume token input. + +A no-terminal command can use an absolute local `tokenFilePath`. It requires a +privileged-user-owned regular non-symlink 0600 file in an owned non-symlink 0700 +parent, passes only its path, and leaves this operator-owned file untouched. +The existing installer's bounded trusted token reader still validates content +and ancestor trust. The FreeBSD card explains this existing private-file route +and does not put credentials in GUI command fields. It does not claim a GUI can +create private files or establish native appliance installation. + +Unix install, credential replacement, upgrade and uninstall use the shared +transport. Explicit replacement/uninstall retain canonical agent ID and host; +ordinary updates recover their existing credential/identity from installer +state without a new prompt. Uninstall skips new-binary preflight so detachment +is not blocked by availability of an unrelated replacement binary. Server +minting, scope, TLS choice, execution opt-in and response shapes are unchanged. +Windows command transport is unchanged and remains separate unfinished work. +`agentInstallCommand.test.ts` exercises actual shells, PTYs, private-file +boundaries and failures; `infrastructureAgentDoctorModel.test.ts` exercises the +real lifecycle closures. These are modelled installer/privilege probes, not +native system installation or publication. ### Pulse Mobile pairing readiness wording diff --git a/frontend-modern/browser-tests/unix-private-install.cjs b/frontend-modern/browser-tests/unix-private-install.cjs new file mode 100644 index 0000000000..96e520158d --- /dev/null +++ b/frontend-modern/browser-tests/unix-private-install.cjs @@ -0,0 +1,257 @@ +// UI proof only. No real token issuance, appliance, download or installation. +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { chromium, webkit } = require('playwright'); +(async () => { + const root = '/workspace/frontend-modern'; + process.chdir(root); + console.log( + JSON.stringify({ + fixtureHTML: fs.existsSync(path.join(root, 'browser-tests/unix-private-install.html')), + fixtureTSX: fs.existsSync(path.join(root, 'browser-tests/unix-private-install.tsx')), + }), + ); + const artifacts = path.join(root, 'node_modules', 'unix-private-browser'); + fs.mkdirSync(artifacts, { recursive: true }); + const { createServer } = await import(path.join(root, 'node_modules/vite/dist/node/index.js')); + const server = await createServer({ + root, + configFile: path.join(root, 'vite.config.ts'), + server: { host: '127.0.0.1', port: 5241, strictPort: true }, + }); + const results = []; + let browser; + const secret = 'browser-synthetic-install-token'; + try { + await server.listen(); + for (const [engine, width, tone] of [ + ['chromium', 1280, 'light'], + ['webkit', 390, 'dark'], + ]) { + browser = await { chromium, webkit }[engine].launch( + engine === 'chromium' + ? { headless: true, channel: 'chromium', args: ['--no-sandbox'] } + : { headless: true }, + ); + const page = await browser.newPage({ + viewport: { width, height: 900 }, + ...(engine === 'webkit' ? { isMobile: true, hasTouch: true } : {}), + }); + const errors = []; + page.on('pageerror', (e) => errors.push(e.message)); + page.on('console', (m) => { + if (m.type() === 'error') { + console.log('PAGE_CONSOLE_ERROR', m.text()); + errors.push(m.text()); + } + }); + page.on('requestfailed', (r) => console.log('REQUEST_FAILED', r.url(), r.failure())); + let minted = 0; + let optionalAuth = false; + await page.addInitScript(() => { + window.__copies = []; + Object.defineProperty(navigator, 'clipboard', { + value: { writeText: async (text) => window.__copies.push(text) }, + }); + }); + await page.route('**/api/**', async (route) => { + const url = new URL(route.request().url()); + if (!url.pathname.startsWith('/api/')) return route.continue(); + if (url.pathname === '/api/security/status') + return route.fulfill({ + json: { + requiresAuth: !optionalAuth, + hasAuthentication: !optionalAuth, + apiTokenConfigured: !optionalAuth, + }, + }); + if (url.pathname === '/api/agent-install-command') { + const body = route.request().postDataJSON(); + assert.equal(body.type, 'host'); + assert.equal(body.enableCommands, false); + minted++; + return route.fulfill({ + json: { + token: secret, + record: { + id: 'synthetic-token-record', + name: 'Synthetic installer', + prefix: 'browser', + suffix: 'token', + createdAt: new Date().toISOString(), + }, + }, + }); + } + if (url.pathname === '/api/state') + return route.fulfill({ json: { connectedInfrastructure: [] } }); + return route.fulfill({ json: { data: [] } }); + }); + await page.goto('http://127.0.0.1:5241/browser-tests/unix-private-install.html'); + await page.waitForTimeout(1000); + console.log('PAGE_TITLE', await page.title()); + console.log( + JSON.stringify({ + engine, + errors, + body: (await page.locator('body').innerText()).slice(0, 300), + }), + ); + await page + .getByRole('heading', { name: 'Private Unix agent installation', exact: true }) + .waitFor(); + if (tone === 'dark') + await page.evaluate(() => document.documentElement.classList.add('dark')); + await page.getByRole('button', { name: 'Generate token', exact: true }).first().click(); + const dialog = page.getByRole('dialog', { name: 'API token ready' }); + await dialog.waitFor(); + await page.waitForFunction(() => { + const d = document.querySelector('[role="dialog"]'); + return d && getComputedStyle(d).opacity === '1'; + }); + assert.match(await dialog.innerText(), /silent “Pulse agent token” prompt/); + const copyToken = dialog.getByRole('button', { name: 'Copy token', exact: true }); + await copyToken.focus(); + await page.keyboard.press('Enter'); + assert.equal(await page.evaluate(() => window.__copies.at(-1)), secret); + await page.screenshot({ path: path.join(artifacts, `${engine}-token.png`), fullPage: true }); + await page.keyboard.press('Escape'); + await dialog.waitFor({ state: 'hidden' }); + // The real component's platform cards and copy handlers. + const code = page.locator('code').filter({ hasText: 'Pulse agent token' }); + assert.ok((await code.count()) >= 3); + const unixCommands = await code.allTextContents(); + for (const command of unixCommands) { + assert.ok(!command.includes(secret)); + assert.ok(command.includes('--token-file "$token_file"')); + assert.ok(!/[\r\n]/.test(command)); + } + const guiCode = page.locator('code').filter({ hasText: 'token_parent=${token_file%/*}' }); + assert.equal(await guiCode.count(), 1); + const guiCommand = await guiCode.innerText(); + assert.ok(guiCommand.includes('/root/.config/pulse-agent/bootstrap-token')); + assert.ok(!guiCommand.includes('read -r')); + assert.ok(!guiCommand.includes(secret)); + assert.ok( + (await page.locator('body').innerText()).includes( + 'Never put the token in a GUI command field', + ), + ); + await page.getByRole('button', { name: 'Copy Install command', exact: true }).click(); + assert.equal(await page.evaluate(() => window.__copies.at(-1)), unixCommands[0]); + await page + .getByRole('button', { + name: 'Copy Install from a private token file (no terminal) command', + exact: true, + }) + .click(); + assert.equal(await page.evaluate(() => window.__copies.at(-1)), guiCommand); + await page + .getByRole('button', { + name: 'Copy authentication repair command for doctor-fixture', + exact: true, + }) + .click(); + assert.ok(!(await page.evaluate(() => window.__copies.at(-1))).includes(secret)); + + await page.getByRole('button', { name: 'Show token only', exact: true }).click(); + await dialog.waitFor(); + assert.equal(minted, 1); + await dialog.getByRole('button', { name: 'Dismiss', exact: true }).click(); + await page + .getByRole('button', { name: 'Show advanced connection and install options', exact: true }) + .click(); + await page + .getByLabel('Connection URL (Agent → Pulse)', { exact: true }) + .fill('https://pulse.example/base/'); + await page + .getByLabel('Custom CA certificate path (optional)', { exact: true }) + .fill('/etc/pulse/ca.pem'); + for (const label of [ + 'Copy Unix uninstall', + 'Copy Unix credential repair', + 'Copy Unix saved-state update', + ]) { + await page.getByRole('button', { name: label, exact: true }).click(); + const command = await page.evaluate(() => window.__copies.at(-1)); + assert.ok(!command.includes(secret)); + assert.ok(command.includes('https://pulse.example/base')); + assert.ok(command.includes('--cacert')); + assert.ok(!command.includes('| bash')); + if (label.includes('uninstall')) { + assert.ok(command.includes('--uninstall')); + assert.ok(command.includes('agent-fixture-42')); + assert.ok(command.includes('node.example')); + assert.ok(!command.includes('--preflight-only')); + } else { + assert.ok(command.includes('--update')); + assert.ok(command.includes('--enable-docker')); + assert.ok(command.includes('--preflight-only')); + if (label.includes('credential')) { + assert.ok(command.includes('agent-fixture-42')); + assert.ok(command.includes('node.example')); + assert.ok(command.includes('--token-file')); + } else { + assert.ok(!command.includes('--token-file')); + assert.ok(!command.includes('read -r')); + } + } + } + assert.ok( + (await page.locator('body').innerText()).includes( + 'Never insert the token into the command', + ), + ); + await page.screenshot({ + path: path.join(artifacts, `${engine}-commands.png`), + fullPage: true, + }); + optionalAuth = true; + await page.goto('http://127.0.0.1:5241/browser-tests/unix-private-install.html?optional=1'); + await page.getByRole('button', { name: 'Confirm without token', exact: true }).click(); + const optionalCode = page.locator('code').filter({ hasText: 'bootstrap_dir=$(mktemp' }); + const optionalCommands = await optionalCode.allTextContents(); + assert.ok(optionalCommands.length >= 3); + for (const command of optionalCommands) { + assert.ok(!command.includes('--token-file')); + assert.ok(!command.includes('read -r')); + } + assert.equal( + await page + .getByRole('button', { + name: 'Copy Install from a private token file (no terminal) command', + exact: true, + }) + .count(), + 0, + ); + assert.deepEqual(errors, []); + results.push({ + engine, + version: browser.version(), + width, + tone, + issued: minted, + copied: await page.evaluate(() => window.__copies.length), + unixCommands: unixCommands.length, + guiCommand: true, + optionalAuth: true, + errors, + }); + await browser.close(); + browser = undefined; + } + fs.writeFileSync( + path.join(artifacts, 'result.json'), + JSON.stringify({ playwright: require('playwright/package.json').version, results }, null, 2), + ); + console.log(JSON.stringify({ result: 'passed', artifacts, results })); + } finally { + if (browser) await browser.close(); + await server.close(); + } +})().catch((e) => { + console.error(e); + process.exitCode = 1; +}); diff --git a/frontend-modern/browser-tests/unix-private-install.html b/frontend-modern/browser-tests/unix-private-install.html new file mode 100644 index 0000000000..862d8a5e58 --- /dev/null +++ b/frontend-modern/browser-tests/unix-private-install.html @@ -0,0 +1,12 @@ + + + + + + Unix private agent installation + + +
+ + + diff --git a/frontend-modern/browser-tests/unix-private-install.tsx b/frontend-modern/browser-tests/unix-private-install.tsx new file mode 100644 index 0000000000..dd4ef40c6c --- /dev/null +++ b/frontend-modern/browser-tests/unix-private-install.tsx @@ -0,0 +1,103 @@ +// Production settings state, installer and token dialog; synthetic API data only. +import { Route, Router } from '@solidjs/router'; +import { render } from 'solid-js/web'; +import { + InfrastructureOperationsStateProvider, + useInfrastructureOperationsContext, +} from '../src/components/Settings/useInfrastructureOperationsState'; +import { InfrastructureInstallerSection } from '../src/components/Settings/InfrastructureInstallerSection'; +import { InfrastructureAgentDoctorPage } from '../src/components/Settings/InfrastructureAgentDoctorPage'; +import type { InfrastructureAgentDoctorTarget } from '../src/components/Settings/infrastructureAgentUpdateCommandsModel'; +import { TokenRevealDialog } from '../src/components/TokenRevealDialog'; +import '../src/index.css'; + +const Operations = () => { + const state = useInfrastructureOperationsContext(); + const identity = { + agentActionId: 'agent-fixture-42', + agentId: 'unused-alias', + hostname: 'node.example', + }; + const connection = { + id: 'agent:agent-fixture-42', + type: 'agent', + name: 'node.example', + address: 'node.example', + state: 'unauthorized', + agentIdentity: { hostname: 'node.example', commandsEnabled: false }, + } as Parameters[0]; + return ( +
+

Actual operations state: identity and trust options must survive.

+ + + +
+ ); +}; +const Fixture = () => ( + +
+

Private Unix agent installation

+ + + + +
+
+); +const Doctor = () => { + const target = { + key: 'agent:doctor-fixture', + connectionId: 'agent:doctor-fixture', + displayName: 'doctor-fixture', + contextLabel: 'Machine', + currentVersion: '6.4.5', + installFlags: ['--enable-docker'], + status: 'critical', + reasons: [], + evidence: [], + needsUpdate: false, + needsCredentialRepair: true, + commandPlatform: 'linux', + safeCollector: false, + actionRunnerPosture: [], + actionRunnerCredentialEligible: false, + source: 'ledger', + connection: { + id: 'agent:doctor-fixture', + type: 'agent', + name: 'doctor-fixture', + address: 'doctor.example', + state: 'unauthorized', + agentIdentity: { hostname: 'doctor.example', commandsEnabled: false }, + }, + } as InfrastructureAgentDoctorTarget; + return ; +}; +render( + () => ( + + + + ), + document.getElementById('root')!, +); diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index 7e82fe7313..949d210a07 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -1,23 +1,27 @@ { "version": 1, - "base_sha": "6b23876ec8104965c7842e194e055f3206134d4f", - "verified_at": "2026-10-01T04:21:56.727078Z", + "base_sha": "9189a7262e84b201a5af6649caf0f8cff7c6d144", + "verified_at": "2026-10-01T13:06:33.056850Z", "result": "passed", "changed_paths": [ - "frontend-modern/src/api/nodes.ts", - "frontend-modern/src/components/Settings/NodeModalSetupGuideSection.tsx", - "frontend-modern/src/components/Settings/useNodeModalState.ts", - "frontend-modern/src/stores/tokenReveal.ts" + "frontend-modern/src/components/Settings/InfrastructureAgentDoctorPage.tsx", + "frontend-modern/src/components/Settings/InfrastructureInstallerSection.tsx", + "frontend-modern/src/components/Settings/infrastructureOperationsModel.tsx", + "frontend-modern/src/components/Settings/useInfrastructureInstallState.tsx", + "frontend-modern/src/components/Settings/useInfrastructureOperationsState.tsx", + "frontend-modern/src/utils/agentInstallCommand.ts" ], "content_sha256": { - "frontend-modern/src/api/nodes.ts": "398f3b87ce192c7650a9e34f184ecdca0e8baf35b57d90ef5f8f697348675fec", - "frontend-modern/src/components/Settings/NodeModalSetupGuideSection.tsx": "e4168945744f8ec20b5439f5730b1ecd8b4811b29ec00da4e7e3798f868a1b41", - "frontend-modern/src/components/Settings/useNodeModalState.ts": "9372c3a4b346a0e31733a7042c0a65b5ebe3518b12595c6c6a68b1cadd667dcd", - "frontend-modern/src/stores/tokenReveal.ts": "c21ac970bde6229506f7d6e627430b6e91c9ea983f705a28e3d149e4415c39b7" + "frontend-modern/src/components/Settings/InfrastructureAgentDoctorPage.tsx": "416c7627303c665f956168a32e5aaf6af4268abacdd7e93045ab26cd45732d14", + "frontend-modern/src/components/Settings/InfrastructureInstallerSection.tsx": "d2ff336c1e602794fd696aed3541684498adfcfeaf1f4e603e21a44865fce3bb", + "frontend-modern/src/components/Settings/infrastructureOperationsModel.tsx": "983a44871cfb908851c215a177e4a39185f7ac9017cc1b2152323b7ee396995a", + "frontend-modern/src/components/Settings/useInfrastructureInstallState.tsx": "c57bf5a99d457d6d21f0d22e0cc34af3618db214391fd09afce34743173a1c0f", + "frontend-modern/src/components/Settings/useInfrastructureOperationsState.tsx": "276c89076d36670752f5d37be1895d739ae13501ea9d87d1cc3a497c7e6f5895", + "frontend-modern/src/utils/agentInstallCommand.ts": "9bb98b1db1b49aa478f894b346479951954899d4c6049d991ec4c75730cafa30" }, "routes": [ - "/browser-tests/proxmox-private-input.html?type=pve", - "/browser-tests/proxmox-private-input.html?type=pbs" + "/browser-tests/unix-private-install.html", + "/browser-tests/unix-private-install.html?optional=1" ], "viewports": [ { @@ -30,34 +34,29 @@ } ], "states": [ - "Actual settings state/setup section and existing token-reveal dialog with production CSS; synthetic server artifacts and no installed bootstrap execution", - "Separate command and credential copy for API Inventory and Host Telemetry Agent; raw secrets absent from copied shell text", - "PVE/PBS tokenless script download reuses live bootstrap metadata; credential cache clears on close/reopen", - "Held setup and agent issuance completed after modal close is discarded: no secret cache and no reveal dialog", - "Default TLS verification and monitoring-only agent request (enableCommands=false) unchanged", - "Desktop light and phone-emulated WebKit dark token reveal is readable; screenshots visually inspected" + "Actual infrastructure installer, operations closures, Agent Doctor and token dialog with production CSS; synthetic APIs, not installed agent/appliance execution", + "Required-auth Unix command and credential remain separate; single-line command grammar, private file no-terminal variant and separate-token instructions", + "Canonical repair/removal identity and custom CA preserved; saved-state update has no replacement credential; removal has no new-binary preflight", + "Optional-auth commands require no terminal or token-file; default TLS verification and monitoring-only issuance unchanged", + "Desktop Chromium light and phone-emulated WebKit dark; four final screenshots visually inspected" ], "interactions": [ - "Copy command; copy token via Enter and pointer; Escape and Dismiss close token dialog", - "Close/reopen setup; download script; switch API/Agent paths", - "Hold setup/agent response, close modal, then release response and verify no late reveal/cache" + "Generate scoped host token, copy token with keyboard, Escape, reopen existing reveal without issuing another token", + "Copy actual Linux installer, no-terminal private-file installer and Agent Doctor repair commands; raw credential absent from each clipboard result", + "Change endpoint and custom CA; copy actual operations-state repair/removal/saved-state update commands; identity/profile/trust checked", + "Reload with optional auth and confirm tokenless commands" ], - "command": "pulse-worker-browser frontend-modern/browser-tests/proxmox-private-input.cjs; pulse-worker-browser frontend-modern/browser-tests/proxmox-private-input.cjs --phone (separate commands from assigned workspace root)", + "command": "pulse-worker-browser frontend-modern/browser-tests/unix-private-install.cjs", "browser_versions": { "playwright": "1.56.1", "chromium": "141.0.7390.37", "webkit": "26.0" }, "artifacts": [ - "/var/lib/pulse-maintainer/worker-outputs/core-runtime-yp94gsia/browser-desktop/pbs-agent.png", - "/var/lib/pulse-maintainer/worker-outputs/core-runtime-yp94gsia/browser-desktop/pbs-setup.png", - "/var/lib/pulse-maintainer/worker-outputs/core-runtime-yp94gsia/browser-desktop/pve-agent.png", - "/var/lib/pulse-maintainer/worker-outputs/core-runtime-yp94gsia/browser-desktop/pve-setup.png", - "/var/lib/pulse-maintainer/worker-outputs/core-runtime-yp94gsia/browser-desktop/result.json", - "/var/lib/pulse-maintainer/worker-outputs/core-runtime-yp94gsia/browser-phone/pbs-agent.png", - "/var/lib/pulse-maintainer/worker-outputs/core-runtime-yp94gsia/browser-phone/pbs-setup.png", - "/var/lib/pulse-maintainer/worker-outputs/core-runtime-yp94gsia/browser-phone/pve-agent.png", - "/var/lib/pulse-maintainer/worker-outputs/core-runtime-yp94gsia/browser-phone/pve-setup.png", - "/var/lib/pulse-maintainer/worker-outputs/core-runtime-yp94gsia/browser-phone/result.json" + "/var/lib/pulse-maintainer/worker-outputs/web-product-wielhxw1/browser/chromium-commands.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-wielhxw1/browser/chromium-token.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-wielhxw1/browser/result.json", + "/var/lib/pulse-maintainer/worker-outputs/web-product-wielhxw1/browser/webkit-commands.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-wielhxw1/browser/webkit-token.png" ] } diff --git a/frontend-modern/src/components/AI/__tests__/aiChatUtils.test.ts b/frontend-modern/src/components/AI/__tests__/aiChatUtils.test.ts index d11a3c0c53..bb17caecc2 100644 --- a/frontend-modern/src/components/AI/__tests__/aiChatUtils.test.ts +++ b/frontend-modern/src/components/AI/__tests__/aiChatUtils.test.ts @@ -214,7 +214,7 @@ describe('aiChatUtils', () => { expect(link?.getAttribute('rel')).toBe('noopener noreferrer'); }); - it.each(['beforeSanitizeElements', 'uponSanitizeElement'] as const)( + it.each(['beforeSanitizeElements', 'uponSanitizeElement', 'afterSanitizeAttributes'] as const)( 'neutralizes a hook-detached IN_PLACE subtree through the %s hook', (hookName) => { // Configure the same singleton hooks used by renderMarkdown before @@ -232,8 +232,10 @@ describe('aiChatUtils', () => { }; if (hookName === 'beforeSanitizeElements') { DOMPurify.addHook('beforeSanitizeElements', removeSection); - } else { + } else if (hookName === 'uponSanitizeElement') { DOMPurify.addHook('uponSanitizeElement', removeSection); + } else { + DOMPurify.addHook('afterSanitizeAttributes', removeSection); } try { @@ -253,8 +255,10 @@ describe('aiChatUtils', () => { } finally { if (hookName === 'beforeSanitizeElements') { DOMPurify.removeHook('beforeSanitizeElements', removeSection); - } else { + } else if (hookName === 'uponSanitizeElement') { DOMPurify.removeHook('uponSanitizeElement', removeSection); + } else { + DOMPurify.removeHook('afterSanitizeAttributes', removeSection); } } }, diff --git a/frontend-modern/src/components/Settings/InfrastructureAgentDoctorPage.tsx b/frontend-modern/src/components/Settings/InfrastructureAgentDoctorPage.tsx index 8abce47160..dd6ada8420 100644 --- a/frontend-modern/src/components/Settings/InfrastructureAgentDoctorPage.tsx +++ b/frontend-modern/src/components/Settings/InfrastructureAgentDoctorPage.tsx @@ -917,6 +917,18 @@ export const InfrastructureAgentDoctorPage: Component {command()} + +

+ Run this command in a console or SSH session first, then + paste the separately revealed token at the silent Pulse + agent token prompt. Never insert the token into the command. +

+
@@ -929,7 +941,9 @@ export const InfrastructureAgentDoctorPage: Component {(entry) => ( diff --git a/frontend-modern/src/components/Settings/InfrastructureInstallerSection.tsx b/frontend-modern/src/components/Settings/InfrastructureInstallerSection.tsx index c7142a16e2..d5135b3d50 100644 --- a/frontend-modern/src/components/Settings/InfrastructureInstallerSection.tsx +++ b/frontend-modern/src/components/Settings/InfrastructureInstallerSection.tsx @@ -396,12 +396,13 @@ export const InfrastructureInstallerSection: Component First-host install token {state.latestRecord()?.name}{' '} - prepared automatically. Commands below already include this credential. + prepared automatically. Unix commands request it separately at a silent + prompt. ) : ( <> - Install token {state.latestRecord()?.name} created. Commands - below now include this credential. + Install token {state.latestRecord()?.name} created. Unix + commands keep it separate: copy the command first, then use Show token only. )} @@ -421,8 +422,8 @@ export const InfrastructureInstallerSection: Component
- Tokens are optional on this Pulse instance. Confirm to generate commands without - embedding a token. + Tokens are optional on this Pulse instance. Confirm to generate commands without a + token.
@@ -496,9 +497,9 @@ export const InfrastructureInstallerSection: Component

Copy the default command for the first host first. It checks this Pulse URL and - the matching agent binary before asking for administrator privileges, then - installs Pulse Agent as a background service on each machine where you want full - node-local telemetry. + the matching agent binary before reading a Unix token, then installs Pulse Agent + as a background service on each machine where you want full node-local + telemetry.

diff --git a/frontend-modern/src/components/Settings/__tests__/InfrastructureOperationsModel.test.tsx b/frontend-modern/src/components/Settings/__tests__/InfrastructureOperationsModel.test.tsx index 0f1926b216..b6c1818533 100644 --- a/frontend-modern/src/components/Settings/__tests__/InfrastructureOperationsModel.test.tsx +++ b/frontend-modern/src/components/Settings/__tests__/InfrastructureOperationsModel.test.tsx @@ -472,13 +472,9 @@ describe('infrastructure operations model', () => { expect(agentUpgradeStart).toBeGreaterThanOrEqual(0); expect(agentUpgradeEnd).toBeGreaterThan(agentUpgradeStart); const agentUpgradeSource = operationsStateSource.slice(agentUpgradeStart, agentUpgradeEnd); - const unixUpgradeStart = agentUpgradeSource.indexOf('let command = `curl'); - expect(unixUpgradeStart).toBeGreaterThanOrEqual(0); - const unixUpgradeSource = agentUpgradeSource.slice(unixUpgradeStart); - - expect(agentUpgradeSource).toContain( - '| bash -s -- --update --url ${shellQuoteArg(url)} --non-interactive', - ); + expect(agentUpgradeSource).toContain("const extraArgs = ['--update', ...installFlags];"); + expect(agentUpgradeSource).toContain('token: replaceCredential ? token : null,'); + expect(agentUpgradeSource).toContain('buildUnixAgentInstallCommand({'); const requiresTokenEnd = operationsStateSource.indexOf('return {', agentUpgradeEnd); expect(requiresTokenEnd).toBeGreaterThan(agentUpgradeEnd); const requiresTokenSource = operationsStateSource.slice(agentUpgradeEnd, requiresTokenEnd); @@ -487,9 +483,9 @@ describe('infrastructure operations model', () => { ); expect(requiresTokenSource).toContain("=== 'windows'"); expect(requiresTokenSource).toContain('installState.requiresToken()'); - expect(unixUpgradeSource).not.toContain('command += ` --token ${shellQuoteArg(token)}`;'); - expect(unixUpgradeSource).not.toContain('--agent-id'); - expect(unixUpgradeSource).not.toContain('--hostname'); + expect(agentUpgradeSource).not.toContain('command += ` --token ${shellQuoteArg(token)}`;'); + // Identity overrides remain inside the explicit credential-replacement branch. + expect(agentUpgradeSource).toContain('if (replaceCredential) {'); }); it('resolves connection upgrade platforms through the shared caption-tolerant resolver', async () => { diff --git a/frontend-modern/src/components/Settings/__tests__/infrastructureAgentDoctorModel.test.ts b/frontend-modern/src/components/Settings/__tests__/infrastructureAgentDoctorModel.test.ts index 8c8a38bc3e..702c93845b 100644 --- a/frontend-modern/src/components/Settings/__tests__/infrastructureAgentDoctorModel.test.ts +++ b/frontend-modern/src/components/Settings/__tests__/infrastructureAgentDoctorModel.test.ts @@ -1,4 +1,6 @@ -import { describe, expect, it } from 'vitest'; +import { execFileSync } from 'node:child_process'; +import { useInfrastructureOperationsState } from '../useInfrastructureOperationsState'; +import { describe, expect, it, vi } from 'vitest'; import type { AgentFleetAgentDiagnostic } from '@/api/agentDiagnostics'; import type { Connection } from '@/api/connections'; import type { InfrastructureSystemRow } from '../connectionsTableModel'; @@ -1097,3 +1099,76 @@ describe('Agent Doctor model', () => { }); }); }); + +// Exercise the real operations closures, not a hand-written command fixture. +// Minting/state storage are outside this narrow presentation/transport proof. +const privateInstallState = vi.hoisted(() => ({ token: 'issued-secret-not-shell-source' })); +vi.mock('../useInfrastructureInstallState', () => ({ + useInfrastructureInstallState: () => ({ + currentToken: () => privateInstallState.token, + requiresToken: () => true, + selectedAgentUrl: () => 'https://pulse.example/base', + insecureMode: () => false, + customCaPath: () => '/etc/pulse/ca.pem', + enableCommands: () => false, + }), +})); + +describe('Agent Doctor Unix credential handoffs', () => { + it('keeps repair/uninstall secrets separate while preserving identity, profile and custom trust', () => { + const operations = useInfrastructureOperationsState(); + const connection = connectionFixture({ + id: 'agent:canonical-id-42', + agentIdentity: { hostname: 'canonical.example', commandsEnabled: false }, + state: 'unauthorized', + }); + const repair = operations.getAgentConnectionUpgradeCommand( + connection, + ['--enable-docker'], + 'linux', + true, + ); + const uninstall = operations.getUninstallCommand({ + agentActionId: 'canonical-id-42', + agentId: 'old-alias', + hostname: 'canonical.example', + }); + for (const command of [repair, uninstall]) { + expect(command).not.toContain(privateInstallState.token); + expect(command).not.toContain('old-alias'); + expect(command).not.toContain('| bash'); + expect(command).toContain('--token-file "$token_file"'); + expect(command).toContain('--agent-id'); + expect(command).toContain('canonical-id-42'); + expect(command).toContain('--hostname'); + expect(command).toContain('canonical.example'); + expect(command).toContain('--cacert'); + expect(command).not.toContain('--insecure'); + expect(command).not.toContain('--enable-commands'); + execFileSync('sh', ['-n', '-c', command]); + } + expect(repair).toContain('--update'); + expect(repair).toContain('--enable-docker'); + expect(repair).toContain('--preflight-only'); + expect(uninstall).toContain('--uninstall'); + expect(uninstall).not.toContain('--preflight-only'); + }); + + it('does not mint, inject or prompt for another credential during a saved-state update', () => { + const operations = useInfrastructureOperationsState(); + const command = operations.getAgentConnectionUpgradeCommand( + connectionFixture(), + ['--enable-docker'], + 'linux', + ); + expect(command).toContain('--update'); + expect(command).toContain('--enable-docker'); + expect(command).toContain('--preflight-only'); + expect(command).not.toContain('--token-file'); + expect(command).not.toContain('read -r'); + expect(command).not.toContain(privateInstallState.token); + expect( + operations.getAgentConnectionUpgradeCommandRequiresToken(connectionFixture(), 'linux'), + ).toBe(false); + }); +}); diff --git a/frontend-modern/src/components/Settings/__tests__/useInfrastructureOperationsState.branchcov0722pm.test.tsx b/frontend-modern/src/components/Settings/__tests__/useInfrastructureOperationsState.branchcov0722pm.test.tsx index d85dae0412..27250bcb9a 100644 --- a/frontend-modern/src/components/Settings/__tests__/useInfrastructureOperationsState.branchcov0722pm.test.tsx +++ b/frontend-modern/src/components/Settings/__tests__/useInfrastructureOperationsState.branchcov0722pm.test.tsx @@ -159,51 +159,47 @@ describe('useInfrastructureOperationsState command-building closures', () => { vi.resetAllMocks(); }); - describe('withPrivilegeEscalation (via getUninstallCommand)', () => { - it('wraps a "| bash -s --" command in the root/sudo escalation block verbatim', async () => { + describe('shared Unix bootstrap (via getUninstallCommand)', () => { + it('uses the complete-download/preflight/privileged private-input path', async () => { const { state, dispose } = mountHook(); await flushAsync(); state.setCustomAgentUrl(HTTPS_URL); - const cmd = state.getUninstallCommand(); - - expect(cmd).toContain('| { if [ "$(id -u)" -eq 0 ]; then bash -s -- --uninstall --url'); - expect(cmd).toContain( - '; elif command -v sudo >/dev/null 2>&1; then sudo bash -s -- --uninstall --url', - ); - expect(cmd).toContain( - '; else echo "Root privileges required. Run as root (su -) and retry." >&2; exit 1; fi; }', - ); - // The original pipe form is consumed by the replacement. - expect(cmd).not.toMatch(/^\S+ \| bash -s --/); + expect(cmd).toContain('--uninstall'); + expect(cmd).toContain('sudo bash -c'); + expect(cmd).not.toContain('--preflight-only'); + expect(cmd).not.toContain('| bash'); + expect(cmd).not.toContain("--token '"); dispose(); }); }); describe('resolvedCommandToken (via getUninstallCommand)', () => { - it('substitutes the api-token placeholder when a token is required but absent', async () => { + it('requests private input without copying a placeholder when a token is required', async () => { const { state, dispose } = mountHook(); await flushAsync(); state.setCustomAgentUrl(HTTPS_URL); - expect(state.getUninstallCommand()).toContain("--token ''"); + expect(state.getUninstallCommand()).toContain('--token-file "$token_file"'); + expect(state.getUninstallCommand()).not.toContain(''); dispose(); }); - it('uses the minted token verbatim when one is present and required', async () => { + it('keeps the minted token separate when one is present and required', async () => { const { state, dispose } = mountHook(); await flushAsync(); state.setCustomAgentUrl(HTTPS_URL); await state.handleGenerateToken(); const cmd = state.getUninstallCommand(); - expect(cmd).toContain("--token 'tok-1'"); + expect(cmd).toContain('--token-file "$token_file"'); + expect(cmd).not.toContain('tok-1'); expect(cmd).not.toContain(''); expect(mocks.showTokenReveal).toHaveBeenCalledWith({ token: 'tok-1', record: expect.objectContaining({ id: 'rec-1', name: 'Agent' }), source: 'agent', - note: expect.stringContaining('PULSE_TOKEN or Compose environment configuration'), + note: expect.stringContaining('silent “Pulse agent token” prompt'), }); dispose(); }); @@ -221,7 +217,7 @@ describe('useInfrastructureOperationsState command-building closures', () => { token: 'tok-1', record: expect.objectContaining({ id: 'rec-1', name: 'Agent' }), source: 'agent', - note: expect.stringContaining('PULSE_TOKEN or Compose environment configuration'), + note: expect.stringContaining('silent “Pulse agent token” prompt'), }); dispose(); }); @@ -239,7 +235,9 @@ describe('useInfrastructureOperationsState command-building closures', () => { const cmd = state.getUninstallCommand(); expect(cmd).not.toContain('--token'); expect(cmd).not.toContain(''); - expect(cmd).toContain("--uninstall --url 'https://pulse.test'"); + expect(cmd).toContain('--uninstall'); + expect(cmd).toContain('--url'); + expect(cmd).toContain('https://pulse.test'); dispose(); }); }); @@ -633,8 +631,8 @@ describe('useInfrastructureOperationsState command-building closures', () => { true, ); - expect(cmd).toContain('tmp_dir=$(mktemp -d)'); - expect(cmd).toContain('printf %s \'tok-1\' > "$token_file"'); + expect(cmd).toContain('bootstrap_dir=$(mktemp -d /tmp/pulse-bootstrap.XXXXXX)'); + expect(cmd).not.toContain('tok-1'); expect(cmd).toContain('--token-file "$token_file"'); expect(cmd).toContain('--update'); expect(cmd).toContain('--enable-proxmox'); @@ -651,9 +649,10 @@ describe('useInfrastructureOperationsState command-building closures', () => { const cmd = state.getAgentConnectionUpgradeCommand(baseConnection, [], 'linux'); - expect(cmd).toContain('| { if [ "$(id -u)" -eq 0 ]; then bash -s -- --update'); + expect(cmd).toContain('--update'); + expect(cmd).toContain('sudo bash -c'); expect(cmd).not.toContain('--token-file'); - expect(cmd).not.toContain('mktemp -d'); + expect(cmd).not.toContain('token_dir='); dispose(); }); diff --git a/frontend-modern/src/components/Settings/infrastructureOperationsModel.tsx b/frontend-modern/src/components/Settings/infrastructureOperationsModel.tsx index 65708c48d1..ac4e77f974 100644 --- a/frontend-modern/src/components/Settings/infrastructureOperationsModel.tsx +++ b/frontend-modern/src/components/Settings/infrastructureOperationsModel.tsx @@ -333,6 +333,7 @@ export const buildCommandsByPlatform = ( unixCommand: string, windowsInteractiveCommand: string, windowsParameterizedCommand: string, + unixPrivateFileCommand?: string, ): Record => ({ linux: { title: 'Install on Linux', @@ -345,8 +346,9 @@ export const buildCommandsByPlatform = ( note: ( Command auto-escalates with sudo when available. Otherwise run from a root - shell (for example su -). Auto-detects your init system and works on - Debian, Ubuntu, Proxmox, Fedora, Alpine, Unraid, Synology, and more. + shell (for example su -). If a token is required, run this command first, + then paste the separately revealed token at its silent prompt. Auto-detects your init + system and works on Debian, Ubuntu, Proxmox, Fedora, Alpine, Unraid, Synology, and more. ), }, @@ -363,8 +365,10 @@ export const buildCommandsByPlatform = ( note: ( Command auto-escalates with sudo when available, so macOS may ask for your - admin password. Creates /Library/LaunchDaemons/com.pulse.agent.plist and - starts the agent automatically. + admin password. If a token is required, run this command first, then paste the + separately revealed token at its silent prompt. Creates{' '} + /Library/LaunchDaemons/com.pulse.agent.plist and starts the agent + automatically. ), }, @@ -380,12 +384,33 @@ export const buildCommandsByPlatform = ( command: unixCommand, note: ( - Run as root. Note: pfSense/OPNsense don't include bash by default. - Install it first: pkg install bash. Creates{' '} + Run in a console or SSH session as root. Paste the separately revealed token only at the + silent prompt, not into the command. For GUI command fields without a terminal, use the + private-file command below. Note: pfSense/OPNsense don't include bash + by default. Install it first: pkg install bash. Creates{' '} /usr/local/etc/rc.d/pulse-agent and starts the agent automatically. ), }, + ...(unixPrivateFileCommand + ? [ + { + label: 'Install from a private token file (no terminal)', + command: unixPrivateFileCommand, + note: ( + + Before running, save the separately revealed token to{' '} + /root/.config/pulse-agent/bootstrap-token using a trusted file editor + or upload path. The file must be root-owned with mode 0600 in a + root-owned 0700 directory. Never put the token in a GUI command + field. This command does not prompt or delete your file; remove it through the + same trusted file path afterwards. If your GUI cannot create private files, use + console or SSH instead. Bash must already be installed. + + ), + }, + ] + : []), ], }, windows: { diff --git a/frontend-modern/src/components/Settings/useInfrastructureInstallState.tsx b/frontend-modern/src/components/Settings/useInfrastructureInstallState.tsx index e9228c638a..a928b3c359 100644 --- a/frontend-modern/src/components/Settings/useInfrastructureInstallState.tsx +++ b/frontend-modern/src/components/Settings/useInfrastructureInstallState.tsx @@ -47,7 +47,7 @@ const SETUP_HANDOFF_INSTALL_STEPS = new Set([ 'kubernetes', ]); const INSTALL_TOKEN_REVEAL_NOTE = - 'Copy just this token for PULSE_TOKEN or Compose environment configuration. The generated install commands remain available after you close this dialog.'; + 'For Unix installation, run the copied command first, then paste this token only at the silent “Pulse agent token” prompt. The no-terminal command reads the private token file named beside it instead. Never insert the token into a Unix command. For Windows or container configuration, keep copied credentials private.'; const isActiveInfrastructureItem = (item: ConnectedInfrastructureItem) => item.status === 'active'; @@ -568,6 +568,15 @@ Pulse prepares the first-host install token from setup so you can move straight unixCommand, windowsInteractiveCommand, windowsParameterizedCommand, + token + ? buildUnixAgentInstallCommand({ + baseUrl: url, + tokenFilePath: '/root/.config/pulse-agent/bootstrap-token', + insecure: insecureMode(), + caCertPath: selectedCustomCaPath(), + extraArgs: getInstallerExtraArgs(), + }) + : undefined, ); return Object.entries(commands).map(([platform, meta]) => ({ platform: platform as AgentPlatform, diff --git a/frontend-modern/src/components/Settings/useInfrastructureOperationsState.tsx b/frontend-modern/src/components/Settings/useInfrastructureOperationsState.tsx index 573cd86bb8..8bf285278d 100644 --- a/frontend-modern/src/components/Settings/useInfrastructureOperationsState.tsx +++ b/frontend-modern/src/components/Settings/useInfrastructureOperationsState.tsx @@ -33,26 +33,7 @@ export const useInfrastructureOperationsState = ( ) => { const installState = useInfrastructureInstallState(options); - const withPrivilegeEscalation = (command: string) => { - if (!command.includes('| bash -s --')) return command; - return command.replace(/\|\s*bash -s --([\s\S]*)$/, (_match, args: string) => { - return `| { if [ "$(id -u)" -eq 0 ]; then bash -s --${args}; elif command -v sudo >/dev/null 2>&1; then sudo bash -s --${args}; else echo "Root privileges required. Run as root (su -) and retry." >&2; exit 1; fi; }`; - }); - }; - const selectedCustomCaPath = () => installState.customCaPath().trim(); - const urlRequiresInstallerInsecure = (url: string) => - installState.insecureMode() || url.trim().toLowerCase().startsWith('http://'); - const getInsecureFlag = (url: string) => (urlRequiresInstallerInsecure(url) ? ' --insecure' : ''); - const getCurlFlags = () => (installState.insecureMode() ? '-kfsSL' : '-fsSL'); - const getShellCustomCaCurlFlag = () => { - const caPath = selectedCustomCaPath(); - return caPath ? ` --cacert ${shellQuoteArg(caPath)}` : ''; - }; - const getShellCustomCaInstallerFlag = () => { - const caPath = selectedCustomCaPath(); - return caPath ? ` --cacert ${shellQuoteArg(caPath)}` : ''; - }; const getPowerShellTransportEnv = () => { const envAssignments: string[] = []; if (installState.insecureMode()) { @@ -99,16 +80,19 @@ export const useInfrastructureOperationsState = ( const getUninstallCommand = (row?: AgentUninstallIdentity) => { const url = installState.selectedAgentUrl(); const token = resolvedCommandToken(); - const insecure = getInsecureFlag(url); const agentId = getCanonicalUninstallAgentId(row); const hostname = getCanonicalUninstallHostname(row); - const baseArgs = token - ? `--uninstall --url ${shellQuoteArg(url)} --token ${shellQuoteArg(token)}${insecure}${getShellCustomCaInstallerFlag()}` - : `--uninstall --url ${shellQuoteArg(url)}${insecure}${getShellCustomCaInstallerFlag()}`; - const identityArgs = `${agentId ? ` --agent-id ${shellQuoteArg(agentId)}` : ''}${hostname ? ` --hostname ${shellQuoteArg(hostname)}` : ''}`; - return withPrivilegeEscalation( - `curl ${getCurlFlags()}${getShellCustomCaCurlFlag()} ${shellQuoteArg(`${url}/install.sh`)} | bash -s -- ${baseArgs}${identityArgs}`, - ); + return buildUnixAgentInstallCommand({ + baseUrl: url, + token, + insecure: installState.insecureMode(), + caCertPath: selectedCustomCaPath(), + extraArgs: [ + '--uninstall', + ...(agentId ? [`--agent-id ${shellQuoteArg(agentId)}`] : []), + ...(hostname ? [`--hostname ${shellQuoteArg(hostname)}`] : []), + ], + }); }; const getWindowsUninstallCommand = (row?: AgentUninstallIdentity) => { @@ -163,24 +147,17 @@ export const useInfrastructureOperationsState = ( extraEnvAssignments: envAssignments, }); } - let command = `curl ${getCurlFlags()}${getShellCustomCaCurlFlag()} ${shellQuoteArg(`${url}/install.sh`)} | bash -s -- --url ${shellQuoteArg(url)}`; - if (token) { - command += ` --token ${shellQuoteArg(token)}`; - } - if (row.installFlags.length > 0) { - command += ` ${row.installFlags.join(' ')}`; - } - if (urlRequiresInstallerInsecure(url)) { - command += getInsecureFlag(url); - } - command += getShellCustomCaInstallerFlag(); - if (agentId) { - command += ` --agent-id ${shellQuoteArg(agentId)}`; - } - if (hostname) { - command += ` --hostname ${shellQuoteArg(hostname)}`; - } - return withPrivilegeEscalation(command); + return buildUnixAgentInstallCommand({ + baseUrl: url, + token, + insecure: installState.insecureMode(), + caCertPath: selectedCustomCaPath(), + extraArgs: [ + ...row.installFlags, + ...(agentId ? [`--agent-id ${shellQuoteArg(agentId)}`] : []), + ...(hostname ? [`--hostname ${shellQuoteArg(hostname)}`] : []), + ], + }); }; const getAgentConnectionUpgradeCommand = ( @@ -218,41 +195,23 @@ export const useInfrastructureOperationsState = ( }); } - if (replaceCredential) { - const extraArgs = ['--update', ...installFlags]; - if (commandsEnabled || installState.enableCommands()) { - extraArgs.push('--enable-commands'); - } - // Pin the existing identity the way the Windows path does. A repair - // reinstall without it can register a fresh suffixed agent identity - // for the same machine instead of converging on this one. - if (agentId) { - extraArgs.push(`--agent-id ${shellQuoteArg(agentId)}`); - } - if (hostname) { - extraArgs.push(`--hostname ${shellQuoteArg(hostname)}`); - } - return buildUnixAgentInstallCommand({ - baseUrl: url, - token, - insecure: installState.insecureMode(), - caCertPath: selectedCustomCaPath(), - extraArgs, - }); - } - - let command = `curl ${getCurlFlags()}${getShellCustomCaCurlFlag()} ${shellQuoteArg(`${url}/install.sh`)} | bash -s -- --update --url ${shellQuoteArg(url)} --non-interactive`; - if (installFlags.length > 0) { - command += ` ${installFlags.join(' ')}`; - } + const extraArgs = ['--update', ...installFlags]; if (commandsEnabled || installState.enableCommands()) { - command += ' --enable-commands'; + extraArgs.push('--enable-commands'); } - if (urlRequiresInstallerInsecure(url)) { - command += getInsecureFlag(url); + if (replaceCredential) { + // Credential replacement pins the existing identity; ordinary updates + // recover saved identity and credentials through the installer's route. + if (agentId) extraArgs.push(`--agent-id ${shellQuoteArg(agentId)}`); + if (hostname) extraArgs.push(`--hostname ${shellQuoteArg(hostname)}`); } - command += getShellCustomCaInstallerFlag(); - return withPrivilegeEscalation(command); + return buildUnixAgentInstallCommand({ + baseUrl: url, + token: replaceCredential ? token : null, + insecure: installState.insecureMode(), + caCertPath: selectedCustomCaPath(), + extraArgs, + }); }; const getAgentConnectionUpgradeCommandRequiresToken = ( diff --git a/frontend-modern/src/utils/__tests__/agentInstallCommand.test.ts b/frontend-modern/src/utils/__tests__/agentInstallCommand.test.ts index 281e22587c..106798f27e 100644 --- a/frontend-modern/src/utils/__tests__/agentInstallCommand.test.ts +++ b/frontend-modern/src/utils/__tests__/agentInstallCommand.test.ts @@ -1,7 +1,16 @@ -import { execFileSync } from 'node:child_process'; -import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { execFileSync, spawnSync } from 'node:child_process'; +import { + chmodSync, + existsSync, + mkdirSync, + symlinkSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs'; import { tmpdir } from 'node:os'; -import { dirname, join } from 'node:path'; +import { join } from 'node:path'; import { describe, expect, it } from 'vitest'; import { buildUnixAgentInstallCommand, @@ -20,8 +29,8 @@ describe('agentInstallCommand', () => { expect(command).toContain("--url 'http://pulse.example:7655'"); expect(command).toContain('token_dir=$(mktemp -d /tmp/pulse-agent-bootstrap.XXXXXX)'); - expect(command).toContain('token_dir=$(sudo mktemp -d /tmp/pulse-agent-bootstrap.XXXXXX)'); - expect(command).toContain('printf %s \'token-123\' | sudo tee "$token_file" >/dev/null'); + expect(command).toContain('sudo bash -c'); + expect(command).not.toContain('token-123'); expect(command).toContain('--token-file "$token_file"'); expect(command).toContain('--insecure'); }); @@ -36,8 +45,8 @@ describe('agentInstallCommand', () => { "curl -fsSL 'https://pulse.example/base path/agent'\"'\"'s/install.sh' -o \"$install_script\"", ); expect(command).toContain("--url 'https://pulse.example/base path/agent'\"'\"'s'"); - expect(command).toContain("printf %s 'tok'\"'\"'en' > \"$token_file\""); - expect(command).toContain("printf %s 'tok'\"'\"'en' | sudo tee \"$token_file\" >/dev/null"); + expect(command).not.toContain("tok'en"); + expect(command).not.toContain("tok'\"'\"'en"); expect(command).toContain('--token-file "$token_file"'); expect(command).not.toContain("--token 'tok"); }); @@ -49,103 +58,20 @@ describe('agentInstallCommand', () => { }); const preflightIndex = command.indexOf('--preflight-only'); - const sudoIndex = command.indexOf('sudo bash "$install_script"'); + const sudoIndex = command.indexOf('sudo bash -c'); - expect(command).toContain('tmp_dir=$(mktemp -d)'); - expect(command).toContain('trap cleanup EXIT HUP INT TERM'); + expect(command).toContain('bootstrap_dir=$(mktemp -d /tmp/pulse-bootstrap.XXXXXX)'); + expect(command).toContain('trap cleanup EXIT'); expect(command).toContain('bash "$install_script" --url'); expect(command).toContain('--output json'); expect(command).toContain('--non-interactive'); expect(preflightIndex).toBeGreaterThan(-1); expect(sudoIndex).toBeGreaterThan(preflightIndex); expect( - command.indexOf('token_dir=$(sudo mktemp -d /tmp/pulse-agent-bootstrap.XXXXXX)'), + command.indexOf('token_dir=$(mktemp -d /tmp/pulse-agent-bootstrap.XXXXXX)'), ).toBeGreaterThan(preflightIndex); }); - it('executes root and sudo token bootstraps from trusted private directories', () => { - if (process.platform === 'win32') return; - - const fixtureDir = mkdtempSync(join(tmpdir(), 'pulse-install-command-')); - try { - const binDir = join(fixtureDir, 'bin'); - const installer = join(fixtureDir, 'installer.sh'); - const capture = join(fixtureDir, 'captured-token'); - execFileSync('mkdir', ['-p', binDir]); - writeFileSync( - installer, - `#!/usr/bin/env bash -set -e -token_file="" -while [ "$#" -gt 0 ]; do - case "$1" in - --preflight-only) exit 0 ;; - --token-file) token_file="$2"; shift 2 ;; - *) shift ;; - esac -done -[ -n "$token_file" ] -[ "$(stat -c %a "$token_file" 2>/dev/null || stat -f %Lp "$token_file")" = "600" ] -parent_dir=$(dirname "$token_file") -[ "$(stat -c %a "$parent_dir" 2>/dev/null || stat -f %Lp "$parent_dir")" = "700" ] -[ "$(stat -c %u "$token_file" 2>/dev/null || stat -f %u "$token_file")" = "$(stat -c %u "$parent_dir" 2>/dev/null || stat -f %u "$parent_dir")" ] -cat "$token_file" > "$FAKE_CAPTURE" -`, - ); - chmodSync(installer, 0o700); - writeFileSync( - join(binDir, 'curl'), - `#!/bin/sh -output="" -while [ "$#" -gt 0 ]; do - case "$1" in - -o) output="$2"; shift 2 ;; - *) shift ;; - esac -done -cp "$FAKE_INSTALLER" "$output" -`, - ); - writeFileSync( - join(binDir, 'sudo'), - `#!/bin/sh -exec "$@" -`, - ); - writeFileSync( - join(binDir, 'id'), - `#!/bin/sh -if [ "$1" = "-u" ]; then - printf '%s\n' "$FAKE_ID_UID" -else - exec /usr/bin/id "$@" -fi -`, - ); - for (const name of ['curl', 'sudo', 'id']) chmodSync(join(binDir, name), 0o700); - - const command = buildUnixAgentInstallCommand({ - baseUrl: 'https://pulse.example', - token: 'token-123', - }); - for (const fakeUID of ['0', '1000']) { - rmSync(capture, { force: true }); - execFileSync('bash', ['-c', command], { - env: { - ...process.env, - PATH: `${binDir}:${process.env.PATH || ''}`, - FAKE_CAPTURE: capture, - FAKE_ID_UID: fakeUID, - FAKE_INSTALLER: installer, - }, - }); - expect(readFileSync(capture, 'utf8')).toBe('token-123'); - } - } finally { - rmSync(fixtureDir, { recursive: true, force: true }); - } - }); - it('normalizes trailing slashes before building installer transport', () => { expect(normalizeInstallerBaseUrl('https://pulse.example/base///')).toBe( 'https://pulse.example/base', @@ -422,100 +348,392 @@ describe('single-line Unix install commands', () => { it.each([ { baseUrl: 'https://pulse.invalid/a\nb' }, - { baseUrl: 'https://pulse.invalid', token: 'a\rb' }, + { baseUrl: 'https://pulse.invalid', tokenFilePath: '/tmp/a\rb' }, { baseUrl: 'https://pulse.invalid', caCertPath: '/tmp/a\nb' }, { baseUrl: 'https://pulse.invalid', extraArgs: ['--a\n--b'] }, ])('rejects embedded line breaks without silently changing values: %j', (options) => { expect(() => buildUnixAgentInstallCommand(options)).toThrow('must not contain line breaks'); }); - it.each(['sh', 'bash'])( - 'preserves execution and failure ordering under %s after paste', - (shell) => { - const dir = mkdtempSync(join(tmpdir(), 'pulse-single-line-')); - const trace = join(dir, 'trace'); - const tokenPath = join(dir, 'token-path'); - const scriptPath = join(dir, 'script-path'); - const token = "tok'en;$(false)`false`\\value"; - const installer = join(dir, 'fixture-installer'); - const fake = (name: string, body: string) => { - writeFileSync(join(dir, name), `#!/bin/sh\n${body}\n`); - chmodSync(join(dir, name), 0o700); - }; - try { - fake('id', 'echo "$FAKE_UID"'); - fake('sudo', 'echo sudo >> "$TRACE"; exec "$@"'); - fake( - 'curl', - `echo fetch >> "$TRACE" + it.each(['sh', 'bash'])('runs tokenless commands under %s', (shell) => { + const fixture = makeUnixFixture(); + try { + for (const uid of ['0', '1000']) { + const command = buildUnixAgentInstallCommand({ baseUrl: 'https://pulse.invalid' }); + const result = spawnSync(shell, ['-c', command], { + env: fixture.env(uid), + encoding: 'utf8', + }); + expect(result.status, result.stderr).toBe(0); + expect(readFileSync(fixture.trace, 'utf8')).toContain('install'); + assertUnixCleanup(fixture.dir); + } + } finally { + fixture.close(); + } + }); +}); + +const unixPTYRunner = ` +import errno, fcntl, json, os, pty, select, signal, subprocess, sys, termios, time +p = json.load(sys.stdin) +master, slave = pty.openpty() +tty_watch = os.dup(slave) +initial_tty = termios.tcgetattr(tty_watch) +def session(): + os.setsid() + fcntl.ioctl(0, termios.TIOCSCTTY, 0) +history = p.get("history") +env = os.environ.copy() +if history: + env.update(HISTFILE=history, HISTSIZE="100", HISTFILESIZE="100", HISTCONTROL="", PS1="PULSE_TEST$ ") +program = ["bash", "--noprofile", "--norc", "-i"] if history else ["sh", "-c", p["command"]] +child = subprocess.Popen(program, stdin=slave, stdout=slave, stderr=slave, preexec_fn=session, env=env) +if history: os.write(master, (p["command"] + "\\n").encode()) +os.close(slave) +output = b"" +sent = False +deadline = time.monotonic() + 15 +while time.monotonic() < deadline: + ready, _, _ = select.select([master], [], [], .1) + if ready: + try: chunk = os.read(master, 65536) + except OSError as e: + if e.errno == errno.EIO: break + raise + if not chunk: break + output += chunk + if output.endswith(b"(paste at this prompt, not in the command): ") and not sent: + if p.get("read_signal"): + time.sleep(.1) # read has displayed its own prompt and is waiting + with open(env["READER_PID"]) as f: reader = int(f.read()) + os.kill(reader, getattr(signal, "SIG" + p["read_signal"])) + elif p.get("slow"): + for piece in [p["input"][:4], p["input"][4:], "\\n"]: + os.write(master, piece.encode()) + time.sleep(.05) + else: + os.write(master, (p["input"] + "\\n").encode()) + sent = True + if history: os.write(master, b"exit\\n") + if child.poll() is not None and not ready: break +else: + os.killpg(child.pid, signal.SIGKILL) + output += b"\\nPTY fixture deadline expired\\n" +code = child.wait() +final_tty = termios.tcgetattr(tty_watch) +mode_mask = termios.ECHO | termios.ICANON +if (initial_tty[3] & mode_mask) != (final_tty[3] & mode_mask): + output += b"\\nBootstrap did not restore terminal echo/canonical mode\\n" + code = 1 +os.close(tty_watch) +os.close(master) +sys.stdout.buffer.write(output) +sys.exit(code if code >= 0 else 128 - code) +`; + +const makeUnixFixture = () => { + const dir = mkdtempSync(join(tmpdir(), 'pulse-private-unix-')); + chmodSync(dir, 0o700); + const trace = join(dir, 'trace'); + writeFileSync(trace, ''); + const bin = join(dir, 'bin'); + mkdirSync(bin, { mode: 0o700 }); + const fake = (name: string, body: string) => { + writeFileSync(join(bin, name), `#!/bin/sh\n${body}\n`, { mode: 0o700 }); + }; + fake( + 'stty', + 'if [ \"$1\" = -echo ]; then echo \"$PPID\" > \"$READER_PID\"; fi; exec /bin/stty \"$@\"', + ); + fake('id', 'if [ "$1" = -u ]; then echo "$FAKE_UID"; else exec /usr/bin/id "$@"; fi'); + fake( + 'sudo', + 'printf "%s\\n" "$@" >> "$ARGV"; [ "$FAIL_AT" != sudo ] || exit 1; [ "$1" != -v ] || exit 0; exec "$@"', + ); + fake( + 'mktemp', + 'path=$(/usr/bin/mktemp "$@") || exit 1; printf "%s\\n" "$path" >> "$DIRS"; printf "%s\\n" "$path"', + ); + fake( + 'curl', + `printf '%s\\n' "$@" >> "$ARGV" while [ "$#" -gt 0 ]; do if [ "$1" = -o ]; then output="$2"; shift; fi shift done -printf %s "$output" > "$SCRIPT_PATH" -[ "$FAIL_AT" != fetch ] || exit 22 -cp "$INSTALLER" "$output"`, - ); - writeFileSync( - installer, - `#!/bin/sh -set -e -case " $* " in - *' --preflight-only '*) echo preflight >> "$TRACE"; [ "$FAIL_AT" != preflight ]; exit $? ;; -esac +cp "$INSTALLER" "$output" +echo fetch >> "$TRACE" +[ "$FAIL_AT" != fetch ] || exit 22`, + ); + writeFileSync( + join(dir, 'installer'), + `#!/bin/bash +set -eu +printf '%s\\n' "$@" >> "$ARGV" +case " $* " in *' --preflight-only '*) echo preflight >> "$TRACE"; [ "$FAIL_AT" != preflight ]; exit $?;; esac echo install >> "$TRACE" +printf '%s:%s' "\${pulse_token:-}" "\${PULSE_TOKEN:-}" > "$SECRET_ENV" +token_file='' while [ "$#" -gt 0 ]; do if [ "$1" = --token-file ]; then token_file="$2"; shift; fi shift done -printf %s "$token_file" > "$TOKEN_PATH" -[ "$(stat -c %a "$token_file")" = 600 ] -[ "$(stat -c %a "$(dirname "$token_file")")" = 700 ] -[ "$(cat "$token_file")" = "$EXPECTED_TOKEN" ] +if [ -n "$token_file" ]; then + [ "$(stat -c %a "$token_file")" = 600 ] + [ "$(stat -c %a "$(dirname "$token_file")")" = 700 ] + [ "$(stat -c %u "$token_file")" = "$(stat -c %u "$(dirname "$token_file")")" ] + cat "$token_file" > "$CAPTURE" +fi +if [ "$FAIL_AT" = TERM ] || [ "$FAIL_AT" = HUP ]; then kill -s "$FAIL_AT" "$PPID"; fi [ "$FAIL_AT" != install ] `, - ); - for (const uid of ['0', '1000']) { - for (const failAt of ['', 'fetch', 'preflight', 'install']) { - writeFileSync(trace, ''); - rmSync(tokenPath, { force: true }); - const command = buildUnixAgentInstallCommand({ - baseUrl: 'https://pulse.invalid', - token, - }); - const input = document.createElement('input'); - input.value = command; - const run = () => - execFileSync(shell, ['-c', input.value], { - env: { - ...process.env, - PATH: `${dir}:${process.env.PATH}`, - FAKE_UID: uid, - FAIL_AT: failAt, - TRACE: trace, - TOKEN_PATH: tokenPath, - SCRIPT_PATH: scriptPath, - INSTALLER: installer, - EXPECTED_TOKEN: token, - }, - }); - if (failAt) expect(run).toThrow(); - else expect(run).not.toThrow(); - const events = readFileSync(trace, 'utf8').trim().split('\n'); - expect(events[0]).toBe('fetch'); - expect(existsSync(dirname(readFileSync(scriptPath, 'utf8')))).toBe(false); - if (failAt === 'fetch' || failAt === 'preflight') { - expect(events).toEqual(failAt === 'fetch' ? ['fetch'] : ['fetch', 'preflight']); - expect(existsSync(tokenPath)).toBe(false); - } else { - expect(events[1]).toBe('preflight'); - expect(events).toContain('install'); - expect(existsSync(dirname(readFileSync(tokenPath, 'utf8')))).toBe(false); - } - } - } + { mode: 0o700 }, + ); + return { + dir, + trace, + env: (uid = '0', failure = '') => ({ + ...process.env, + PATH: `${bin}:${process.env.PATH}`, + FAKE_UID: uid, + FAIL_AT: failure, + TRACE: trace, + ARGV: join(dir, 'argv'), + DIRS: join(dir, 'dirs'), + INSTALLER: join(dir, 'installer'), + CAPTURE: join(dir, 'capture'), + SECRET_ENV: join(dir, 'secret-env'), + READER_PID: join(dir, 'reader-pid'), + PULSE_TOKEN: '', + }), + close: () => rmSync(dir, { recursive: true, force: true }), + }; +}; + +const assertUnixCleanup = (dir: string) => { + if (existsSync(join(dir, 'dirs'))) + for (const path of readFileSync(join(dir, 'dirs'), 'utf8').trim().split('\n')) + expect(existsSync(path), `temporary directory left behind: ${path}`).toBe(false); +}; +const runPrivateUnix = ( + command: string, + input: string, + env: NodeJS.ProcessEnv, + history?: string, + readSignal?: string, + slow = false, +) => + spawnSync('python3', ['-c', unixPTYRunner], { + input: JSON.stringify({ command, input, history, read_signal: readSignal, slow }), + env, + encoding: 'utf8', + timeout: 20000, + }); + +describe('credential-free Unix lifecycle execution', () => { + it.each(['0', '1000'])( + 'keeps private input out of root/sudo argv, environment, output and history (uid=%s)', + (uid) => { + const fixture = makeUnixFixture(); + const marker = join(fixture.dir, 'must-not-execute'); + const token = `synthetic-' ; $(touch ${marker}) \`false\` \\value`; + try { + const command = buildUnixAgentInstallCommand({ + baseUrl: "https://pulse.invalid/agent's path", + token, + extraArgs: ['--enable-docker', "--agent-id 'agent-42'", "--hostname 'node.example'"], + }); + const history = join(fixture.dir, 'history'); + const result = runPrivateUnix(command, token, fixture.env(uid), history); + expect(result.status, result.stdout + result.stderr).toBe(0); + expect(readFileSync(join(fixture.dir, 'capture'), 'utf8')).toBe(token); + expect(existsSync(marker)).toBe(false); + expect(readFileSync(join(fixture.dir, 'secret-env'), 'utf8')).toBe(':'); + const argv = readFileSync(join(fixture.dir, 'argv'), 'utf8'); + expect(argv).toContain("--url\nhttps://pulse.invalid/agent's path\n"); + expect(argv).toContain('--agent-id\nagent-42\n'); + expect(argv).toContain('--hostname\nnode.example\n'); + const hist = readFileSync(history, 'utf8'); + expect(hist).toContain(command); + for (const text of [command, argv, hist, result.stdout, result.stderr]) + expect(text).not.toContain(token); + assertUnixCleanup(fixture.dir); } finally { - rmSync(dir, { recursive: true, force: true }); + fixture.close(); + } + }, + ); + + it('preserves every character of the maximum-size accepted token', () => { + const fixture = makeUnixFixture(); + try { + const input = 'a'.repeat(4096); + const result = runPrivateUnix( + buildUnixAgentInstallCommand({ baseUrl: 'https://pulse.invalid', token: 'present' }), + input, + fixture.env(), + ); + expect(result.status, result.stdout + result.stderr).toBe(0); + expect(readFileSync(join(fixture.dir, 'capture'), 'utf8')).toBe(input); + assertUnixCleanup(fixture.dir); + } finally { + fixture.close(); + } + }); + + it('drains oversized input without echoing it or returning it to interactive history', () => { + const fixture = makeUnixFixture(); + try { + const input = 'x'.repeat(10000) + '-OVERFLOW_HISTORY_MARKER'; + const history = join(fixture.dir, 'history'); + const result = runPrivateUnix( + buildUnixAgentInstallCommand({ baseUrl: 'https://pulse.invalid', token: 'present' }), + input, + fixture.env(), + history, + ); + expect(result.status, result.stdout + result.stderr).not.toBe(0); + expect(result.stdout).toContain('at most 4096'); + expect(existsSync(join(fixture.dir, 'capture'))).toBe(false); + for (const text of [result.stdout, readFileSync(history, 'utf8')]) { + expect(text).not.toContain('OVERFLOW_HISTORY_MARKER'); + expect(text).not.toContain('x'.repeat(512)); + } + assertUnixCleanup(fixture.dir); + } finally { + fixture.close(); + } + }); + + it.each([ + 'fetch', + 'preflight', + 'sudo', + 'install', + 'HUP', + 'TERM', + 'blank', + 'interrupt', + 'no-terminal', + ])('stops and cleans up on %s', (failure) => { + const fixture = makeUnixFixture(); + try { + const command = buildUnixAgentInstallCommand({ + baseUrl: 'https://pulse.invalid', + token: 'present', + }); + const env = fixture.env(failure === 'sudo' ? '1000' : '0', failure); + const result = + failure === 'no-terminal' + ? spawnSync('sh', ['-c', command], { env, encoding: 'utf8' }) + : runPrivateUnix( + command, + failure === 'blank' ? '' : failure === 'interrupt' ? '\x03' : 'test-input-secret', + env, + ); + expect(result.status, result.stdout + result.stderr).not.toBe(0); + if (!['install', 'HUP', 'TERM'].includes(failure)) + expect(existsSync(join(fixture.dir, 'capture'))).toBe(false); + expect(result.stdout).not.toContain('test-input-secret'); + expect(result.stdout).not.toContain('did not restore'); + assertUnixCleanup(fixture.dir); + } finally { + fixture.close(); + } + }); + + it.each(['HUP', 'TERM', 'INT'])( + 'restores the terminal when %s interrupts private input', + (readSignal) => { + const fixture = makeUnixFixture(); + try { + const command = buildUnixAgentInstallCommand({ + baseUrl: 'https://pulse.invalid', + token: 'present', + }); + const result = runPrivateUnix(command, '', fixture.env(), undefined, readSignal); + expect(result.status, result.stdout + result.stderr).not.toBe(0); + expect(result.stdout).not.toContain('did not restore'); + expect(result.stdout).not.toContain('deadline expired'); + expect(existsSync(join(fixture.dir, 'capture'))).toBe(false); + assertUnixCleanup(fixture.dir); + } finally { + fixture.close(); + } + }, + ); + + it('accepts deliberate slow token input without a timeout or truncation', () => { + const fixture = makeUnixFixture(); + try { + const command = buildUnixAgentInstallCommand({ + baseUrl: 'https://pulse.invalid', + token: 'present', + }); + const input = 'deliberate-slow-input'; + const result = runPrivateUnix(command, input, fixture.env(), undefined, undefined, true); + expect(result.status, result.stdout + result.stderr).toBe(0); + expect(readFileSync(join(fixture.dir, 'capture'), 'utf8')).toBe(input); + expect(result.stdout).not.toContain(input); + assertUnixCleanup(fixture.dir); + } finally { + fixture.close(); + } + }); + + it('supports non-terminal private-file input without deleting the operator file', () => { + const fixture = makeUnixFixture(); + try { + const file = join(fixture.dir, "operator's token"); + writeFileSync(file, 'private-file-token', { mode: 0o600 }); + const command = buildUnixAgentInstallCommand({ + baseUrl: 'https://pulse.invalid', + tokenFilePath: file, + }); + expect(command).not.toContain('read -r'); + expect(command).not.toContain('private-file-token'); + const result = spawnSync('sh', ['-c', command], { env: fixture.env(), encoding: 'utf8' }); + expect(result.status, result.stdout + result.stderr).toBe(0); + expect(readFileSync(join(fixture.dir, 'capture'), 'utf8')).toBe('private-file-token'); + expect(readFileSync(file, 'utf8')).toBe('private-file-token'); + assertUnixCleanup(fixture.dir); + } finally { + fixture.close(); + } + }); + + it.each(['missing', 'symlink', 'fifo', 'directory', 'public-file', 'public-parent'])( + 'rejects unsafe private-file input: %s', + (kind) => { + const fixture = makeUnixFixture(); + try { + const file = join(fixture.dir, 'provided-token'); + const real = join(fixture.dir, 'actual-token'); + writeFileSync(real, 'private-file-token', { mode: 0o600 }); + if (kind === 'symlink') symlinkSync(real, file); + if (kind === 'fifo') execFileSync('mkfifo', [file]); + if (kind === 'directory') mkdirSync(file); + if (kind === 'public-file' || kind === 'public-parent') { + writeFileSync(file, 'private-file-token', { + mode: kind === 'public-file' ? 0o644 : 0o600, + }); + if (kind === 'public-parent') chmodSync(fixture.dir, 0o755); + } + const command = buildUnixAgentInstallCommand({ + baseUrl: 'https://pulse.invalid', + tokenFilePath: file, + }); + const result = spawnSync('sh', ['-c', command], { + env: fixture.env(), + encoding: 'utf8', + timeout: 3000, + }); + expect(result.status, result.stdout + result.stderr).not.toBe(0); + expect(existsSync(join(fixture.dir, 'capture'))).toBe(false); + assertUnixCleanup(fixture.dir); + } finally { + fixture.close(); } }, ); diff --git a/frontend-modern/src/utils/agentInstallCommand.ts b/frontend-modern/src/utils/agentInstallCommand.ts index 1a38ceb454..e7a181c79e 100644 --- a/frontend-modern/src/utils/agentInstallCommand.ts +++ b/frontend-modern/src/utils/agentInstallCommand.ts @@ -122,6 +122,8 @@ type BuildUnixAgentInstallCommandOptions = { insecure?: boolean; caCertPath?: string | null; extraArgs?: string[]; + // Existing private file for non-terminal hosts; never contains credential bytes. + tokenFilePath?: string | null; }; type BuildWindowsAgentInstallCommandOptions = { @@ -138,20 +140,25 @@ export const buildUnixAgentInstallCommand = ({ insecure = false, caCertPath, extraArgs = [], + tokenFilePath, }: BuildUnixAgentInstallCommandOptions) => { const normalizedBaseUrl = normalizeInstallerBaseUrl(baseUrl); if (!normalizedBaseUrl.trim()) { throw new Error('Pulse install endpoint URL is required.'); } const normalizedCaCertPath = (caCertPath || '').trim(); - const normalizedToken = (token || '').trim(); + const needsToken = Boolean((token || '').trim() || tokenFilePath?.trim()); + const normalizedTokenFilePath = (tokenFilePath || '').trim(); + if (normalizedTokenFilePath && !normalizedTokenFilePath.startsWith('/')) { + throw new Error('Pulse token file path must be absolute.'); + } const normalizedExtraArgs = extraArgs.map((arg) => arg.trim()).filter((arg) => arg.length > 0); const installRequiresInsecure = insecure || normalizedBaseUrl.startsWith('http://'); const curlFlags = insecure ? '-kfsSL' : '-fsSL'; // Build shell grammar explicitly: command fields such as pfSense's remove // pasted newlines. Never flatten a multiline script (or quoted user data). if ( - [normalizedBaseUrl, normalizedToken, normalizedCaCertPath, ...normalizedExtraArgs].some( + [normalizedBaseUrl, normalizedTokenFilePath, normalizedCaCertPath, ...normalizedExtraArgs].some( (value) => /[\r\n]/.test(value), ) ) { @@ -167,57 +174,57 @@ export const buildUnixAgentInstallCommand = ({ ].join(' '); const installArgs = [ `--url ${shellQuoteArg(normalizedBaseUrl)}`, - ...(normalizedToken ? ['--token-file "$token_file"'] : []), + ...(needsToken ? ['--token-file "$token_file"'] : []), ...normalizedExtraArgs, '--non-interactive', ].join(' '); - const rootTokenSetup = normalizedToken - ? [ - 'token_dir=$(mktemp -d /tmp/pulse-agent-bootstrap.XXXXXX);', - 'token_file="$token_dir/token";', - 'umask 077;', - `printf %s ${shellQuoteArg(normalizedToken)} > "$token_file";`, - ] - : []; - const sudoTokenSetup = normalizedToken - ? [ - 'token_dir=$(sudo mktemp -d /tmp/pulse-agent-bootstrap.XXXXXX);', - 'token_file="$token_dir/token";', - `printf %s ${shellQuoteArg(normalizedToken)} | sudo tee "$token_file" >/dev/null;`, - 'sudo chmod 0600 "$token_file";', - ] - : []; + + // Keep this reader aligned with configapi/privateBootstrapCommand. Bash owns + // the silent bounded read in the privileged child, not the caller's shell or + // sudo argv. A canonical line read silently truncates long pasted tokens. + const privileged = ['set +xv;', 'set -eu;', 'umask 077;']; + if (normalizedTokenFilePath) { + privileged.push( + `token_file=${shellQuoteArg(normalizedTokenFilePath)};`, + 'token_parent=${token_file%/*};', + 'if [ ! -f "$token_file" ] || [ -L "$token_file" ] || [ ! -O "$token_file" ] || [ ! -d "$token_parent" ] || [ -L "$token_parent" ] || [ ! -O "$token_parent" ] || [ "$(stat -c %a "$token_file" 2>/dev/null || stat -f %Lp "$token_file")" != 600 ] || [ "$(stat -c %a "$token_parent" 2>/dev/null || stat -f %Lp "$token_parent")" != 700 ]; then echo "Use a root-owned 0600 regular token file in a root-owned 0700 directory. No installation was attempted." >&2; exit 1; fi;', + ); + } else if (needsToken) { + privileged.push( + 'token_dir=$(mktemp -d /tmp/pulse-agent-bootstrap.XXXXXX);', + 'token_file="$token_dir/token";', + 'unset pulse_token pulse_discard tty_state;', + 'cleanup() { unset pulse_token pulse_discard; if [ -n "${tty_state:-}" ]; then stty "$tty_state" /dev/null || :; fi; rm -f -- "$token_file"; rmdir -- "$token_dir"; };', + "trap cleanup EXIT; trap 'exit 129' HUP; trap 'exit 130' INT; trap 'exit 143' TERM;", + 'if ! tty_state=$(stty -g &2; exit 1; fi;', + 'stty -echo &2; exit 1; fi;`, + 'if [ "${#pulse_token}" -gt 4096 ]; then while IFS= read -r -s -n 4097 pulse_discard /dev/tty;", + 'if [ -z "$pulse_token" ] || [ "${#pulse_token}" -gt 4096 ]; then echo "A non-empty token of at most 4096 characters is required." >&2; exit 1; fi;', + 'printf %s "$pulse_token" > "$token_file"; unset pulse_token;', + ); + } + privileged.push(`bash "$1" ${installArgs}${caCertArg}${insecureArg};`); + const child = `bash -c ${shellQuoteArg(privileged.join(' '))} pulse-bootstrap "$install_script";`; return [ '(', - 'set -e;', - 'tmp_dir=$(mktemp -d);', - 'token_dir="";', - 'install_script="$tmp_dir/install.sh";', - 'cleanup() {', - 'rm -rf -- "$tmp_dir";', - 'if [ -n "${token_dir:-}" ]; then', - 'if [ "$(id -u)" -eq 0 ]; then', - 'rm -rf -- "$token_dir";', - 'elif command -v sudo >/dev/null 2>&1; then', - 'sudo rm -rf -- "$token_dir" >/dev/null 2>&1 || true;', - 'fi;', - 'fi;', - '};', - 'trap cleanup EXIT HUP INT TERM;', + 'set +xv;', + 'set -eu;', + 'umask 077;', + 'if [ "$(id -u)" -eq 0 ]; then :; elif command -v sudo >/dev/null 2>&1; then sudo -v; else echo "Root privileges required. Run as root (su -) and retry." >&2; exit 1; fi;', + 'bootstrap_dir=$(mktemp -d /tmp/pulse-bootstrap.XXXXXX);', + 'install_script="$bootstrap_dir/install.sh";', + 'cleanup() { rm -f -- "$install_script"; rmdir -- "$bootstrap_dir"; };', + "trap cleanup EXIT; trap 'exit 129' HUP; trap 'exit 130' INT; trap 'exit 143' TERM;", `curl ${curlFlags}${caCertArg} ${shellQuoteArg(`${normalizedBaseUrl}/install.sh`)} -o "$install_script";`, - 'chmod +x "$install_script";', - `bash "$install_script" ${preflightArgs}${caCertArg}${insecureArg};`, - 'if [ "$(id -u)" -eq 0 ]; then', - ...rootTokenSetup, - `bash "$install_script" ${installArgs}${caCertArg}${insecureArg};`, - 'elif command -v sudo >/dev/null 2>&1; then', - ...sudoTokenSetup, - `sudo bash "$install_script" ${installArgs}${caCertArg}${insecureArg};`, - 'else', - 'echo "Root privileges required. Run as root (su -) and retry." >&2;', - 'exit 1;', - 'fi;', + // Detachment must not require a downloadable new agent binary. + ...(!normalizedExtraArgs.includes('--uninstall') + ? [`bash "$install_script" ${preflightArgs}${caCertArg}${insecureArg};`] + : []), + `if [ "$(id -u)" -eq 0 ]; then ${child} else sudo ${child} fi;`, ')', ].join(' '); }; From b66e774ff84c0e3111e2a41bbcdd6b720fb75e57 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Thu, 1 Oct 2026 14:13:21 +0100 Subject: [PATCH 2/3] Verify the quoted lifecycle child without weakening identity assertions The focused executable probes passed, but four legacy wording/outer-quoting assertions failed. Decode the exact POSIX-quoted Bash child before asserting the existing full identity literals, and pin the truthful token-entry ordering text. Runtime source and its parent-bound browser receipt are unchanged. Change-source: pulse-maintainer --- .../__tests__/InfrastructureOperationsModel.test.tsx | 4 +--- .../__tests__/infrastructureAgentDoctorModel.test.ts | 5 +++-- ...nfrastructureOperationsState.branchcov0722pm.test.tsx | 9 +++++---- .../src/utils/__tests__/unixBootstrapTestSource.ts | 8 ++++++++ 4 files changed, 17 insertions(+), 9 deletions(-) create mode 100644 frontend-modern/src/utils/__tests__/unixBootstrapTestSource.ts diff --git a/frontend-modern/src/components/Settings/__tests__/InfrastructureOperationsModel.test.tsx b/frontend-modern/src/components/Settings/__tests__/InfrastructureOperationsModel.test.tsx index b6c1818533..c9fd83a24f 100644 --- a/frontend-modern/src/components/Settings/__tests__/InfrastructureOperationsModel.test.tsx +++ b/frontend-modern/src/components/Settings/__tests__/InfrastructureOperationsModel.test.tsx @@ -277,9 +277,7 @@ describe('infrastructure operations model', () => { 'Use Availability checks for ping-only or agentless device monitoring.', ); expect(infrastructureInstallerSectionSource).toContain('checks this Pulse URL and'); - expect(infrastructureInstallerSectionSource).toContain( - 'before asking for administrator privileges', - ); + expect(infrastructureInstallerSectionSource).toContain('before reading a Unix token'); expect(infrastructureInstallerSectionSource).toContain( 'For Proxmox, start with a dedicated read-only or narrowly scoped API token', ); diff --git a/frontend-modern/src/components/Settings/__tests__/infrastructureAgentDoctorModel.test.ts b/frontend-modern/src/components/Settings/__tests__/infrastructureAgentDoctorModel.test.ts index 702c93845b..72260c3b27 100644 --- a/frontend-modern/src/components/Settings/__tests__/infrastructureAgentDoctorModel.test.ts +++ b/frontend-modern/src/components/Settings/__tests__/infrastructureAgentDoctorModel.test.ts @@ -1,3 +1,4 @@ +import { unixBootstrapTestSource } from '@/utils/__tests__/unixBootstrapTestSource'; import { execFileSync } from 'node:child_process'; import { useInfrastructureOperationsState } from '../useInfrastructureOperationsState'; import { describe, expect, it, vi } from 'vitest'; @@ -392,8 +393,8 @@ describe('Agent Doctor model', () => { expect(install).toContain('--enable-privileged-helper'); expect(install).toContain('--enable-action-runner'); expect(install).toContain('--action-token-file'); - expect(install).toContain("--agent-id 'host-1'"); - expect(install).toContain("--hostname 'host-1.local'"); + expect(unixBootstrapTestSource(install)).toContain("--agent-id 'host-1'"); + expect(unixBootstrapTestSource(install)).toContain("--hostname 'host-1.local'"); expect(install).toContain("--cacert '/etc/pulse/ca.pem'"); expect(install).not.toContain(secret); expect(install).not.toContain('--action-token '); diff --git a/frontend-modern/src/components/Settings/__tests__/useInfrastructureOperationsState.branchcov0722pm.test.tsx b/frontend-modern/src/components/Settings/__tests__/useInfrastructureOperationsState.branchcov0722pm.test.tsx index 27250bcb9a..bde07dcf74 100644 --- a/frontend-modern/src/components/Settings/__tests__/useInfrastructureOperationsState.branchcov0722pm.test.tsx +++ b/frontend-modern/src/components/Settings/__tests__/useInfrastructureOperationsState.branchcov0722pm.test.tsx @@ -1,3 +1,4 @@ +import { unixBootstrapTestSource } from '@/utils/__tests__/unixBootstrapTestSource'; import { createRoot } from 'solid-js'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { Connection } from '@/api/connections'; @@ -308,8 +309,8 @@ describe('useInfrastructureOperationsState command-building closures', () => { }; const cmd = state.getUninstallCommand(row); - expect(cmd).toContain("--agent-id 'act-9'"); - expect(cmd).toContain("--hostname 'host-9'"); + expect(unixBootstrapTestSource(cmd)).toContain("--agent-id 'act-9'"); + expect(unixBootstrapTestSource(cmd)).toContain("--hostname 'host-9'"); expect(cmd).not.toContain('agent-9'); dispose(); }); @@ -321,8 +322,8 @@ describe('useInfrastructureOperationsState command-building closures', () => { const row: AgentUninstallIdentity = { agentId: 'agent-7', hostname: 'host-7' }; const cmd = state.getUninstallCommand(row); - expect(cmd).toContain("--agent-id 'agent-7'"); - expect(cmd).toContain("--hostname 'host-7'"); + expect(unixBootstrapTestSource(cmd)).toContain("--agent-id 'agent-7'"); + expect(unixBootstrapTestSource(cmd)).toContain("--hostname 'host-7'"); dispose(); }); diff --git a/frontend-modern/src/utils/__tests__/unixBootstrapTestSource.ts b/frontend-modern/src/utils/__tests__/unixBootstrapTestSource.ts new file mode 100644 index 0000000000..58be170a82 --- /dev/null +++ b/frontend-modern/src/utils/__tests__/unixBootstrapTestSource.ts @@ -0,0 +1,8 @@ +// Decode exactly the single-quoted Bash -c argument, including POSIX apostrophe +// escapes. Consumers can assert its actual source without depending on the +// extra outer quoting layer. Executable PTY tests separately pin real argv. +export const unixBootstrapTestSource = (command: string): string => { + const child = /bash -c ('(?:[^']|'"'"')*') pulse-bootstrap "\$install_script";/.exec(command); + if (!child) throw new Error('Expected a single-quoted private Unix bootstrap child'); + return child[1].slice(1, -1).replaceAll(`'"'"'`, "'"); +}; From bd973b66c93f2bd5268c54703601a50b8679255d Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Thu, 1 Oct 2026 14:44:36 +0100 Subject: [PATCH 3/3] Keep copy contracts aligned with private entry and Relay retirement The complete frontend run exposed one remaining Unix instruction assertion and two inherited assertions requiring the retired Relay sales tier. Enforce separate Unix credential entry and the existing approved retirement, subscriber entitlement, app-only and paired-access guidance instead. Canonical and shipped copy must still match. No runtime, docs, native mobile behaviour or browser source changes. Change-source: pulse-maintainer --- .../InfrastructureOperationsModel.test.tsx | 6 +++++- .../src/stores/__tests__/systemSettings.test.ts | 14 +++++++++----- .../__tests__/quickstartCopyContract.test.ts | 17 ++++++++++++----- 3 files changed, 26 insertions(+), 11 deletions(-) diff --git a/frontend-modern/src/components/Settings/__tests__/InfrastructureOperationsModel.test.tsx b/frontend-modern/src/components/Settings/__tests__/InfrastructureOperationsModel.test.tsx index c9fd83a24f..2d4a920c7d 100644 --- a/frontend-modern/src/components/Settings/__tests__/InfrastructureOperationsModel.test.tsx +++ b/frontend-modern/src/components/Settings/__tests__/InfrastructureOperationsModel.test.tsx @@ -285,7 +285,11 @@ describe('infrastructure operations model', () => { expect(infrastructureInstallerSectionSource).toContain('augmentation.'); expect(infrastructureInstallerSectionSource).toContain('Installation commands'); expect(infrastructureInstallerSectionSource).toContain( - 'Generate an install token first. Pulse will then build copy-ready commands', + 'Generate an install token first. Then copy the command for the target host and', + ); + expect(infrastructureInstallerSectionSource).toContain('follow its credential-entry instructions.'); + expect(infrastructureInstallerSectionSource).toContain( + 'commands keep it separate: copy the command first, then use Show token only.', ); expect(infrastructureInstallerSectionSource).toContain( 'Install the transitional combined runtime that can accept server command requests', diff --git a/frontend-modern/src/stores/__tests__/systemSettings.test.ts b/frontend-modern/src/stores/__tests__/systemSettings.test.ts index 92dd021eb8..69dc328dae 100644 --- a/frontend-modern/src/stores/__tests__/systemSettings.test.ts +++ b/frontend-modern/src/stores/__tests__/systemSettings.test.ts @@ -145,7 +145,7 @@ describe('systemSettings store', () => { expect(configurationDoc).toContain('PULSE_TELEMETRY'); }); - it('keeps Relay security guidance aligned with the Relay tier boundary', () => { + it('keeps existing mobile pairing security without offering retired Relay', () => { const securityDoc = readFileSync(path.join(repoRoot, 'SECURITY.md'), 'utf8'); const publicSecurityDoc = readFileSync( path.join(frontendRoot, 'public', 'docs', 'SECURITY.md'), @@ -153,13 +153,17 @@ describe('systemSettings store', () => { ); for (const copy of [securityDoc, publicSecurityDoc]) { - expect(copy).toContain('Relay Security (Relay and Above)'); - expect(copy).toContain( - 'Relay functionality requires a Relay, Pro, legacy Pro+, or Cloud license', - ); + expect(copy).toContain('### Existing Mobile Pairings (Retirement)'); + expect(copy).toContain('Pulse Mobile and Relay retire on **31 March 2027**'); + expect(copy).toContain('Relay is no longer sold'); + expect(copy).toContain('receive Pro features at their current price'); + expect(copy).toContain('Paired-app access remains license-gated until retirement'); + expect(copy).toMatch(/Relay connects the app, not the\s+web UI/); + expect(copy).not.toContain('Relay Security (Relay and Above)'); expect(copy).not.toContain('Relay Security (Pro)'); expect(copy).not.toContain('Relay functionality requires a Pro or Cloud license'); } + expect(publicSecurityDoc).toBe(securityDoc); }); it('documents self-hosted AI provider transport and resource-policy redaction in the privacy doc', () => { diff --git a/frontend-modern/src/utils/__tests__/quickstartCopyContract.test.ts b/frontend-modern/src/utils/__tests__/quickstartCopyContract.test.ts index 2f392c81d7..e55a26d4ad 100644 --- a/frontend-modern/src/utils/__tests__/quickstartCopyContract.test.ts +++ b/frontend-modern/src/utils/__tests__/quickstartCopyContract.test.ts @@ -42,7 +42,7 @@ describe('quickstart copy contract', () => { } }); - it('keeps Relay public docs aligned with the Relay tier instead of Pro-only copy', () => { + it('keeps retired Relay guidance about existing pairings, not a current offer', () => { const security = readRepoFile('SECURITY.md'); const publicSecurity = readRepoFile('frontend-modern/public/docs/SECURITY.md'); const screenshots = readRepoFile('docs/SCREENSHOTS.md'); @@ -53,10 +53,17 @@ describe('quickstart copy contract', () => { expect(copy).not.toContain('relay protocol (Pro feature)'); } - expect(security).toContain('Relay Security (Relay and Above)'); - expect(publicSecurity).toContain('Relay Security (Relay and Above)'); - expect(security).toContain('Relay, Pro, legacy Pro+, or Cloud license'); - expect(publicSecurity).toContain('Relay, Pro, legacy Pro+, or Cloud license'); + for (const copy of [security, publicSecurity]) { + expect(copy).toContain('### Existing Mobile Pairings (Retirement)'); + expect(copy).toContain('Pulse Mobile and Relay retire on **31 March 2027**'); + expect(copy).toContain('Relay is no longer sold'); + expect(copy).toContain('receive Pro features at their current price'); + expect(copy).toContain('Paired-app access remains license-gated until retirement'); + expect(copy).toMatch(/Relay connects the app, not the\s+web UI/); + expect(copy).not.toContain('Relay Security (Relay and Above)'); + expect(copy).not.toContain('Relay, Pro, legacy Pro+, or Cloud license'); + } + expect(publicSecurity).toBe(security); }); it('keeps public AI docs aligned with model-owned Patrol and Assistant reasoning', () => {