diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index 50e9dc861d..9c83d2411f 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -9543,7 +9543,7 @@ cross-compiled with `GOOS=windows go build ./internal/hostagent/`. 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; +owns the 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. @@ -9551,8 +9551,22 @@ 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 +Root/sudo dispatch emits the quoted private reader only once via an isolated +POSIX positional-argument prefix. Normal quoted URL/profile/identity variants +remain below the common 4096-byte terminal line boundary. Actual interactive +PTY controls wait for the caller prompt before pasting and before exiting, so +startup truncation or a queued exit is not mistaken for a reader failure. + +The frontend reader selects standalone `stty`, or `busybox stty` when standalone +`stty` is absent, and uses the same selected utility to restore terminal modes. +Missing terminal, missing/unsupported utility, unreadable settings, echo-disable +failure and restoration failure stop before installation with distinct errors; +no echoed, inline-token or broader-privilege fallback is permitted. A mode-save +failure never requests input. Interrupted/oversized input remains private. +The backend bootstrap mirror remains separately owned by Core until composed. + +For Linux/NAS and 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 diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index dfab6e4712..627ca0c045 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -12034,7 +12034,7 @@ requirements. No runtime paths or required contract updates are exempted. 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 +Bash child uses the bounded private 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 @@ -12044,10 +12044,23 @@ 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 ancestor trust. The Linux/NAS and FreeBSD cards explain 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. +Root/sudo dispatch emits the quoted private reader only once via an isolated +POSIX positional-argument prefix. Normal quoted URL/profile/identity variants +remain below the common 4096-byte terminal line boundary. Actual interactive +PTY controls wait for the caller prompt before pasting and before exiting, so +startup truncation or a queued exit is not mistaken for a reader failure. + +The frontend terminal reader can select `busybox stty` only when standalone +`stty` is absent. It distinguishes missing TTY, absent/unsupported tool and +mode-save/echo-disable/restoration failures, refuses unsafe entry/installation, +and restores using the selected tool on normal and signal exits. A host without +a usable tool keeps the existing private-file option; credential issuance and +transport do not change. Core owns the separate backend-generated reader mirror. + 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 diff --git a/frontend-modern/browser-tests/build-unix-private-install.mjs b/frontend-modern/browser-tests/build-unix-private-install.mjs new file mode 100644 index 0000000000..62a3920a3e --- /dev/null +++ b/frontend-modern/browser-tests/build-unix-private-install.mjs @@ -0,0 +1,100 @@ +// Compile in the source-proof VM, not the bounded browser process tree. +// This is a consuming preview, not the production build or a native agent. +import { build } from 'vite'; +import solid from 'vite-plugin-solid'; +import tailwind from '@tailwindcss/vite'; +import { stop } from 'esbuild'; +import fs from 'node:fs'; +import path from 'node:path'; +import crypto from 'node:crypto'; +import { execFileSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import identity from './publication-fixture-source.cjs'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const output = path.resolve(root, '../tmp/unix-private-install/static'); +const hash = (bytes) => crypto.createHash('sha256').update(bytes).digest('hex'); +const binding = { + ...identity.publicationSourceIdentity(process.argv.slice(2), () => + execFileSync('git', ['rev-parse', 'HEAD'], { cwd: root, encoding: 'utf8' }), + ), + sources: {}, + artifacts: {}, +}; +try { + await build({ + root, + base: '/', + configFile: false, + publicDir: false, + plugins: [ + { + name: 'private-input-consumer-binding', + generateBundle() { + for (const id of this.getModuleIds()) { + const file = id.split('?')[0]; + if ( + file.startsWith(root + '/') && + !file.includes('/node_modules/') && + fs.existsSync(file) && + fs.statSync(file).isFile() + ) + binding.sources[path.relative(root, file)] = hash(fs.readFileSync(file)); + } + for (const file of [ + 'src/utils/agentInstallCommand.ts', + 'src/components/Settings/infrastructureOperationsModel.tsx', + 'src/components/Settings/InfrastructureInstallerSection.tsx', + 'src/components/Settings/InfrastructureAgentDoctorPage.tsx', + 'src/components/Settings/useInfrastructureOperationsState.tsx', + 'src/components/Settings/useInfrastructureInstallState.tsx', + 'browser-tests/unix-private-install.tsx', + ]) + if (!binding.sources[file]) throw new Error(`Consumer not compiled: ${file}`); + }, + }, + tailwind(), + solid(), + ], + resolve: { alias: { '@': path.join(root, 'src') } }, + // This fixture targets the installed current browser, not a legacy-engine + // qualification. Match the other consuming preview's non-lowering target. + build: { + target: 'esnext', + outDir: output, + emptyOutDir: true, + rollupOptions: { input: path.join(root, 'browser-tests/unix-private-install.html') }, + }, + }); + const bind = (directory) => { + for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { + const file = path.join(directory, entry.name); + if (entry.isDirectory()) bind(file); + else if (/\.(css|js|jsx|ts|tsx|html|mjs|cjs)$/.test(file)) + binding.sources[path.relative(root, file)] = hash(fs.readFileSync(file)); + } + }; + // Tailwind scans source beyond the consumer graph. Bind those inputs too. + bind(path.join(root, 'src')); + bind(path.join(root, 'browser-tests')); + for (const file of ['package.json', 'package-lock.json', 'tsconfig.json', 'index.html']) + binding.sources[file] = hash(fs.readFileSync(path.join(root, file))); + const artifacts = (directory) => { + for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { + const file = path.join(directory, entry.name); + if (entry.isDirectory()) artifacts(file); + else binding.artifacts[path.relative(output, file)] = hash(fs.readFileSync(file)); + } + }; + artifacts(output); + fs.writeFileSync(path.join(output, 'binding.json'), JSON.stringify(binding, null, 2) + '\n'); + console.log( + JSON.stringify({ + source: binding.source_sha, + source_inputs: Object.keys(binding.sources).length, + artifacts: Object.keys(binding.artifacts).length, + }), + ); +} finally { + stop(); +} diff --git a/frontend-modern/browser-tests/unix-private-install.cjs b/frontend-modern/browser-tests/unix-private-install.cjs index 96e520158d..62f3566191 100644 --- a/frontend-modern/browser-tests/unix-private-install.cjs +++ b/frontend-modern/browser-tests/unix-private-install.cjs @@ -2,31 +2,56 @@ const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); +const http = require('node:http'); +const crypto = require('node:crypto'); const { chromium, webkit } = require('playwright'); +const hash = (bytes) => crypto.createHash('sha256').update(bytes).digest('hex'); (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 artifacts = '/workspace/tmp/unix-private-install'; + const preview = path.join(artifacts, 'static'); + const bindingBytes = fs.readFileSync(path.join(preview, 'binding.json')); + const binding = JSON.parse(bindingBytes); + for (const [file, expected] of Object.entries(binding.sources)) + assert.equal(hash(fs.readFileSync(path.join(root, file))), expected, file); + for (const [file, expected] of Object.entries(binding.artifacts)) + assert.equal(hash(fs.readFileSync(path.join(preview, file))), expected, file); + const served = []; + const server = http.createServer((req, res) => { + const file = new URL(req.url, 'http://fixture.invalid').pathname.slice(1); + if (!['GET', 'HEAD'].includes(req.method) || !Object.hasOwn(binding.artifacts, file)) { + res.writeHead(404); + res.end(); + return; + } + const bytes = fs.readFileSync(path.join(preview, file)); + assert.equal(hash(bytes), binding.artifacts[file]); + served.push({ file, sha256: hash(bytes) }); + res.writeHead(200, { + 'Content-Type': file.endsWith('.js') + ? 'text/javascript' + : file.endsWith('.css') + ? 'text/css' + : 'text/html', + 'Cache-Control': 'no-store', + }); + res.end(bytes); }); + const captures = [], + consumerCommands = [], + requests = []; + let cleanup = { browser_closed: false, server_closed: false }; const results = []; let browser; const secret = 'browser-synthetic-install-token'; try { - await server.listen(); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const origin = `http://127.0.0.1:${server.address().port}`; + assert.equal(require('playwright/package.json').version, '1.56.1'); for (const [engine, width, tone] of [ ['chromium', 1280, 'light'], + ['chromium', 1280, 'dark'], + ['webkit', 390, 'light'], ['webkit', 390, 'dark'], ]) { browser = await { chromium, webkit }[engine].launch( @@ -39,6 +64,36 @@ const { chromium, webkit } = require('playwright'); ...(engine === 'webkit' ? { isMobile: true, hasTouch: true } : {}), }); const errors = []; + const capture = async (name, locator) => { + if (locator) + await locator.evaluate((el) => window.scrollBy(0, el.getBoundingClientRect().top - 48)); + await page.evaluate( + () => + new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))), + ); + assert.equal( + await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), + true, + name, + ); + const file = `${engine}-${tone}-${name}.png`; + await page.screenshot({ path: path.join(artifacts, file) }); + captures.push({ + file, + sha256: hash(fs.readFileSync(path.join(artifacts, file))), + engine, + tone, + width, + height: 900, + }); + }; + const activate = async (locator) => { + if (engine === 'webkit') await locator.tap(); + else { + await locator.focus(); + await page.keyboard.press('Enter'); + } + }; page.on('pageerror', (e) => errors.push(e.message)); page.on('console', (m) => { if (m.type() === 'error') { @@ -55,6 +110,9 @@ const { chromium, webkit } = require('playwright'); value: { writeText: async (text) => window.__copies.push(text) }, }); }); + page.on('request', (req) => + requests.push({ path: new URL(req.url()).pathname, method: req.method() }), + ); await page.route('**/api/**', async (route) => { const url = new URL(route.request().url()); if (!url.pathname.startsWith('/api/')) return route.continue(); @@ -88,7 +146,7 @@ const { chromium, webkit } = require('playwright'); 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.goto(`${origin}/browser-tests/unix-private-install.html`); await page.waitForTimeout(1000); console.log('PAGE_TITLE', await page.title()); console.log( @@ -115,7 +173,7 @@ const { chromium, webkit } = require('playwright'); 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 capture('token-dialog'); await page.keyboard.press('Escape'); await dialog.waitFor({ state: 'hidden' }); // The real component's platform cards and copy handlers. @@ -126,10 +184,14 @@ const { chromium, webkit } = require('playwright'); assert.ok(!command.includes(secret)); assert.ok(command.includes('--token-file "$token_file"')); assert.ok(!/[\r\n]/.test(command)); + assert.ok(command.includes('tty_control=(busybox stty)')); + assert.ok(command.includes('Cannot disable terminal echo')); + assert.ok(command.includes('private token-file command')); } const guiCode = page.locator('code').filter({ hasText: 'token_parent=${token_file%/*}' }); - assert.equal(await guiCode.count(), 1); - const guiCommand = await guiCode.innerText(); + assert.equal(await guiCode.count(), 2); + const guiCommand = await guiCode.first().innerText(); + assert.equal(await guiCode.last().innerText(), guiCommand); assert.ok(guiCommand.includes('/root/.config/pulse-agent/bootstrap-token')); assert.ok(!guiCommand.includes('read -r')); assert.ok(!guiCommand.includes(secret)); @@ -138,22 +200,46 @@ const { chromium, webkit } = require('playwright'); 'Never put the token in a GUI command field', ), ); - await page.getByRole('button', { name: 'Copy Install command', exact: true }).click(); + await activate(page.getByRole('button', { name: 'Copy Install command', exact: true })); assert.equal(await page.evaluate(() => window.__copies.at(-1)), unixCommands[0]); - await page + const fileCopy = page .getByRole('button', { name: 'Copy Install from a private token file (no terminal) command', exact: true, }) - .click(); + .first(); + await activate(fileCopy); + const linuxCard = page + .getByRole('heading', { name: 'Install on Linux', exact: true }) + .locator('..') + .locator('..'); + assert.match(await linuxCard.innerText(), /BusyBox/); + assert.match(await linuxCard.innerText(), /root-owned/); + await capture('linux-private-file', fileCopy); assert.equal(await page.evaluate(() => window.__copies.at(-1)), guiCommand); + consumerCommands.push({ engine, tone, kind: 'linux-file', command: guiCommand }); + await activate( + page.getByRole('button', { name: 'Show details for doctor-fixture', exact: true }), + ); 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)); + const doctorCommand = await page.evaluate(() => window.__copies.at(-1)); + assert.ok(!doctorCommand.includes(secret)); + assert.ok(doctorCommand.includes('tty_control=(busybox stty)')); + assert.ok(doctorCommand.includes('--update --enable-docker')); + assert.ok(doctorCommand.includes('doctor-fixture')); + consumerCommands.push({ engine, tone, kind: 'doctor-repair', command: doctorCommand }); + await capture( + 'doctor-repair', + page.getByRole('button', { + name: 'Copy authentication repair command for doctor-fixture', + exact: true, + }), + ); await page.getByRole('button', { name: 'Show token only', exact: true }).click(); await dialog.waitFor(); @@ -203,12 +289,13 @@ const { chromium, webkit } = require('playwright'); 'Never insert the token into the command', ), ); - await page.screenshot({ - path: path.join(artifacts, `${engine}-commands.png`), - fullPage: true, - }); + await capture( + 'trust-options', + page.getByLabel('Custom CA certificate path (optional)', { exact: true }), + ); + consumerCommands.push({ engine, tone, kind: 'linux-install', command: unixCommands[0] }); optionalAuth = true; - await page.goto('http://127.0.0.1:5241/browser-tests/unix-private-install.html?optional=1'); + await page.goto(`${origin}/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(); @@ -242,15 +329,38 @@ const { chromium, webkit } = require('playwright'); 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 })); + assert.equal(captures.length, 16); } finally { if (browser) await browser.close(); - await server.close(); + cleanup.browser_closed = true; + await new Promise((resolve) => server.close(resolve)); + cleanup.server_closed = true; + fs.writeFileSync( + path.join(artifacts, 'consumer-commands.json'), + JSON.stringify({ source: binding.source_sha, consumerCommands }, null, 2) + '\n', + ); + fs.writeFileSync( + path.join(artifacts, 'result.json'), + JSON.stringify( + { + source: binding.source_sha, + binding_sha256: hash(bindingBytes), + source_inputs: Object.keys(binding.sources).length, + playwright: require('playwright/package.json').version, + results, + captures, + requests, + served, + cleanup, + }, + null, + 2, + ) + '\n', + ); } + console.log( + JSON.stringify({ result: 'passed', artifacts, results, captures: captures.length, cleanup }), + ); })().catch((e) => { console.error(e); process.exitCode = 1; diff --git a/frontend-modern/browser-verification/75c45c0a6c8bb147.json b/frontend-modern/browser-verification/75c45c0a6c8bb147.json new file mode 100644 index 0000000000..ec0eebd7f4 --- /dev/null +++ b/frontend-modern/browser-verification/75c45c0a6c8bb147.json @@ -0,0 +1,41 @@ +{ + "version": 1, + "base_sha": "14b9b3fbaca62696741aa19845eedbde1cbbb28c", + "verified_at": "2026-10-13T15:41:16.054533Z", + "result": "passed", + "changed_paths": [ + "frontend-modern/src/utils/agentInstallCommand.ts", + "frontend-modern/src/components/Settings/infrastructureOperationsModel.tsx" + ], + "content_sha256": { + "frontend-modern/src/utils/agentInstallCommand.ts": "b6bdfc4a8a8b9253d1dfedb651ed07dc7945542382a672baf2c89b8d2482b18b", + "frontend-modern/src/components/Settings/infrastructureOperationsModel.tsx": "f2a75e62b318b48f528262c77b0fd3f778d95c535575da6d1873869632e126ff" + }, + "routes": [ + "/browser-tests/unix-private-install.html (offline actual Settings installer/state/TokenRevealDialog/Agent Doctor consuming fixture)" + ], + "viewports": [ + { + "width": 1280, + "height": 900 + }, + { + "width": 390, + "height": 900 + } + ], + "states": [ + "Chromium141 desktop and WebKit26 phone, both light and dark; Playwright1.56.1 matches integration lock. 2811 source inputs and three served artifact hashes match14b9b3fb.", + "Synthetic token reveal stays separate from Unix commands; Linux/NAS and FreeBSD private-file alternatives are credential-free and preserve trusted-file instructions. Actual clipboard values match displayed commands.", + "Doctor authentication repair uses the new selected-tool reader and existing identity/profile; ordinary saved-state update and uninstall still need no new token/prompt. Custom CA and URL survive the real state/copy consumers.", + "Optional authentication confirms tokenless commands without any private-file or prompt block. Token reveal does not mint a second credential.", + "All16 final captures opened natively; readable fields/advice and keyboard outlines, no page horizontal overflow; browser/server closed." + ], + "interactions": [ + "Generate synthetic host token; copy separate token; dismiss with Escape and reopen existing reveal without reminting.", + "Keyboard Enter/phone tap on actual Linux installer/private-file copy and Doctor row expansion; actual Doctor repair copy.", + "Advanced URL/custom CA edits into actual lifecycle commands; preserve identity/flags for credential repair and saved-state update/removal semantics.", + "Confirm optional tokenless installation after synthetic auth transition." + ], + "limits": "Modelled API/agent/privilege/installer data, no native QNAP or backend-mirror, deployment/publication/release/reporter acceptance." +} diff --git a/frontend-modern/src/components/Settings/__tests__/InfrastructureOperationsModel.test.tsx b/frontend-modern/src/components/Settings/__tests__/InfrastructureOperationsModel.test.tsx index 42ccaecf25..a5c2b4087a 100644 --- a/frontend-modern/src/components/Settings/__tests__/InfrastructureOperationsModel.test.tsx +++ b/frontend-modern/src/components/Settings/__tests__/InfrastructureOperationsModel.test.tsx @@ -8,6 +8,7 @@ import useInfrastructureInstallStateSource from '../useInfrastructureInstallStat import { resolveAgentCommandPlatform } from '@/utils/agentInstallCommand'; import { INSTALL_PROFILE_OPTIONS, + buildCommandsByPlatform, getCapabilityManagementPath, getCapabilitySurfaceLabel, getPlatformConnectionsViewForCapability, @@ -18,6 +19,28 @@ import { } from '../infrastructureOperationsModel'; describe('infrastructure operations model', () => { + it('offers the existing private-file alternative on Linux/NAS and FreeBSD only when supplied', () => { + const cards = buildCommandsByPlatform( + 'prompt-command', + 'windows', + 'windows-args', + 'file-command', + ); + for (const platform of ['linux', 'freebsd'] as const) { + expect(cards[platform].snippets.map((snippet) => snippet.command)).toEqual([ + 'prompt-command', + 'file-command', + ]); + expect(cards[platform].snippets[1].label).toBe( + 'Install from a private token file (no terminal)', + ); + } + const tokenless = buildCommandsByPlatform('no-token', 'windows', 'windows-args'); + expect(tokenless.linux.snippets).toHaveLength(1); + expect(tokenless.freebsd.snippets).toHaveLength(1); + // Do not silently point a macOS administrator at Linux's /root path. + expect(cards.macos.snippets).toHaveLength(1); + }); it('builds unified rows from connected infrastructure surfaces', () => { const item: ConnectedInfrastructureItem = { id: 'agent-1', 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 18254c397a..0a3fe7187c 100644 --- a/frontend-modern/src/components/Settings/__tests__/useInfrastructureOperationsState.branchcov0722pm.test.tsx +++ b/frontend-modern/src/components/Settings/__tests__/useInfrastructureOperationsState.branchcov0722pm.test.tsx @@ -167,7 +167,8 @@ describe('useInfrastructureOperationsState command-building closures', () => { state.setCustomAgentUrl(HTTPS_URL); const cmd = state.getUninstallCommand(); expect(cmd).toContain('--uninstall'); - expect(cmd).toContain('sudo bash -c'); + expect(cmd).toContain('set -- sudo'); + expect(cmd).toContain('\"$@\" bash -c'); expect(cmd).not.toContain('--preflight-only'); expect(cmd).not.toContain('| bash'); expect(cmd).not.toContain("--token '"); @@ -634,7 +635,8 @@ describe('useInfrastructureOperationsState command-building closures', () => { const cmd = state.getAgentConnectionUpgradeCommand(baseConnection, [], 'linux'); expect(cmd).toContain('--update'); - expect(cmd).toContain('sudo bash -c'); + expect(cmd).toContain('set -- sudo'); + expect(cmd).toContain('\"$@\" bash -c'); expect(cmd).not.toContain('--token-file'); 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 dbc1fafc4b..09d42c907c 100644 --- a/frontend-modern/src/components/Settings/infrastructureOperationsModel.tsx +++ b/frontend-modern/src/components/Settings/infrastructureOperationsModel.tsx @@ -329,6 +329,30 @@ export const INSTALL_PROFILE_OPTIONS: InstallProfileOption[] = [ }, ]; +// The same already-supported file handoff is useful on NAS/Linux hosts whose +// console has no usable stty, not only in FreeBSD GUI command fields. +const privateFileInstallSnippets = (command?: string): InfrastructureCommandSnippet[] => + command + ? [ + { + label: 'Install from a private token file (no terminal)', + command, + note: ( + + Use this when there is no terminal or no usable stty or BusyBox{' '} + stty. 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. + + ), + }, + ] + : []; + export const buildCommandsByPlatform = ( unixCommand: string, windowsInteractiveCommand: string, @@ -348,10 +372,13 @@ export const buildCommandsByPlatform = ( Command auto-escalates with sudo when available. Otherwise run from a root 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. + system and works on Debian, Ubuntu, Proxmox, Fedora, Alpine, Unraid, Synology, and more.{' '} + The prompt needs stty or BusyBox stty. Otherwise use the + private-file command below. ), }, + ...privateFileInstallSnippets(unixPrivateFileCommand), ], }, macos: { @@ -392,25 +419,7 @@ export const buildCommandsByPlatform = ( ), }, - ...(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. - - ), - }, - ] - : []), + ...privateFileInstallSnippets(unixPrivateFileCommand), ], }, windows: { diff --git a/frontend-modern/src/features/docs/__tests__/troubleshootingEntryGuide.test.ts b/frontend-modern/src/features/docs/__tests__/troubleshootingEntryGuide.test.ts index 5f203970eb..88bf145b14 100644 --- a/frontend-modern/src/features/docs/__tests__/troubleshootingEntryGuide.test.ts +++ b/frontend-modern/src/features/docs/__tests__/troubleshootingEntryGuide.test.ts @@ -44,7 +44,7 @@ describe('troubleshooting workflow entry', () => { const section = entry(); const link = section.querySelector('a'); expect(link?.getAttribute('href')).toBe('/docs/VM_DISK_MONITORING#backup-safety'); - expect(section.querySelectorAll('li')).toHaveLength(9); + expect(section.querySelectorAll('li')).toHaveLength(10); expect(section.querySelector('pre')).toBeNull(); const beforeList = [...section.children] .slice( @@ -62,30 +62,32 @@ describe('troubleshooting workflow entry', () => { it('keeps server updates, workload updates and reporting acceptance distinct', () => { const section = entry(); - const rows = section.querySelectorAll('li'); - expect(prose(rows[1] as HTMLElement)).toContain( - 'An update of a monitored container is a different job', - ); - const updateLinks = [...rows[1].querySelectorAll('a')].map((link) => link.getAttribute('href')); + const row = (label: string) => { + const rows = [...section.querySelectorAll('li')].filter((element) => + prose(element.querySelector('strong') as HTMLElement).startsWith(label), + ); + expect(rows, label).toHaveLength(1); + return rows[0] as HTMLElement; + }; + const update = row('Update failed or its result is uncertain'); + const install = row('Agent host is missing or reporting is rejected'); + const reporting = row('Guest readings are missing or disagree'); + const memory = row('Pulse itself uses more memory, CPU or disk writes'); + expect(prose(update)).toContain('An update of a monitored container is a different job'); + const updateLinks = [...update.querySelectorAll('a')].map((link) => link.getAttribute('href')); expect(updateLinks).toEqual([ '/docs/AUTO_UPDATE#update-failed', '/docs/DOCKER#check-a-failed-or-pending-workload-update', ]); - expect(prose(rows[1] as HTMLElement)).toContain( - 'Preserve the current state and rollback evidence', - ); - expect(prose(rows[2] as HTMLElement)).toContain('Installer success is not an accepted report'); - expect(prose(rows[3] as HTMLElement)).toContain( - 'a working Pulse Agent view does not prove API recovery', - ); - expect(prose(rows[6] as HTMLElement)).toContain( - 'Guest usage and database size are different measurements', - ); + expect(prose(update)).toContain('Preserve the current state and rollback evidence'); + expect(prose(install)).toContain('Installer success is not an accepted report'); + expect(prose(reporting)).toContain('a working Pulse Agent view does not prove API recovery'); + expect(prose(memory)).toContain('Guest usage and database size are different measurements'); }); it('routes every entry to an existing shipped heading in the production viewer', () => { const links = [...entry().querySelectorAll('a[href]')]; - expect(links).toHaveLength(16); + expect(links).toHaveLength(17); for (const link of links) { const href = link.getAttribute('href')!; const match = /^\/docs\/([^#]+)#(.+)$/.exec(href); diff --git a/frontend-modern/src/utils/__tests__/agentInstallCommand.test.ts b/frontend-modern/src/utils/__tests__/agentInstallCommand.test.ts index b5b1f87ba1..c0c7be1b52 100644 --- a/frontend-modern/src/utils/__tests__/agentInstallCommand.test.ts +++ b/frontend-modern/src/utils/__tests__/agentInstallCommand.test.ts @@ -31,7 +31,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('sudo bash -c'); + expect(command).toContain('set -- sudo'); + expect(command).toContain('\"$@\" bash -c'); expect(command).not.toContain('token-123'); expect(command).toContain('--token-file "$token_file"'); expect(command).toContain('--insecure'); @@ -60,7 +61,7 @@ describe('agentInstallCommand', () => { }); const preflightIndex = command.indexOf('--preflight-only'); - const sudoIndex = command.indexOf('sudo bash -c'); + const sudoIndex = command.indexOf('\"$@\" bash -c'); expect(command).toContain('bootstrap_dir=$(mktemp -d /tmp/pulse-bootstrap.XXXXXX)'); expect(command).toContain('trap cleanup EXIT'); @@ -336,6 +337,21 @@ const variants = [ ]; describe('single-line Unix install commands', () => { + it.each(variants)('keeps one private body below the usual terminal line limit: %j', (options) => { + const command = buildUnixAgentInstallCommand({ + ...options, + token: 'present', + extraArgs: [ + '--update', + '--enable-docker', + "--agent-id 'agent-42'", + "--hostname 'node.example'", + ], + }); + expect(Buffer.byteLength(command, 'utf8')).toBeLessThan(4096); + expect(command.match(/bash -c /g)).toHaveLength(1); + expect(command).toContain('set -- sudo'); + }); it.each(variants)('survives text-input normalization: %j', (options) => { const command = buildUnixAgentInstallCommand(options); const input = document.createElement('input'); @@ -391,7 +407,8 @@ 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()) +command_sent = not bool(history) +exit_sent = False os.close(slave) output = b"" sent = False @@ -406,6 +423,17 @@ while time.monotonic() < deadline: raise if not chunk: break output += chunk + # A human pastes after the caller's prompt. Injecting a longer command + # while the PTY is still in its startup canonical mode can truncate it + # before Bash has even entered readline. + if history and not command_sent and output.endswith(b"PULSE_TEST$ "): + os.write(master, (p["command"] + "\\n").encode()) + command_sent = True + continue + # Do not queue exit while the private reader/installer owns the tty. + if history and sent and not exit_sent and output.endswith(b"PULSE_TEST$ "): + os.write(master, b"exit\\n") + exit_sent = True # The interactive shell's echo of the command line quotes the prompt # text too, so a chunk can end there long before the bootstrap runs. # Only the bootstrap's own stty -echo (recorded by the stty fixture) @@ -424,15 +452,13 @@ while time.monotonic() < deadline: 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): +if initial_tty != final_tty: output += b"\\nBootstrap did not restore terminal echo/canonical mode\\n" code = 1 if echo_at_prompt: @@ -444,7 +470,8 @@ sys.stdout.buffer.write(output) sys.exit(code if code >= 0 else 128 - code) `; -const makeUnixFixture = () => { +type TtyTool = 'stty' | 'busybox' | 'both' | 'none' | 'busybox-without-stty'; +const makeUnixFixture = (ttyTool: TtyTool = 'stty', isolatedPath = false) => { const dir = mkdtempSync(join(tmpdir(), 'pulse-private-unix-')); chmodSync(dir, 0o700); const trace = join(dir, 'trace'); @@ -454,10 +481,37 @@ const makeUnixFixture = () => { 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 \"$@\"', - ); + const ttyBody = ` +printf '%s\\n' "$0 $*" >> "$TTY_TRACE" +[ "$FAIL_AT:$1" != tty-state:-g ] || exit 1 +[ "$FAIL_AT:$1" != tty-state-empty:-g ] || exit 0 +[ "$FAIL_AT:$1" != tty-echo:-echo ] || exit 1 +if [ "$FAIL_AT" = tty-restore ] && [ "$1" != -g ] && [ "$1" != -echo ] && [ ! -e "$RESTORE_FAILED" ]; then + : > "$RESTORE_FAILED"; exit 1 +fi +if [ "$1" = -echo ]; then echo "$PPID" > "$READER_PID"; fi +`; + if (ttyTool === 'stty' || ttyTool === 'both') fake('stty', ttyBody + 'exec /bin/stty "$@"'); + if (ttyTool === 'busybox' || ttyTool === 'both') + fake( + 'busybox', + `[ "$1" = stty ] || exit 127; shift +${ttyBody} +# CI's shim exercises dispatch/modes without adding a package dependency. +# Source proof supplies an actual static BusyBox binary as an explicit input. +if [ -n "\${PULSE_TEST_BUSYBOX:-}" ]; then exec "$PULSE_TEST_BUSYBOX" stty "$@"; fi +exec /bin/stty "$@"`, + ); + if (ttyTool === 'busybox-without-stty') fake('busybox', 'exit 127'); + if (isolatedPath) { + // No /bin in PATH: an accidentally inherited standalone stty must not + // turn the absent-tool control into a successful prompt. + for (const program of ['bash', 'sh', 'python3', 'cp', 'cat', 'stat', 'dirname', 'rm', 'rmdir']) + symlinkSync( + execFileSync('which', [program], { encoding: 'utf8' }).trim(), + join(bin, program), + ); + } fake('id', 'if [ "$1" = -u ]; then echo "$FAKE_UID"; else exec /usr/bin/id "$@"; fi'); fake( 'sudo', @@ -507,7 +561,7 @@ if [ "$FAIL_AT" = TERM ] || [ "$FAIL_AT" = HUP ]; then kill -s "$FAIL_AT" "$PPID trace, env: (uid = '0', failure = '') => ({ ...process.env, - PATH: `${bin}:${process.env.PATH}`, + PATH: isolatedPath ? bin : `${bin}:${process.env.PATH}`, FAKE_UID: uid, FAIL_AT: failure, TRACE: trace, @@ -517,6 +571,8 @@ if [ "$FAIL_AT" = TERM ] || [ "$FAIL_AT" = HUP ]; then kill -s "$FAIL_AT" "$PPID CAPTURE: join(dir, 'capture'), SECRET_ENV: join(dir, 'secret-env'), READER_PID: join(dir, 'reader-pid'), + TTY_TRACE: join(dir, 'tty-trace'), + RESTORE_FAILED: join(dir, 'restore-failed'), PULSE_TOKEN: '', }), close: () => rmSync(dir, { recursive: true, force: true }), @@ -852,6 +908,130 @@ describe('credential-free Unix lifecycle execution', { timeout: 25_000 }, () => ); }); +describe('terminal-control portability', { timeout: 25_000 }, () => { + const command = () => + buildUnixAgentInstallCommand({ baseUrl: 'https://pulse.invalid', token: 'present' }); + + it.each(['stty', 'busybox', 'both'] as const)( + 'keeps maximum input private and restores modes with %s', + (tool) => { + const fixture = makeUnixFixture(tool, true); + const input = 'portable-private-'.padEnd(4096, 'x'); + const history = join(fixture.dir, 'history'); + try { + const result = runPrivateUnix(command(), input, fixture.env(), history); + expect(result.status, result.stdout + result.stderr).toBe(0); + expect(readFileSync(join(fixture.dir, 'capture'), 'utf8')).toBe(input); + expect(readFileSync(join(fixture.dir, 'secret-env'), 'utf8')).toBe(':'); + const trace = readFileSync(join(fixture.dir, 'tty-trace'), 'utf8'); + expect(trace).toContain(`${tool === 'busybox' ? 'busybox' : 'stty'} -g`); + if (tool === 'both') expect(trace).not.toContain('busybox'); + for (const text of [ + result.stdout, + result.stderr, + readFileSync(history, 'utf8'), + readFileSync(join(fixture.dir, 'argv'), 'utf8'), + ]) + expect(text).not.toContain(input); + assertUnixCleanup(fixture.dir); + } finally { + fixture.close(); + } + }, + ); + + it.each(['blank', 'overflow', 'HUP', 'INT', 'TERM'])( + 'refuses %s with applet-only input, without leaks or leftover modes/files', + (failure) => { + const fixture = makeUnixFixture('busybox', true); + const input = failure === 'overflow' ? 'x'.repeat(10000) + 'PRIVATE_OVERFLOW_SENTINEL' : ''; + const history = join(fixture.dir, 'history'); + try { + const result = runPrivateUnix( + command(), + input, + fixture.env(), + history, + ['HUP', 'INT', 'TERM'].includes(failure) ? failure : undefined, + ); + expect(result.status, result.stdout + result.stderr).not.toBe(0); + expect(result.stdout).not.toMatch(/deadline expired|did not restore/); + expect(existsSync(join(fixture.dir, 'capture'))).toBe(false); + for (const text of [result.stdout, result.stderr, readFileSync(history, 'utf8')]) + expect(text).not.toMatch(/PRIVATE_OVERFLOW_SENTINEL|x{512}/); + assertUnixCleanup(fixture.dir); + } finally { + fixture.close(); + } + }, + ); + + it.each([ + ['none', '', 'Silent token input requires stty or BusyBox stty'], + ['busybox-without-stty', '', 'Cannot read terminal settings'], + ['stty', 'tty-state', 'Cannot read terminal settings'], + ['busybox', 'tty-state', 'Cannot read terminal settings'], + ['stty', 'tty-state-empty', 'Cannot read terminal settings'], + ['busybox', 'tty-state-empty', 'Cannot read terminal settings'], + ['stty', 'tty-echo', 'Cannot disable terminal echo'], + ['busybox', 'tty-echo', 'Cannot disable terminal echo'], + ['stty', 'tty-restore', 'Cannot restore terminal settings'], + ['busybox', 'tty-restore', 'Cannot restore terminal settings'], + ] as const)( + 'diagnoses %s/%s without pretending a real TTY is missing', + (tool, failure, error) => { + const fixture = makeUnixFixture(tool, true); + try { + const result = runPrivateUnix(command(), 'private-input', fixture.env('0', failure)); + expect(result.status, result.stdout + result.stderr).not.toBe(0); + expect(result.stdout).toContain(error); + expect(result.stdout).toMatch(/no installation was attempted/i); + expect(result.stdout).not.toMatch( + /A terminal is required|command not found|deadline expired|did not restore|private-input/, + ); + expect(existsSync(join(fixture.dir, 'capture'))).toBe(false); + assertUnixCleanup(fixture.dir); + } finally { + fixture.close(); + } + }, + ); + + it.each(['stty', 'busybox', 'none'] as const)('no TTY fails before selecting %s', (tool) => { + const fixture = makeUnixFixture(tool, true); + try { + const result = spawnSync('sh', ['-c', command()], { env: fixture.env(), encoding: 'utf8' }); + expect(result.status, result.stdout + result.stderr).not.toBe(0); + expect(result.stderr).toContain('A terminal is required'); + expect(result.stderr).toContain('private token-file command'); + expect(existsSync(join(fixture.dir, 'tty-trace'))).toBe(false); + expect(existsSync(join(fixture.dir, 'capture'))).toBe(false); + assertUnixCleanup(fixture.dir); + } finally { + fixture.close(); + } + }); + + it('uses the existing private-file handoff with neither TTY nor terminal utilities', () => { + const fixture = makeUnixFixture('none', true); + try { + const file = join(fixture.dir, 'operator-token'); + writeFileSync(file, 'file-input', { mode: 0o600 }); + const cmd = buildUnixAgentInstallCommand({ + baseUrl: 'https://pulse.invalid', + tokenFilePath: file, + }); + const result = spawnSync('sh', ['-c', cmd], { env: fixture.env(), encoding: 'utf8' }); + expect(result.status, result.stdout + result.stderr).toBe(0); + expect(readFileSync(join(fixture.dir, 'capture'), 'utf8')).toBe('file-input'); + expect(readFileSync(file, 'utf8')).toBe('file-input'); + assertUnixCleanup(fixture.dir); + } finally { + fixture.close(); + } + }); +}); + describe('saved-credential uninstall commands', () => { it.each(['unused-new-install-token', null])( 'never asks for installation credentials (%s)', diff --git a/frontend-modern/src/utils/agentInstallCommand.ts b/frontend-modern/src/utils/agentInstallCommand.ts index c82158b381..c2f9f2bd42 100644 --- a/frontend-modern/src/utils/agentInstallCommand.ts +++ b/frontend-modern/src/utils/agentInstallCommand.ts @@ -205,13 +205,19 @@ export const buildUnixAgentInstallCommand = ({ '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"; };', + 'tty_control=();', + 'cleanup() { unset pulse_token pulse_discard; if [ -n "${tty_state:-}" ]; then "${tty_control[@]}" "$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 /dev/null; then echo "A terminal is required to enter the token. Use a private token-file command instead. No installation was attempted." >&2; exit 1; fi;', + 'if command -v stty >/dev/null 2>&1; then tty_control=(stty); elif command -v busybox >/dev/null 2>&1; then tty_control=(busybox stty); else echo "Silent token input requires stty or BusyBox stty. Use a private token-file command instead. No installation was attempted." >&2; exit 1; fi;', + 'if ! tty_state=$("${tty_control[@]}" -g /dev/null) || [ -z "$tty_state" ]; then echo "Cannot read terminal settings with stty or BusyBox stty. Use a private token-file command instead. No installation was attempted." >&2; exit 1; fi;', + 'if ! "${tty_control[@]}" -echo /dev/null; then echo "Cannot disable terminal echo. No token was requested and no installation was attempted." >&2; exit 1; fi;', `if ! IFS= read -r -s -p ${shellQuoteArg('Pulse agent token (paste at this prompt, not in the command): ')} -n 4097 pulse_token &2; exit 1; fi;`, 'if [ "${#pulse_token}" -gt 4096 ]; then while IFS= read -r -s -n 4097 pulse_discard /dev/null; then echo "Cannot restore terminal settings. No installation was attempted." >&2; exit 1; fi; unset tty_state;', "printf '\\n' >/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;', @@ -233,7 +239,11 @@ export const buildUnixAgentInstallCommand = ({ `curl ${curlFlags}${caCertArg} ${shellQuoteArg(`${normalizedBaseUrl}/install.sh`)} -o "$install_script";`, // Detachment must not require a downloadable new agent binary. ...(!uninstall ? [`bash "$install_script" ${preflightArgs}${caCertArg}${insecureArg};`] : []), - `if [ "$(id -u)" -eq 0 ]; then ${child} else sudo ${child} fi;`, + // Expand the root/sudo prefix once. Repeating the entire quoted reader + // makes a normal pasted command exceed some terminals' canonical line + // limit. Positional arguments live only inside this isolated subshell. + 'if [ "$(id -u)" -eq 0 ]; then set --; else set -- sudo; fi;', + `"$@" ${child}`, ')', ].join(' '); };