mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-26 21:47:20 +00:00
Compose reviewed portable private input with current maintenance
Preserve the exact reviewed private-input candidate and its source-bound receipts. Qualify changed drawer composition separately; no native or release acceptance is claimed. Change-source: pulse-maintainer
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
+23
@@ -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',
|
||||
|
||||
+4
-2
@@ -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();
|
||||
|
||||
@@ -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: (
|
||||
<span>
|
||||
Use this when there is no terminal or no usable <code>stty</code> or BusyBox{' '}
|
||||
<code>stty</code>. Before running, save the separately revealed token to{' '}
|
||||
<code>/root/.config/pulse-agent/bootstrap-token</code> using a trusted file editor or
|
||||
upload path. The file must be root-owned with mode <code>0600</code> in a root-owned{' '}
|
||||
<code>0700</code> 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.
|
||||
</span>
|
||||
),
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
export const buildCommandsByPlatform = (
|
||||
unixCommand: string,
|
||||
windowsInteractiveCommand: string,
|
||||
@@ -348,10 +372,13 @@ export const buildCommandsByPlatform = (
|
||||
Command auto-escalates with <code>sudo</code> when available. Otherwise run from a root
|
||||
shell (for example <code>su -</code>). 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 <code>stty</code> or BusyBox <code>stty</code>. Otherwise use the
|
||||
private-file command below.
|
||||
</span>
|
||||
),
|
||||
},
|
||||
...privateFileInstallSnippets(unixPrivateFileCommand),
|
||||
],
|
||||
},
|
||||
macos: {
|
||||
@@ -392,25 +419,7 @@ export const buildCommandsByPlatform = (
|
||||
</span>
|
||||
),
|
||||
},
|
||||
...(unixPrivateFileCommand
|
||||
? [
|
||||
{
|
||||
label: 'Install from a private token file (no terminal)',
|
||||
command: unixPrivateFileCommand,
|
||||
note: (
|
||||
<span>
|
||||
Before running, save the separately revealed token to{' '}
|
||||
<code>/root/.config/pulse-agent/bootstrap-token</code> using a trusted file editor
|
||||
or upload path. The file must be root-owned with mode <code>0600</code> in a
|
||||
root-owned <code>0700</code> 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.
|
||||
</span>
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...privateFileInstallSnippets(unixPrivateFileCommand),
|
||||
],
|
||||
},
|
||||
windows: {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)',
|
||||
|
||||
@@ -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/tty 2>/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/tty 2>/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 </dev/tty); then echo "A terminal is required to enter the token. No installation was attempted." >&2; exit 1; fi;',
|
||||
'stty -echo </dev/tty;',
|
||||
// Some NAS hosts expose stty only as a BusyBox applet. Keep the selected
|
||||
// utility for both mode changes and cleanup; never fall back to echoed
|
||||
// input. Missing TTY, missing tool and failed mode changes are distinct.
|
||||
'if ! { : </dev/tty; } 2>/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/tty 2>/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/tty 2>/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 </dev/tty; then echo "Token input was interrupted. No installation was attempted." >&2; exit 1; fi;`,
|
||||
'if [ "${#pulse_token}" -gt 4096 ]; then while IFS= read -r -s -n 4097 pulse_discard </dev/tty && [ "${#pulse_discard}" -eq 4097 ]; do :; done; unset pulse_discard; fi;',
|
||||
'stty "$tty_state" </dev/tty; unset tty_state;',
|
||||
'if ! "${tty_control[@]}" "$tty_state" </dev/tty 2>/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(' ');
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user