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..2d4a920c7d 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', ); @@ -287,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', @@ -472,13 +474,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 +485,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..72260c3b27 100644 --- a/frontend-modern/src/components/Settings/__tests__/infrastructureAgentDoctorModel.test.ts +++ b/frontend-modern/src/components/Settings/__tests__/infrastructureAgentDoctorModel.test.ts @@ -1,4 +1,7 @@ -import { describe, expect, it } from 'vitest'; +import { unixBootstrapTestSource } from '@/utils/__tests__/unixBootstrapTestSource'; +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'; @@ -390,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 '); @@ -1097,3 +1100,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..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'; @@ -159,51 +160,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 +218,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 +236,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(); }); }); @@ -310,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(); }); @@ -323,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(); }); @@ -633,8 +632,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 +650,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/stores/__tests__/systemSettings.test.ts b/frontend-modern/src/stores/__tests__/systemSettings.test.ts index 6c5c131e4b..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 existing mobile pairing security guidance aligned with retirement', () => { + 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,14 +153,17 @@ describe('systemSettings store', () => { ); for (const copy of [securityDoc, publicSecurityDoc]) { - expect(copy).toContain('Existing Mobile Pairings (Retirement)'); - expect(copy).toContain('31 March 2027'); + 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('Relay connects the app, not the'); + 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__/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/__tests__/quickstartCopyContract.test.ts b/frontend-modern/src/utils/__tests__/quickstartCopyContract.test.ts index eb555d090a..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 existing mobile security guidance aligned with retirement', () => { + 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'); @@ -54,13 +54,16 @@ describe('quickstart copy contract', () => { } for (const copy of [security, publicSecurity]) { - expect(copy).toContain('Existing Mobile Pairings (Retirement)'); - expect(copy).toContain('31 March 2027'); + 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('Relay connects the app, not the'); + 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', () => { 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(`'"'"'`, "'"); +}; 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(' '); };