mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-24 04:33:17 +00:00
Integrate reviewed private Unix lifecycle command repair
Merge the exact reviewed Web tip and retain its stronger retirement copy assertions where concurrent test-only maintenance overlaps. Runtime frontend content remains exactly browser-verified; existing credential issuance, identity and transport policy are preserved. Change-source: pulse-maintainer
This commit is contained in:
@@ -8344,20 +8344,37 @@ Focused proof lives in `internal/hostagent/agent_new_test.go`
|
||||
`TestGetReliableMachineIDNormalizesWindowsBraces`); the Windows recovery file is
|
||||
cross-compiled with `GOOS=windows go build ./internal/hostagent/`.
|
||||
|
||||
### Unix bootstrap survives single-line command fields
|
||||
### Private Unix lifecycle bootstrap
|
||||
|
||||
The shared frontend Unix installer command uses explicit shell statement
|
||||
separators rather than literal newlines or backslash-newline continuations.
|
||||
This preserves its grammar when copied into a single-line command field (#2123).
|
||||
The builder rejects embedded CR/LF in its normalized inputs rather than silently
|
||||
altering a quoted URL, credential, CA path or option. Shell quoting is unchanged.
|
||||
Download and unprivileged preflight still precede token-file creation and sudo
|
||||
installation. Root and sudo paths retain private token directories, file modes,
|
||||
TLS options, non-interactive operation and cleanup on success or failure.
|
||||
`agentInstallCommand.test.ts` covers normalized-input syntax and
|
||||
synthetic root/sudo execution under sh and Bash, including download, preflight
|
||||
and install failures. A clipboard fixture proves input compatibility, not native
|
||||
FreeBSD installation, offline dependency availability or service persistence.
|
||||
The shared frontend Unix installer preserves single-line paste grammar,
|
||||
quoted destinations, explicit custom CA/insecure continuity and profile flags.
|
||||
Issued credentials are separate from copied commands. A privileged Bash child
|
||||
owns the Core-aligned silent bounded reader and temporary 0700/0600 handoff;
|
||||
maximum-size input must survive unchanged, overflow must not reach the caller's
|
||||
history, and every exit must restore terminal modes and remove temporary files.
|
||||
Download/preflight failures never reach credential entry or installation.
|
||||
Sudo validation occurs before download; download and unprivileged preflight
|
||||
still precede privileged installation. Token-optional and saved-state update
|
||||
paths need no terminal or replacement credential.
|
||||
|
||||
For non-terminal FreeBSD command fields, the existing page offers an absolute
|
||||
local private-token-file variant, with ownership/regular-file/symlink/mode
|
||||
checks and the installer's bounded trusted reader. The operator provisions and
|
||||
removes that file through a trusted file path; the command neither contains its
|
||||
contents nor deletes it. A GUI without private file provisioning requires a
|
||||
console/SSH session; native pfSense/FreeBSD installation is not inferred.
|
||||
|
||||
Unix upgrades, explicit credential repairs and uninstall now share this
|
||||
complete-download transport rather than executing a partial fetch pipe or
|
||||
putting tokens in argv. Repairs/removal retain canonical ID and hostname;
|
||||
ordinary updates preserve their saved-state credential/identity route. Removal
|
||||
skips new-binary preflight, which is not a detachment prerequisite. Issuance,
|
||||
scopes, tenant/host binding, TLS and command-execution opt-in are unchanged.
|
||||
Windows credential handling is deliberately unchanged pending its own complete
|
||||
validated step. `agentInstallCommand.test.ts` is the executable helper proof;
|
||||
`infrastructureAgentDoctorModel.test.ts` verifies the real operations closures
|
||||
and existing credential/identity choices. Browser receipts cover real settings
|
||||
components with synthetic APIs, not installed outcome or reporter acceptance.
|
||||
|
||||
### Agent Doctor handoff stays readable on a phone
|
||||
|
||||
|
||||
@@ -11015,19 +11015,37 @@ status-only or unrelated test for the setup contract. The registry routes only `
|
||||
setup proof policy; unrelated API runtime paths retain their existing verification
|
||||
requirements. No runtime paths or required contract updates are exempted.
|
||||
|
||||
### Single-line Unix client install transport
|
||||
### Single-line private Unix client install transport
|
||||
|
||||
`frontend-modern/src/utils/agentInstallCommand.ts` emits a single-line Unix
|
||||
bootstrap with explicit shell separators so text-input paste normalization
|
||||
cannot join shell statements (#2123). It retains the canonical URL, quoted
|
||||
credential-to-private-file transport, custom CA/insecure continuity, and
|
||||
preflight-before-install ordering; no API request, response or auth scope changes.
|
||||
Normalized values containing CR/LF are rejected, not rewritten. Regression
|
||||
coverage in `agentInstallCommand.test.ts` checks shell syntax after
|
||||
text-input normalization, literal quoted token bytes, root/sudo private-file
|
||||
permissions, cleanup and early exit when download or preflight fails. This
|
||||
client transport repair does not establish the reporter's native pfSense abort
|
||||
cause or successful offline installation.
|
||||
`frontend-modern/src/utils/agentInstallCommand.ts` retains explicit single-line
|
||||
shell grammar for paste hosts (#2123), canonical URLs, custom CA/insecure
|
||||
continuity and complete-download-before-execution. Token values select private
|
||||
entry only and never become shell source, argv or environment. The privileged
|
||||
Bash child uses the bounded Core bootstrap reader; 4096-character input is
|
||||
preserved, overflow is drained before returning to the caller, and terminal
|
||||
modes and temporary 0700/0600 files are restored/removed on exit. Download and
|
||||
unprivileged preflight precede credential entry and installation; sudo
|
||||
validation precedes the download so authentication cannot consume token input.
|
||||
|
||||
A no-terminal command can use an absolute local `tokenFilePath`. It requires a
|
||||
privileged-user-owned regular non-symlink 0600 file in an owned non-symlink 0700
|
||||
parent, passes only its path, and leaves this operator-owned file untouched.
|
||||
The existing installer's bounded trusted token reader still validates content
|
||||
and ancestor trust. The FreeBSD card explains this existing private-file route
|
||||
and does not put credentials in GUI command fields. It does not claim a GUI can
|
||||
create private files or establish native appliance installation.
|
||||
|
||||
Unix install, credential replacement, upgrade and uninstall use the shared
|
||||
transport. Explicit replacement/uninstall retain canonical agent ID and host;
|
||||
ordinary updates recover their existing credential/identity from installer
|
||||
state without a new prompt. Uninstall skips new-binary preflight so detachment
|
||||
is not blocked by availability of an unrelated replacement binary. Server
|
||||
minting, scope, TLS choice, execution opt-in and response shapes are unchanged.
|
||||
Windows command transport is unchanged and remains separate unfinished work.
|
||||
`agentInstallCommand.test.ts` exercises actual shells, PTYs, private-file
|
||||
boundaries and failures; `infrastructureAgentDoctorModel.test.ts` exercises the
|
||||
real lifecycle closures. These are modelled installer/privilege probes, not
|
||||
native system installation or publication.
|
||||
|
||||
### Pulse Mobile pairing readiness wording
|
||||
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
// UI proof only. No real token issuance, appliance, download or installation.
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { chromium, webkit } = require('playwright');
|
||||
(async () => {
|
||||
const root = '/workspace/frontend-modern';
|
||||
process.chdir(root);
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
fixtureHTML: fs.existsSync(path.join(root, 'browser-tests/unix-private-install.html')),
|
||||
fixtureTSX: fs.existsSync(path.join(root, 'browser-tests/unix-private-install.tsx')),
|
||||
}),
|
||||
);
|
||||
const artifacts = path.join(root, 'node_modules', 'unix-private-browser');
|
||||
fs.mkdirSync(artifacts, { recursive: true });
|
||||
const { createServer } = await import(path.join(root, 'node_modules/vite/dist/node/index.js'));
|
||||
const server = await createServer({
|
||||
root,
|
||||
configFile: path.join(root, 'vite.config.ts'),
|
||||
server: { host: '127.0.0.1', port: 5241, strictPort: true },
|
||||
});
|
||||
const results = [];
|
||||
let browser;
|
||||
const secret = 'browser-synthetic-install-token';
|
||||
try {
|
||||
await server.listen();
|
||||
for (const [engine, width, tone] of [
|
||||
['chromium', 1280, 'light'],
|
||||
['webkit', 390, 'dark'],
|
||||
]) {
|
||||
browser = await { chromium, webkit }[engine].launch(
|
||||
engine === 'chromium'
|
||||
? { headless: true, channel: 'chromium', args: ['--no-sandbox'] }
|
||||
: { headless: true },
|
||||
);
|
||||
const page = await browser.newPage({
|
||||
viewport: { width, height: 900 },
|
||||
...(engine === 'webkit' ? { isMobile: true, hasTouch: true } : {}),
|
||||
});
|
||||
const errors = [];
|
||||
page.on('pageerror', (e) => errors.push(e.message));
|
||||
page.on('console', (m) => {
|
||||
if (m.type() === 'error') {
|
||||
console.log('PAGE_CONSOLE_ERROR', m.text());
|
||||
errors.push(m.text());
|
||||
}
|
||||
});
|
||||
page.on('requestfailed', (r) => console.log('REQUEST_FAILED', r.url(), r.failure()));
|
||||
let minted = 0;
|
||||
let optionalAuth = false;
|
||||
await page.addInitScript(() => {
|
||||
window.__copies = [];
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
value: { writeText: async (text) => window.__copies.push(text) },
|
||||
});
|
||||
});
|
||||
await page.route('**/api/**', async (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
if (!url.pathname.startsWith('/api/')) return route.continue();
|
||||
if (url.pathname === '/api/security/status')
|
||||
return route.fulfill({
|
||||
json: {
|
||||
requiresAuth: !optionalAuth,
|
||||
hasAuthentication: !optionalAuth,
|
||||
apiTokenConfigured: !optionalAuth,
|
||||
},
|
||||
});
|
||||
if (url.pathname === '/api/agent-install-command') {
|
||||
const body = route.request().postDataJSON();
|
||||
assert.equal(body.type, 'host');
|
||||
assert.equal(body.enableCommands, false);
|
||||
minted++;
|
||||
return route.fulfill({
|
||||
json: {
|
||||
token: secret,
|
||||
record: {
|
||||
id: 'synthetic-token-record',
|
||||
name: 'Synthetic installer',
|
||||
prefix: 'browser',
|
||||
suffix: 'token',
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
if (url.pathname === '/api/state')
|
||||
return route.fulfill({ json: { connectedInfrastructure: [] } });
|
||||
return route.fulfill({ json: { data: [] } });
|
||||
});
|
||||
await page.goto('http://127.0.0.1:5241/browser-tests/unix-private-install.html');
|
||||
await page.waitForTimeout(1000);
|
||||
console.log('PAGE_TITLE', await page.title());
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
engine,
|
||||
errors,
|
||||
body: (await page.locator('body').innerText()).slice(0, 300),
|
||||
}),
|
||||
);
|
||||
await page
|
||||
.getByRole('heading', { name: 'Private Unix agent installation', exact: true })
|
||||
.waitFor();
|
||||
if (tone === 'dark')
|
||||
await page.evaluate(() => document.documentElement.classList.add('dark'));
|
||||
await page.getByRole('button', { name: 'Generate token', exact: true }).first().click();
|
||||
const dialog = page.getByRole('dialog', { name: 'API token ready' });
|
||||
await dialog.waitFor();
|
||||
await page.waitForFunction(() => {
|
||||
const d = document.querySelector('[role="dialog"]');
|
||||
return d && getComputedStyle(d).opacity === '1';
|
||||
});
|
||||
assert.match(await dialog.innerText(), /silent “Pulse agent token” prompt/);
|
||||
const copyToken = dialog.getByRole('button', { name: 'Copy token', exact: true });
|
||||
await copyToken.focus();
|
||||
await page.keyboard.press('Enter');
|
||||
assert.equal(await page.evaluate(() => window.__copies.at(-1)), secret);
|
||||
await page.screenshot({ path: path.join(artifacts, `${engine}-token.png`), fullPage: true });
|
||||
await page.keyboard.press('Escape');
|
||||
await dialog.waitFor({ state: 'hidden' });
|
||||
// The real component's platform cards and copy handlers.
|
||||
const code = page.locator('code').filter({ hasText: 'Pulse agent token' });
|
||||
assert.ok((await code.count()) >= 3);
|
||||
const unixCommands = await code.allTextContents();
|
||||
for (const command of unixCommands) {
|
||||
assert.ok(!command.includes(secret));
|
||||
assert.ok(command.includes('--token-file "$token_file"'));
|
||||
assert.ok(!/[\r\n]/.test(command));
|
||||
}
|
||||
const guiCode = page.locator('code').filter({ hasText: 'token_parent=${token_file%/*}' });
|
||||
assert.equal(await guiCode.count(), 1);
|
||||
const guiCommand = await guiCode.innerText();
|
||||
assert.ok(guiCommand.includes('/root/.config/pulse-agent/bootstrap-token'));
|
||||
assert.ok(!guiCommand.includes('read -r'));
|
||||
assert.ok(!guiCommand.includes(secret));
|
||||
assert.ok(
|
||||
(await page.locator('body').innerText()).includes(
|
||||
'Never put the token in a GUI command field',
|
||||
),
|
||||
);
|
||||
await page.getByRole('button', { name: 'Copy Install command', exact: true }).click();
|
||||
assert.equal(await page.evaluate(() => window.__copies.at(-1)), unixCommands[0]);
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: 'Copy Install from a private token file (no terminal) command',
|
||||
exact: true,
|
||||
})
|
||||
.click();
|
||||
assert.equal(await page.evaluate(() => window.__copies.at(-1)), guiCommand);
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: 'Copy authentication repair command for doctor-fixture',
|
||||
exact: true,
|
||||
})
|
||||
.click();
|
||||
assert.ok(!(await page.evaluate(() => window.__copies.at(-1))).includes(secret));
|
||||
|
||||
await page.getByRole('button', { name: 'Show token only', exact: true }).click();
|
||||
await dialog.waitFor();
|
||||
assert.equal(minted, 1);
|
||||
await dialog.getByRole('button', { name: 'Dismiss', exact: true }).click();
|
||||
await page
|
||||
.getByRole('button', { name: 'Show advanced connection and install options', exact: true })
|
||||
.click();
|
||||
await page
|
||||
.getByLabel('Connection URL (Agent → Pulse)', { exact: true })
|
||||
.fill('https://pulse.example/base/');
|
||||
await page
|
||||
.getByLabel('Custom CA certificate path (optional)', { exact: true })
|
||||
.fill('/etc/pulse/ca.pem');
|
||||
for (const label of [
|
||||
'Copy Unix uninstall',
|
||||
'Copy Unix credential repair',
|
||||
'Copy Unix saved-state update',
|
||||
]) {
|
||||
await page.getByRole('button', { name: label, exact: true }).click();
|
||||
const command = await page.evaluate(() => window.__copies.at(-1));
|
||||
assert.ok(!command.includes(secret));
|
||||
assert.ok(command.includes('https://pulse.example/base'));
|
||||
assert.ok(command.includes('--cacert'));
|
||||
assert.ok(!command.includes('| bash'));
|
||||
if (label.includes('uninstall')) {
|
||||
assert.ok(command.includes('--uninstall'));
|
||||
assert.ok(command.includes('agent-fixture-42'));
|
||||
assert.ok(command.includes('node.example'));
|
||||
assert.ok(!command.includes('--preflight-only'));
|
||||
} else {
|
||||
assert.ok(command.includes('--update'));
|
||||
assert.ok(command.includes('--enable-docker'));
|
||||
assert.ok(command.includes('--preflight-only'));
|
||||
if (label.includes('credential')) {
|
||||
assert.ok(command.includes('agent-fixture-42'));
|
||||
assert.ok(command.includes('node.example'));
|
||||
assert.ok(command.includes('--token-file'));
|
||||
} else {
|
||||
assert.ok(!command.includes('--token-file'));
|
||||
assert.ok(!command.includes('read -r'));
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.ok(
|
||||
(await page.locator('body').innerText()).includes(
|
||||
'Never insert the token into the command',
|
||||
),
|
||||
);
|
||||
await page.screenshot({
|
||||
path: path.join(artifacts, `${engine}-commands.png`),
|
||||
fullPage: true,
|
||||
});
|
||||
optionalAuth = true;
|
||||
await page.goto('http://127.0.0.1:5241/browser-tests/unix-private-install.html?optional=1');
|
||||
await page.getByRole('button', { name: 'Confirm without token', exact: true }).click();
|
||||
const optionalCode = page.locator('code').filter({ hasText: 'bootstrap_dir=$(mktemp' });
|
||||
const optionalCommands = await optionalCode.allTextContents();
|
||||
assert.ok(optionalCommands.length >= 3);
|
||||
for (const command of optionalCommands) {
|
||||
assert.ok(!command.includes('--token-file'));
|
||||
assert.ok(!command.includes('read -r'));
|
||||
}
|
||||
assert.equal(
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: 'Copy Install from a private token file (no terminal) command',
|
||||
exact: true,
|
||||
})
|
||||
.count(),
|
||||
0,
|
||||
);
|
||||
assert.deepEqual(errors, []);
|
||||
results.push({
|
||||
engine,
|
||||
version: browser.version(),
|
||||
width,
|
||||
tone,
|
||||
issued: minted,
|
||||
copied: await page.evaluate(() => window.__copies.length),
|
||||
unixCommands: unixCommands.length,
|
||||
guiCommand: true,
|
||||
optionalAuth: true,
|
||||
errors,
|
||||
});
|
||||
await browser.close();
|
||||
browser = undefined;
|
||||
}
|
||||
fs.writeFileSync(
|
||||
path.join(artifacts, 'result.json'),
|
||||
JSON.stringify({ playwright: require('playwright/package.json').version, results }, null, 2),
|
||||
);
|
||||
console.log(JSON.stringify({ result: 'passed', artifacts, results }));
|
||||
} finally {
|
||||
if (browser) await browser.close();
|
||||
await server.close();
|
||||
}
|
||||
})().catch((e) => {
|
||||
console.error(e);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Unix private agent installation</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./unix-private-install.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,103 @@
|
||||
// Production settings state, installer and token dialog; synthetic API data only.
|
||||
import { Route, Router } from '@solidjs/router';
|
||||
import { render } from 'solid-js/web';
|
||||
import {
|
||||
InfrastructureOperationsStateProvider,
|
||||
useInfrastructureOperationsContext,
|
||||
} from '../src/components/Settings/useInfrastructureOperationsState';
|
||||
import { InfrastructureInstallerSection } from '../src/components/Settings/InfrastructureInstallerSection';
|
||||
import { InfrastructureAgentDoctorPage } from '../src/components/Settings/InfrastructureAgentDoctorPage';
|
||||
import type { InfrastructureAgentDoctorTarget } from '../src/components/Settings/infrastructureAgentUpdateCommandsModel';
|
||||
import { TokenRevealDialog } from '../src/components/TokenRevealDialog';
|
||||
import '../src/index.css';
|
||||
|
||||
const Operations = () => {
|
||||
const state = useInfrastructureOperationsContext();
|
||||
const identity = {
|
||||
agentActionId: 'agent-fixture-42',
|
||||
agentId: 'unused-alias',
|
||||
hostname: 'node.example',
|
||||
};
|
||||
const connection = {
|
||||
id: 'agent:agent-fixture-42',
|
||||
type: 'agent',
|
||||
name: 'node.example',
|
||||
address: 'node.example',
|
||||
state: 'unauthorized',
|
||||
agentIdentity: { hostname: 'node.example', commandsEnabled: false },
|
||||
} as Parameters<typeof state.getAgentConnectionUpgradeCommand>[0];
|
||||
return (
|
||||
<section class="space-y-2 p-4 border border-border" aria-label="Lifecycle command fixture">
|
||||
<p>Actual operations state: identity and trust options must survive.</p>
|
||||
<button onClick={() => navigator.clipboard.writeText(state.getUninstallCommand(identity))}>
|
||||
Copy Unix uninstall
|
||||
</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
navigator.clipboard.writeText(
|
||||
state.getAgentConnectionUpgradeCommand(connection, ['--enable-docker'], 'linux', true),
|
||||
)
|
||||
}
|
||||
>
|
||||
Copy Unix credential repair
|
||||
</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
navigator.clipboard.writeText(
|
||||
state.getAgentConnectionUpgradeCommand(connection, ['--enable-docker'], 'linux', false),
|
||||
)
|
||||
}
|
||||
>
|
||||
Copy Unix saved-state update
|
||||
</button>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
const Fixture = () => (
|
||||
<InfrastructureOperationsStateProvider embedded>
|
||||
<main class="mx-auto max-w-5xl p-4 bg-base text-base-content">
|
||||
<h1 class="text-xl mb-4">Private Unix agent installation</h1>
|
||||
<InfrastructureInstallerSection />
|
||||
<Operations />
|
||||
<Doctor />
|
||||
<TokenRevealDialog />
|
||||
</main>
|
||||
</InfrastructureOperationsStateProvider>
|
||||
);
|
||||
const Doctor = () => {
|
||||
const target = {
|
||||
key: 'agent:doctor-fixture',
|
||||
connectionId: 'agent:doctor-fixture',
|
||||
displayName: 'doctor-fixture',
|
||||
contextLabel: 'Machine',
|
||||
currentVersion: '6.4.5',
|
||||
installFlags: ['--enable-docker'],
|
||||
status: 'critical',
|
||||
reasons: [],
|
||||
evidence: [],
|
||||
needsUpdate: false,
|
||||
needsCredentialRepair: true,
|
||||
commandPlatform: 'linux',
|
||||
safeCollector: false,
|
||||
actionRunnerPosture: [],
|
||||
actionRunnerCredentialEligible: false,
|
||||
source: 'ledger',
|
||||
connection: {
|
||||
id: 'agent:doctor-fixture',
|
||||
type: 'agent',
|
||||
name: 'doctor-fixture',
|
||||
address: 'doctor.example',
|
||||
state: 'unauthorized',
|
||||
agentIdentity: { hostname: 'doctor.example', commandsEnabled: false },
|
||||
},
|
||||
} as InfrastructureAgentDoctorTarget;
|
||||
return <InfrastructureAgentDoctorPage targets={[target]} />;
|
||||
};
|
||||
render(
|
||||
() => (
|
||||
<Router>
|
||||
<Route path="*" component={Fixture} />
|
||||
</Router>
|
||||
),
|
||||
document.getElementById('root')!,
|
||||
);
|
||||
@@ -1,23 +1,27 @@
|
||||
{
|
||||
"version": 1,
|
||||
"base_sha": "6b23876ec8104965c7842e194e055f3206134d4f",
|
||||
"verified_at": "2026-10-01T04:21:56.727078Z",
|
||||
"base_sha": "9189a7262e84b201a5af6649caf0f8cff7c6d144",
|
||||
"verified_at": "2026-10-01T13:06:33.056850Z",
|
||||
"result": "passed",
|
||||
"changed_paths": [
|
||||
"frontend-modern/src/api/nodes.ts",
|
||||
"frontend-modern/src/components/Settings/NodeModalSetupGuideSection.tsx",
|
||||
"frontend-modern/src/components/Settings/useNodeModalState.ts",
|
||||
"frontend-modern/src/stores/tokenReveal.ts"
|
||||
"frontend-modern/src/components/Settings/InfrastructureAgentDoctorPage.tsx",
|
||||
"frontend-modern/src/components/Settings/InfrastructureInstallerSection.tsx",
|
||||
"frontend-modern/src/components/Settings/infrastructureOperationsModel.tsx",
|
||||
"frontend-modern/src/components/Settings/useInfrastructureInstallState.tsx",
|
||||
"frontend-modern/src/components/Settings/useInfrastructureOperationsState.tsx",
|
||||
"frontend-modern/src/utils/agentInstallCommand.ts"
|
||||
],
|
||||
"content_sha256": {
|
||||
"frontend-modern/src/api/nodes.ts": "398f3b87ce192c7650a9e34f184ecdca0e8baf35b57d90ef5f8f697348675fec",
|
||||
"frontend-modern/src/components/Settings/NodeModalSetupGuideSection.tsx": "e4168945744f8ec20b5439f5730b1ecd8b4811b29ec00da4e7e3798f868a1b41",
|
||||
"frontend-modern/src/components/Settings/useNodeModalState.ts": "9372c3a4b346a0e31733a7042c0a65b5ebe3518b12595c6c6a68b1cadd667dcd",
|
||||
"frontend-modern/src/stores/tokenReveal.ts": "c21ac970bde6229506f7d6e627430b6e91c9ea983f705a28e3d149e4415c39b7"
|
||||
"frontend-modern/src/components/Settings/InfrastructureAgentDoctorPage.tsx": "416c7627303c665f956168a32e5aaf6af4268abacdd7e93045ab26cd45732d14",
|
||||
"frontend-modern/src/components/Settings/InfrastructureInstallerSection.tsx": "d2ff336c1e602794fd696aed3541684498adfcfeaf1f4e603e21a44865fce3bb",
|
||||
"frontend-modern/src/components/Settings/infrastructureOperationsModel.tsx": "983a44871cfb908851c215a177e4a39185f7ac9017cc1b2152323b7ee396995a",
|
||||
"frontend-modern/src/components/Settings/useInfrastructureInstallState.tsx": "c57bf5a99d457d6d21f0d22e0cc34af3618db214391fd09afce34743173a1c0f",
|
||||
"frontend-modern/src/components/Settings/useInfrastructureOperationsState.tsx": "276c89076d36670752f5d37be1895d739ae13501ea9d87d1cc3a497c7e6f5895",
|
||||
"frontend-modern/src/utils/agentInstallCommand.ts": "9bb98b1db1b49aa478f894b346479951954899d4c6049d991ec4c75730cafa30"
|
||||
},
|
||||
"routes": [
|
||||
"/browser-tests/proxmox-private-input.html?type=pve",
|
||||
"/browser-tests/proxmox-private-input.html?type=pbs"
|
||||
"/browser-tests/unix-private-install.html",
|
||||
"/browser-tests/unix-private-install.html?optional=1"
|
||||
],
|
||||
"viewports": [
|
||||
{
|
||||
@@ -30,34 +34,29 @@
|
||||
}
|
||||
],
|
||||
"states": [
|
||||
"Actual settings state/setup section and existing token-reveal dialog with production CSS; synthetic server artifacts and no installed bootstrap execution",
|
||||
"Separate command and credential copy for API Inventory and Host Telemetry Agent; raw secrets absent from copied shell text",
|
||||
"PVE/PBS tokenless script download reuses live bootstrap metadata; credential cache clears on close/reopen",
|
||||
"Held setup and agent issuance completed after modal close is discarded: no secret cache and no reveal dialog",
|
||||
"Default TLS verification and monitoring-only agent request (enableCommands=false) unchanged",
|
||||
"Desktop light and phone-emulated WebKit dark token reveal is readable; screenshots visually inspected"
|
||||
"Actual infrastructure installer, operations closures, Agent Doctor and token dialog with production CSS; synthetic APIs, not installed agent/appliance execution",
|
||||
"Required-auth Unix command and credential remain separate; single-line command grammar, private file no-terminal variant and separate-token instructions",
|
||||
"Canonical repair/removal identity and custom CA preserved; saved-state update has no replacement credential; removal has no new-binary preflight",
|
||||
"Optional-auth commands require no terminal or token-file; default TLS verification and monitoring-only issuance unchanged",
|
||||
"Desktop Chromium light and phone-emulated WebKit dark; four final screenshots visually inspected"
|
||||
],
|
||||
"interactions": [
|
||||
"Copy command; copy token via Enter and pointer; Escape and Dismiss close token dialog",
|
||||
"Close/reopen setup; download script; switch API/Agent paths",
|
||||
"Hold setup/agent response, close modal, then release response and verify no late reveal/cache"
|
||||
"Generate scoped host token, copy token with keyboard, Escape, reopen existing reveal without issuing another token",
|
||||
"Copy actual Linux installer, no-terminal private-file installer and Agent Doctor repair commands; raw credential absent from each clipboard result",
|
||||
"Change endpoint and custom CA; copy actual operations-state repair/removal/saved-state update commands; identity/profile/trust checked",
|
||||
"Reload with optional auth and confirm tokenless commands"
|
||||
],
|
||||
"command": "pulse-worker-browser frontend-modern/browser-tests/proxmox-private-input.cjs; pulse-worker-browser frontend-modern/browser-tests/proxmox-private-input.cjs --phone (separate commands from assigned workspace root)",
|
||||
"command": "pulse-worker-browser frontend-modern/browser-tests/unix-private-install.cjs",
|
||||
"browser_versions": {
|
||||
"playwright": "1.56.1",
|
||||
"chromium": "141.0.7390.37",
|
||||
"webkit": "26.0"
|
||||
},
|
||||
"artifacts": [
|
||||
"/var/lib/pulse-maintainer/worker-outputs/core-runtime-yp94gsia/browser-desktop/pbs-agent.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/core-runtime-yp94gsia/browser-desktop/pbs-setup.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/core-runtime-yp94gsia/browser-desktop/pve-agent.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/core-runtime-yp94gsia/browser-desktop/pve-setup.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/core-runtime-yp94gsia/browser-desktop/result.json",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/core-runtime-yp94gsia/browser-phone/pbs-agent.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/core-runtime-yp94gsia/browser-phone/pbs-setup.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/core-runtime-yp94gsia/browser-phone/pve-agent.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/core-runtime-yp94gsia/browser-phone/pve-setup.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/core-runtime-yp94gsia/browser-phone/result.json"
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-wielhxw1/browser/chromium-commands.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-wielhxw1/browser/chromium-token.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-wielhxw1/browser/result.json",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-wielhxw1/browser/webkit-commands.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-wielhxw1/browser/webkit-token.png"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -214,7 +214,7 @@ describe('aiChatUtils', () => {
|
||||
expect(link?.getAttribute('rel')).toBe('noopener noreferrer');
|
||||
});
|
||||
|
||||
it.each(['beforeSanitizeElements', 'uponSanitizeElement'] as const)(
|
||||
it.each(['beforeSanitizeElements', 'uponSanitizeElement', 'afterSanitizeAttributes'] as const)(
|
||||
'neutralizes a hook-detached IN_PLACE subtree through the %s hook',
|
||||
(hookName) => {
|
||||
// Configure the same singleton hooks used by renderMarkdown before
|
||||
@@ -232,8 +232,10 @@ describe('aiChatUtils', () => {
|
||||
};
|
||||
if (hookName === 'beforeSanitizeElements') {
|
||||
DOMPurify.addHook('beforeSanitizeElements', removeSection);
|
||||
} else {
|
||||
} else if (hookName === 'uponSanitizeElement') {
|
||||
DOMPurify.addHook('uponSanitizeElement', removeSection);
|
||||
} else {
|
||||
DOMPurify.addHook('afterSanitizeAttributes', removeSection);
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -253,8 +255,10 @@ describe('aiChatUtils', () => {
|
||||
} finally {
|
||||
if (hookName === 'beforeSanitizeElements') {
|
||||
DOMPurify.removeHook('beforeSanitizeElements', removeSection);
|
||||
} else {
|
||||
} else if (hookName === 'uponSanitizeElement') {
|
||||
DOMPurify.removeHook('uponSanitizeElement', removeSection);
|
||||
} else {
|
||||
DOMPurify.removeHook('afterSanitizeAttributes', removeSection);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -917,6 +917,18 @@ export const InfrastructureAgentDoctorPage: Component<InfrastructureAgentDoctorP
|
||||
<pre class="overflow-x-auto rounded-md bg-base p-3 pr-12 text-xs text-base-content">
|
||||
<code>{command()}</code>
|
||||
</pre>
|
||||
<Show
|
||||
when={
|
||||
commandRequiresToken(target) &&
|
||||
target.commandPlatform !== 'windows'
|
||||
}
|
||||
>
|
||||
<p class="mt-2 text-xs text-muted">
|
||||
Run this command in a console or SSH session first, then
|
||||
paste the separately revealed token at the silent Pulse
|
||||
agent token prompt. Never insert the token into the command.
|
||||
</p>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
@@ -929,7 +941,9 @@ export const InfrastructureAgentDoctorPage: Component<InfrastructureAgentDoctorP
|
||||
This agent was removed from Pulse, but the agent software may
|
||||
still be installed on its host. Finish detaching it by running
|
||||
the uninstall command on the affected host itself. Pulse does
|
||||
not run commands remotely.
|
||||
not run commands remotely. For Unix commands that require a
|
||||
token, run the command first, then paste the separately revealed
|
||||
token at its silent prompt; never insert it into the command.
|
||||
</p>
|
||||
<For each={handoff().commands}>
|
||||
{(entry) => (
|
||||
|
||||
@@ -396,12 +396,13 @@ export const InfrastructureInstallerSection: Component<InfrastructureInstallerSe
|
||||
{state.latestTokenSource() === 'setup_handoff' ? (
|
||||
<>
|
||||
First-host install token <strong>{state.latestRecord()?.name}</strong>{' '}
|
||||
prepared automatically. Commands below already include this credential.
|
||||
prepared automatically. Unix commands request it separately at a silent
|
||||
prompt.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Install token <strong>{state.latestRecord()?.name}</strong> created. Commands
|
||||
below now include this credential.
|
||||
Install token <strong>{state.latestRecord()?.name}</strong> created. Unix
|
||||
commands keep it separate: copy the command first, then use Show token only.
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
@@ -421,8 +422,8 @@ export const InfrastructureInstallerSection: Component<InfrastructureInstallerSe
|
||||
<Show when={!state.requiresToken()}>
|
||||
<div class="space-y-3">
|
||||
<div class="rounded-md border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800 dark:border-amber-700 dark:bg-amber-900 dark:text-amber-200">
|
||||
Tokens are optional on this Pulse instance. Confirm to generate commands without
|
||||
embedding a token.
|
||||
Tokens are optional on this Pulse instance. Confirm to generate commands without a
|
||||
token.
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@@ -449,8 +450,8 @@ export const InfrastructureInstallerSection: Component<InfrastructureInstallerSe
|
||||
Installation commands
|
||||
</h4>
|
||||
<p class="ml-6 text-xs text-muted">
|
||||
Generate an install token first. Pulse will then build copy-ready commands with the
|
||||
credential inserted for the target host.
|
||||
Generate an install token first. Then copy the command for the target host and
|
||||
follow its credential-entry instructions.
|
||||
</p>
|
||||
</div>
|
||||
<div class="grid gap-3 lg:grid-cols-2">
|
||||
@@ -496,9 +497,9 @@ export const InfrastructureInstallerSection: Component<InfrastructureInstallerSe
|
||||
</h4>
|
||||
<p class={`mt-0.5 text-xs text-muted ${state.requiresToken() ? 'ml-6' : ''}`}>
|
||||
Copy the default command for the first host first. It checks this Pulse URL and
|
||||
the matching agent binary before asking for administrator privileges, then
|
||||
installs Pulse Agent as a background service on each machine where you want full
|
||||
node-local telemetry.
|
||||
the matching agent binary before reading a Unix token, then installs Pulse Agent
|
||||
as a background service on each machine where you want full node-local
|
||||
telemetry.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+12
-14
@@ -277,9 +277,7 @@ describe('infrastructure operations model', () => {
|
||||
'Use Availability checks for ping-only or agentless device monitoring.',
|
||||
);
|
||||
expect(infrastructureInstallerSectionSource).toContain('checks this Pulse URL and');
|
||||
expect(infrastructureInstallerSectionSource).toContain(
|
||||
'before asking for administrator privileges',
|
||||
);
|
||||
expect(infrastructureInstallerSectionSource).toContain('before reading a Unix token');
|
||||
expect(infrastructureInstallerSectionSource).toContain(
|
||||
'For Proxmox, start with a dedicated read-only or narrowly scoped API token',
|
||||
);
|
||||
@@ -287,7 +285,11 @@ describe('infrastructure operations model', () => {
|
||||
expect(infrastructureInstallerSectionSource).toContain('augmentation.');
|
||||
expect(infrastructureInstallerSectionSource).toContain('Installation commands');
|
||||
expect(infrastructureInstallerSectionSource).toContain(
|
||||
'Generate an install token first. Pulse will then build copy-ready commands',
|
||||
'Generate an install token first. Then copy the command for the target host and',
|
||||
);
|
||||
expect(infrastructureInstallerSectionSource).toContain('follow its credential-entry instructions.');
|
||||
expect(infrastructureInstallerSectionSource).toContain(
|
||||
'commands keep it separate: copy the command first, then use Show token only.',
|
||||
);
|
||||
expect(infrastructureInstallerSectionSource).toContain(
|
||||
'Install the transitional combined runtime that can accept server command requests',
|
||||
@@ -472,13 +474,9 @@ describe('infrastructure operations model', () => {
|
||||
expect(agentUpgradeStart).toBeGreaterThanOrEqual(0);
|
||||
expect(agentUpgradeEnd).toBeGreaterThan(agentUpgradeStart);
|
||||
const agentUpgradeSource = operationsStateSource.slice(agentUpgradeStart, agentUpgradeEnd);
|
||||
const unixUpgradeStart = agentUpgradeSource.indexOf('let command = `curl');
|
||||
expect(unixUpgradeStart).toBeGreaterThanOrEqual(0);
|
||||
const unixUpgradeSource = agentUpgradeSource.slice(unixUpgradeStart);
|
||||
|
||||
expect(agentUpgradeSource).toContain(
|
||||
'| bash -s -- --update --url ${shellQuoteArg(url)} --non-interactive',
|
||||
);
|
||||
expect(agentUpgradeSource).toContain("const extraArgs = ['--update', ...installFlags];");
|
||||
expect(agentUpgradeSource).toContain('token: replaceCredential ? token : null,');
|
||||
expect(agentUpgradeSource).toContain('buildUnixAgentInstallCommand({');
|
||||
const requiresTokenEnd = operationsStateSource.indexOf('return {', agentUpgradeEnd);
|
||||
expect(requiresTokenEnd).toBeGreaterThan(agentUpgradeEnd);
|
||||
const requiresTokenSource = operationsStateSource.slice(agentUpgradeEnd, requiresTokenEnd);
|
||||
@@ -487,9 +485,9 @@ describe('infrastructure operations model', () => {
|
||||
);
|
||||
expect(requiresTokenSource).toContain("=== 'windows'");
|
||||
expect(requiresTokenSource).toContain('installState.requiresToken()');
|
||||
expect(unixUpgradeSource).not.toContain('command += ` --token ${shellQuoteArg(token)}`;');
|
||||
expect(unixUpgradeSource).not.toContain('--agent-id');
|
||||
expect(unixUpgradeSource).not.toContain('--hostname');
|
||||
expect(agentUpgradeSource).not.toContain('command += ` --token ${shellQuoteArg(token)}`;');
|
||||
// Identity overrides remain inside the explicit credential-replacement branch.
|
||||
expect(agentUpgradeSource).toContain('if (replaceCredential) {');
|
||||
});
|
||||
|
||||
it('resolves connection upgrade platforms through the shared caption-tolerant resolver', async () => {
|
||||
|
||||
+79
-3
@@ -1,4 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { unixBootstrapTestSource } from '@/utils/__tests__/unixBootstrapTestSource';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { useInfrastructureOperationsState } from '../useInfrastructureOperationsState';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { AgentFleetAgentDiagnostic } from '@/api/agentDiagnostics';
|
||||
import type { Connection } from '@/api/connections';
|
||||
import type { InfrastructureSystemRow } from '../connectionsTableModel';
|
||||
@@ -390,8 +393,8 @@ describe('Agent Doctor model', () => {
|
||||
expect(install).toContain('--enable-privileged-helper');
|
||||
expect(install).toContain('--enable-action-runner');
|
||||
expect(install).toContain('--action-token-file');
|
||||
expect(install).toContain("--agent-id 'host-1'");
|
||||
expect(install).toContain("--hostname 'host-1.local'");
|
||||
expect(unixBootstrapTestSource(install)).toContain("--agent-id 'host-1'");
|
||||
expect(unixBootstrapTestSource(install)).toContain("--hostname 'host-1.local'");
|
||||
expect(install).toContain("--cacert '/etc/pulse/ca.pem'");
|
||||
expect(install).not.toContain(secret);
|
||||
expect(install).not.toContain('--action-token ');
|
||||
@@ -1097,3 +1100,76 @@ describe('Agent Doctor model', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Exercise the real operations closures, not a hand-written command fixture.
|
||||
// Minting/state storage are outside this narrow presentation/transport proof.
|
||||
const privateInstallState = vi.hoisted(() => ({ token: 'issued-secret-not-shell-source' }));
|
||||
vi.mock('../useInfrastructureInstallState', () => ({
|
||||
useInfrastructureInstallState: () => ({
|
||||
currentToken: () => privateInstallState.token,
|
||||
requiresToken: () => true,
|
||||
selectedAgentUrl: () => 'https://pulse.example/base',
|
||||
insecureMode: () => false,
|
||||
customCaPath: () => '/etc/pulse/ca.pem',
|
||||
enableCommands: () => false,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('Agent Doctor Unix credential handoffs', () => {
|
||||
it('keeps repair/uninstall secrets separate while preserving identity, profile and custom trust', () => {
|
||||
const operations = useInfrastructureOperationsState();
|
||||
const connection = connectionFixture({
|
||||
id: 'agent:canonical-id-42',
|
||||
agentIdentity: { hostname: 'canonical.example', commandsEnabled: false },
|
||||
state: 'unauthorized',
|
||||
});
|
||||
const repair = operations.getAgentConnectionUpgradeCommand(
|
||||
connection,
|
||||
['--enable-docker'],
|
||||
'linux',
|
||||
true,
|
||||
);
|
||||
const uninstall = operations.getUninstallCommand({
|
||||
agentActionId: 'canonical-id-42',
|
||||
agentId: 'old-alias',
|
||||
hostname: 'canonical.example',
|
||||
});
|
||||
for (const command of [repair, uninstall]) {
|
||||
expect(command).not.toContain(privateInstallState.token);
|
||||
expect(command).not.toContain('old-alias');
|
||||
expect(command).not.toContain('| bash');
|
||||
expect(command).toContain('--token-file "$token_file"');
|
||||
expect(command).toContain('--agent-id');
|
||||
expect(command).toContain('canonical-id-42');
|
||||
expect(command).toContain('--hostname');
|
||||
expect(command).toContain('canonical.example');
|
||||
expect(command).toContain('--cacert');
|
||||
expect(command).not.toContain('--insecure');
|
||||
expect(command).not.toContain('--enable-commands');
|
||||
execFileSync('sh', ['-n', '-c', command]);
|
||||
}
|
||||
expect(repair).toContain('--update');
|
||||
expect(repair).toContain('--enable-docker');
|
||||
expect(repair).toContain('--preflight-only');
|
||||
expect(uninstall).toContain('--uninstall');
|
||||
expect(uninstall).not.toContain('--preflight-only');
|
||||
});
|
||||
|
||||
it('does not mint, inject or prompt for another credential during a saved-state update', () => {
|
||||
const operations = useInfrastructureOperationsState();
|
||||
const command = operations.getAgentConnectionUpgradeCommand(
|
||||
connectionFixture(),
|
||||
['--enable-docker'],
|
||||
'linux',
|
||||
);
|
||||
expect(command).toContain('--update');
|
||||
expect(command).toContain('--enable-docker');
|
||||
expect(command).toContain('--preflight-only');
|
||||
expect(command).not.toContain('--token-file');
|
||||
expect(command).not.toContain('read -r');
|
||||
expect(command).not.toContain(privateInstallState.token);
|
||||
expect(
|
||||
operations.getAgentConnectionUpgradeCommandRequiresToken(connectionFixture(), 'linux'),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
+28
-28
@@ -1,3 +1,4 @@
|
||||
import { unixBootstrapTestSource } from '@/utils/__tests__/unixBootstrapTestSource';
|
||||
import { createRoot } from 'solid-js';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { Connection } from '@/api/connections';
|
||||
@@ -159,51 +160,47 @@ describe('useInfrastructureOperationsState command-building closures', () => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
describe('withPrivilegeEscalation (via getUninstallCommand)', () => {
|
||||
it('wraps a "| bash -s --" command in the root/sudo escalation block verbatim', async () => {
|
||||
describe('shared Unix bootstrap (via getUninstallCommand)', () => {
|
||||
it('uses the complete-download/preflight/privileged private-input path', async () => {
|
||||
const { state, dispose } = mountHook();
|
||||
await flushAsync();
|
||||
state.setCustomAgentUrl(HTTPS_URL);
|
||||
|
||||
const cmd = state.getUninstallCommand();
|
||||
|
||||
expect(cmd).toContain('| { if [ "$(id -u)" -eq 0 ]; then bash -s -- --uninstall --url');
|
||||
expect(cmd).toContain(
|
||||
'; elif command -v sudo >/dev/null 2>&1; then sudo bash -s -- --uninstall --url',
|
||||
);
|
||||
expect(cmd).toContain(
|
||||
'; else echo "Root privileges required. Run as root (su -) and retry." >&2; exit 1; fi; }',
|
||||
);
|
||||
// The original pipe form is consumed by the replacement.
|
||||
expect(cmd).not.toMatch(/^\S+ \| bash -s --/);
|
||||
expect(cmd).toContain('--uninstall');
|
||||
expect(cmd).toContain('sudo bash -c');
|
||||
expect(cmd).not.toContain('--preflight-only');
|
||||
expect(cmd).not.toContain('| bash');
|
||||
expect(cmd).not.toContain("--token '");
|
||||
dispose();
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolvedCommandToken (via getUninstallCommand)', () => {
|
||||
it('substitutes the api-token placeholder when a token is required but absent', async () => {
|
||||
it('requests private input without copying a placeholder when a token is required', async () => {
|
||||
const { state, dispose } = mountHook();
|
||||
await flushAsync();
|
||||
state.setCustomAgentUrl(HTTPS_URL);
|
||||
|
||||
expect(state.getUninstallCommand()).toContain("--token '<api-token>'");
|
||||
expect(state.getUninstallCommand()).toContain('--token-file "$token_file"');
|
||||
expect(state.getUninstallCommand()).not.toContain('<api-token>');
|
||||
dispose();
|
||||
});
|
||||
|
||||
it('uses the minted token verbatim when one is present and required', async () => {
|
||||
it('keeps the minted token separate when one is present and required', async () => {
|
||||
const { state, dispose } = mountHook();
|
||||
await flushAsync();
|
||||
state.setCustomAgentUrl(HTTPS_URL);
|
||||
await state.handleGenerateToken();
|
||||
|
||||
const cmd = state.getUninstallCommand();
|
||||
expect(cmd).toContain("--token 'tok-1'");
|
||||
expect(cmd).toContain('--token-file "$token_file"');
|
||||
expect(cmd).not.toContain('tok-1');
|
||||
expect(cmd).not.toContain('<api-token>');
|
||||
expect(mocks.showTokenReveal).toHaveBeenCalledWith({
|
||||
token: 'tok-1',
|
||||
record: expect.objectContaining({ id: 'rec-1', name: 'Agent' }),
|
||||
source: 'agent',
|
||||
note: expect.stringContaining('PULSE_TOKEN or Compose environment configuration'),
|
||||
note: expect.stringContaining('silent “Pulse agent token” prompt'),
|
||||
});
|
||||
dispose();
|
||||
});
|
||||
@@ -221,7 +218,7 @@ describe('useInfrastructureOperationsState command-building closures', () => {
|
||||
token: 'tok-1',
|
||||
record: expect.objectContaining({ id: 'rec-1', name: 'Agent' }),
|
||||
source: 'agent',
|
||||
note: expect.stringContaining('PULSE_TOKEN or Compose environment configuration'),
|
||||
note: expect.stringContaining('silent “Pulse agent token” prompt'),
|
||||
});
|
||||
dispose();
|
||||
});
|
||||
@@ -239,7 +236,9 @@ describe('useInfrastructureOperationsState command-building closures', () => {
|
||||
const cmd = state.getUninstallCommand();
|
||||
expect(cmd).not.toContain('--token');
|
||||
expect(cmd).not.toContain('<api-token>');
|
||||
expect(cmd).toContain("--uninstall --url 'https://pulse.test'");
|
||||
expect(cmd).toContain('--uninstall');
|
||||
expect(cmd).toContain('--url');
|
||||
expect(cmd).toContain('https://pulse.test');
|
||||
dispose();
|
||||
});
|
||||
});
|
||||
@@ -310,8 +309,8 @@ describe('useInfrastructureOperationsState command-building closures', () => {
|
||||
};
|
||||
|
||||
const cmd = state.getUninstallCommand(row);
|
||||
expect(cmd).toContain("--agent-id 'act-9'");
|
||||
expect(cmd).toContain("--hostname 'host-9'");
|
||||
expect(unixBootstrapTestSource(cmd)).toContain("--agent-id 'act-9'");
|
||||
expect(unixBootstrapTestSource(cmd)).toContain("--hostname 'host-9'");
|
||||
expect(cmd).not.toContain('agent-9');
|
||||
dispose();
|
||||
});
|
||||
@@ -323,8 +322,8 @@ describe('useInfrastructureOperationsState command-building closures', () => {
|
||||
const row: AgentUninstallIdentity = { agentId: 'agent-7', hostname: 'host-7' };
|
||||
|
||||
const cmd = state.getUninstallCommand(row);
|
||||
expect(cmd).toContain("--agent-id 'agent-7'");
|
||||
expect(cmd).toContain("--hostname 'host-7'");
|
||||
expect(unixBootstrapTestSource(cmd)).toContain("--agent-id 'agent-7'");
|
||||
expect(unixBootstrapTestSource(cmd)).toContain("--hostname 'host-7'");
|
||||
dispose();
|
||||
});
|
||||
|
||||
@@ -633,8 +632,8 @@ describe('useInfrastructureOperationsState command-building closures', () => {
|
||||
true,
|
||||
);
|
||||
|
||||
expect(cmd).toContain('tmp_dir=$(mktemp -d)');
|
||||
expect(cmd).toContain('printf %s \'tok-1\' > "$token_file"');
|
||||
expect(cmd).toContain('bootstrap_dir=$(mktemp -d /tmp/pulse-bootstrap.XXXXXX)');
|
||||
expect(cmd).not.toContain('tok-1');
|
||||
expect(cmd).toContain('--token-file "$token_file"');
|
||||
expect(cmd).toContain('--update');
|
||||
expect(cmd).toContain('--enable-proxmox');
|
||||
@@ -651,9 +650,10 @@ describe('useInfrastructureOperationsState command-building closures', () => {
|
||||
|
||||
const cmd = state.getAgentConnectionUpgradeCommand(baseConnection, [], 'linux');
|
||||
|
||||
expect(cmd).toContain('| { if [ "$(id -u)" -eq 0 ]; then bash -s -- --update');
|
||||
expect(cmd).toContain('--update');
|
||||
expect(cmd).toContain('sudo bash -c');
|
||||
expect(cmd).not.toContain('--token-file');
|
||||
expect(cmd).not.toContain('mktemp -d');
|
||||
expect(cmd).not.toContain('token_dir=');
|
||||
dispose();
|
||||
});
|
||||
|
||||
|
||||
@@ -333,6 +333,7 @@ export const buildCommandsByPlatform = (
|
||||
unixCommand: string,
|
||||
windowsInteractiveCommand: string,
|
||||
windowsParameterizedCommand: string,
|
||||
unixPrivateFileCommand?: string,
|
||||
): Record<AgentPlatform, InfrastructureCommandSection> => ({
|
||||
linux: {
|
||||
title: 'Install on Linux',
|
||||
@@ -345,8 +346,9 @@ export const buildCommandsByPlatform = (
|
||||
note: (
|
||||
<span>
|
||||
Command auto-escalates with <code>sudo</code> when available. Otherwise run from a root
|
||||
shell (for example <code>su -</code>). Auto-detects your init system and works on
|
||||
Debian, Ubuntu, Proxmox, Fedora, Alpine, Unraid, Synology, and more.
|
||||
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.
|
||||
</span>
|
||||
),
|
||||
},
|
||||
@@ -363,8 +365,10 @@ export const buildCommandsByPlatform = (
|
||||
note: (
|
||||
<span>
|
||||
Command auto-escalates with <code>sudo</code> when available, so macOS may ask for your
|
||||
admin password. Creates <code>/Library/LaunchDaemons/com.pulse.agent.plist</code> and
|
||||
starts the agent automatically.
|
||||
admin password. If a token is required, run this command first, then paste the
|
||||
separately revealed token at its silent prompt. Creates{' '}
|
||||
<code>/Library/LaunchDaemons/com.pulse.agent.plist</code> and starts the agent
|
||||
automatically.
|
||||
</span>
|
||||
),
|
||||
},
|
||||
@@ -380,12 +384,33 @@ export const buildCommandsByPlatform = (
|
||||
command: unixCommand,
|
||||
note: (
|
||||
<span>
|
||||
Run as root. <strong>Note:</strong> pfSense/OPNsense don't include bash by default.
|
||||
Install it first: <code>pkg install bash</code>. Creates{' '}
|
||||
Run in a console or SSH session as root. Paste the separately revealed token only at the
|
||||
silent prompt, not into the command. For GUI command fields without a terminal, use the
|
||||
private-file command below. <strong>Note:</strong> pfSense/OPNsense don't include bash
|
||||
by default. Install it first: <code>pkg install bash</code>. Creates{' '}
|
||||
<code>/usr/local/etc/rc.d/pulse-agent</code> and starts the agent automatically.
|
||||
</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>
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
},
|
||||
windows: {
|
||||
|
||||
@@ -47,7 +47,7 @@ const SETUP_HANDOFF_INSTALL_STEPS = new Set<InfrastructurePanelStep>([
|
||||
'kubernetes',
|
||||
]);
|
||||
const INSTALL_TOKEN_REVEAL_NOTE =
|
||||
'Copy just this token for PULSE_TOKEN or Compose environment configuration. The generated install commands remain available after you close this dialog.';
|
||||
'For Unix installation, run the copied command first, then paste this token only at the silent “Pulse agent token” prompt. The no-terminal command reads the private token file named beside it instead. Never insert the token into a Unix command. For Windows or container configuration, keep copied credentials private.';
|
||||
|
||||
const isActiveInfrastructureItem = (item: ConnectedInfrastructureItem) => item.status === 'active';
|
||||
|
||||
@@ -568,6 +568,15 @@ Pulse prepares the first-host install token from setup so you can move straight
|
||||
unixCommand,
|
||||
windowsInteractiveCommand,
|
||||
windowsParameterizedCommand,
|
||||
token
|
||||
? buildUnixAgentInstallCommand({
|
||||
baseUrl: url,
|
||||
tokenFilePath: '/root/.config/pulse-agent/bootstrap-token',
|
||||
insecure: insecureMode(),
|
||||
caCertPath: selectedCustomCaPath(),
|
||||
extraArgs: getInstallerExtraArgs(),
|
||||
})
|
||||
: undefined,
|
||||
);
|
||||
return Object.entries(commands).map(([platform, meta]) => ({
|
||||
platform: platform as AgentPlatform,
|
||||
|
||||
@@ -33,26 +33,7 @@ export const useInfrastructureOperationsState = (
|
||||
) => {
|
||||
const installState = useInfrastructureInstallState(options);
|
||||
|
||||
const withPrivilegeEscalation = (command: string) => {
|
||||
if (!command.includes('| bash -s --')) return command;
|
||||
return command.replace(/\|\s*bash -s --([\s\S]*)$/, (_match, args: string) => {
|
||||
return `| { if [ "$(id -u)" -eq 0 ]; then bash -s --${args}; elif command -v sudo >/dev/null 2>&1; then sudo bash -s --${args}; else echo "Root privileges required. Run as root (su -) and retry." >&2; exit 1; fi; }`;
|
||||
});
|
||||
};
|
||||
|
||||
const selectedCustomCaPath = () => installState.customCaPath().trim();
|
||||
const urlRequiresInstallerInsecure = (url: string) =>
|
||||
installState.insecureMode() || url.trim().toLowerCase().startsWith('http://');
|
||||
const getInsecureFlag = (url: string) => (urlRequiresInstallerInsecure(url) ? ' --insecure' : '');
|
||||
const getCurlFlags = () => (installState.insecureMode() ? '-kfsSL' : '-fsSL');
|
||||
const getShellCustomCaCurlFlag = () => {
|
||||
const caPath = selectedCustomCaPath();
|
||||
return caPath ? ` --cacert ${shellQuoteArg(caPath)}` : '';
|
||||
};
|
||||
const getShellCustomCaInstallerFlag = () => {
|
||||
const caPath = selectedCustomCaPath();
|
||||
return caPath ? ` --cacert ${shellQuoteArg(caPath)}` : '';
|
||||
};
|
||||
const getPowerShellTransportEnv = () => {
|
||||
const envAssignments: string[] = [];
|
||||
if (installState.insecureMode()) {
|
||||
@@ -99,16 +80,19 @@ export const useInfrastructureOperationsState = (
|
||||
const getUninstallCommand = (row?: AgentUninstallIdentity) => {
|
||||
const url = installState.selectedAgentUrl();
|
||||
const token = resolvedCommandToken();
|
||||
const insecure = getInsecureFlag(url);
|
||||
const agentId = getCanonicalUninstallAgentId(row);
|
||||
const hostname = getCanonicalUninstallHostname(row);
|
||||
const baseArgs = token
|
||||
? `--uninstall --url ${shellQuoteArg(url)} --token ${shellQuoteArg(token)}${insecure}${getShellCustomCaInstallerFlag()}`
|
||||
: `--uninstall --url ${shellQuoteArg(url)}${insecure}${getShellCustomCaInstallerFlag()}`;
|
||||
const identityArgs = `${agentId ? ` --agent-id ${shellQuoteArg(agentId)}` : ''}${hostname ? ` --hostname ${shellQuoteArg(hostname)}` : ''}`;
|
||||
return withPrivilegeEscalation(
|
||||
`curl ${getCurlFlags()}${getShellCustomCaCurlFlag()} ${shellQuoteArg(`${url}/install.sh`)} | bash -s -- ${baseArgs}${identityArgs}`,
|
||||
);
|
||||
return buildUnixAgentInstallCommand({
|
||||
baseUrl: url,
|
||||
token,
|
||||
insecure: installState.insecureMode(),
|
||||
caCertPath: selectedCustomCaPath(),
|
||||
extraArgs: [
|
||||
'--uninstall',
|
||||
...(agentId ? [`--agent-id ${shellQuoteArg(agentId)}`] : []),
|
||||
...(hostname ? [`--hostname ${shellQuoteArg(hostname)}`] : []),
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
const getWindowsUninstallCommand = (row?: AgentUninstallIdentity) => {
|
||||
@@ -163,24 +147,17 @@ export const useInfrastructureOperationsState = (
|
||||
extraEnvAssignments: envAssignments,
|
||||
});
|
||||
}
|
||||
let command = `curl ${getCurlFlags()}${getShellCustomCaCurlFlag()} ${shellQuoteArg(`${url}/install.sh`)} | bash -s -- --url ${shellQuoteArg(url)}`;
|
||||
if (token) {
|
||||
command += ` --token ${shellQuoteArg(token)}`;
|
||||
}
|
||||
if (row.installFlags.length > 0) {
|
||||
command += ` ${row.installFlags.join(' ')}`;
|
||||
}
|
||||
if (urlRequiresInstallerInsecure(url)) {
|
||||
command += getInsecureFlag(url);
|
||||
}
|
||||
command += getShellCustomCaInstallerFlag();
|
||||
if (agentId) {
|
||||
command += ` --agent-id ${shellQuoteArg(agentId)}`;
|
||||
}
|
||||
if (hostname) {
|
||||
command += ` --hostname ${shellQuoteArg(hostname)}`;
|
||||
}
|
||||
return withPrivilegeEscalation(command);
|
||||
return buildUnixAgentInstallCommand({
|
||||
baseUrl: url,
|
||||
token,
|
||||
insecure: installState.insecureMode(),
|
||||
caCertPath: selectedCustomCaPath(),
|
||||
extraArgs: [
|
||||
...row.installFlags,
|
||||
...(agentId ? [`--agent-id ${shellQuoteArg(agentId)}`] : []),
|
||||
...(hostname ? [`--hostname ${shellQuoteArg(hostname)}`] : []),
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
const getAgentConnectionUpgradeCommand = (
|
||||
@@ -218,41 +195,23 @@ export const useInfrastructureOperationsState = (
|
||||
});
|
||||
}
|
||||
|
||||
if (replaceCredential) {
|
||||
const extraArgs = ['--update', ...installFlags];
|
||||
if (commandsEnabled || installState.enableCommands()) {
|
||||
extraArgs.push('--enable-commands');
|
||||
}
|
||||
// Pin the existing identity the way the Windows path does. A repair
|
||||
// reinstall without it can register a fresh suffixed agent identity
|
||||
// for the same machine instead of converging on this one.
|
||||
if (agentId) {
|
||||
extraArgs.push(`--agent-id ${shellQuoteArg(agentId)}`);
|
||||
}
|
||||
if (hostname) {
|
||||
extraArgs.push(`--hostname ${shellQuoteArg(hostname)}`);
|
||||
}
|
||||
return buildUnixAgentInstallCommand({
|
||||
baseUrl: url,
|
||||
token,
|
||||
insecure: installState.insecureMode(),
|
||||
caCertPath: selectedCustomCaPath(),
|
||||
extraArgs,
|
||||
});
|
||||
}
|
||||
|
||||
let command = `curl ${getCurlFlags()}${getShellCustomCaCurlFlag()} ${shellQuoteArg(`${url}/install.sh`)} | bash -s -- --update --url ${shellQuoteArg(url)} --non-interactive`;
|
||||
if (installFlags.length > 0) {
|
||||
command += ` ${installFlags.join(' ')}`;
|
||||
}
|
||||
const extraArgs = ['--update', ...installFlags];
|
||||
if (commandsEnabled || installState.enableCommands()) {
|
||||
command += ' --enable-commands';
|
||||
extraArgs.push('--enable-commands');
|
||||
}
|
||||
if (urlRequiresInstallerInsecure(url)) {
|
||||
command += getInsecureFlag(url);
|
||||
if (replaceCredential) {
|
||||
// Credential replacement pins the existing identity; ordinary updates
|
||||
// recover saved identity and credentials through the installer's route.
|
||||
if (agentId) extraArgs.push(`--agent-id ${shellQuoteArg(agentId)}`);
|
||||
if (hostname) extraArgs.push(`--hostname ${shellQuoteArg(hostname)}`);
|
||||
}
|
||||
command += getShellCustomCaInstallerFlag();
|
||||
return withPrivilegeEscalation(command);
|
||||
return buildUnixAgentInstallCommand({
|
||||
baseUrl: url,
|
||||
token: replaceCredential ? token : null,
|
||||
insecure: installState.insecureMode(),
|
||||
caCertPath: selectedCustomCaPath(),
|
||||
extraArgs,
|
||||
});
|
||||
};
|
||||
|
||||
const getAgentConnectionUpgradeCommandRequiresToken = (
|
||||
|
||||
@@ -145,7 +145,7 @@ describe('systemSettings store', () => {
|
||||
expect(configurationDoc).toContain('PULSE_TELEMETRY');
|
||||
});
|
||||
|
||||
it('keeps existing mobile pairing security guidance aligned with retirement', () => {
|
||||
it('keeps existing mobile pairing security without offering retired Relay', () => {
|
||||
const securityDoc = readFileSync(path.join(repoRoot, 'SECURITY.md'), 'utf8');
|
||||
const publicSecurityDoc = readFileSync(
|
||||
path.join(frontendRoot, 'public', 'docs', 'SECURITY.md'),
|
||||
@@ -153,14 +153,17 @@ describe('systemSettings store', () => {
|
||||
);
|
||||
|
||||
for (const copy of [securityDoc, publicSecurityDoc]) {
|
||||
expect(copy).toContain('Existing Mobile Pairings (Retirement)');
|
||||
expect(copy).toContain('31 March 2027');
|
||||
expect(copy).toContain('### Existing Mobile Pairings (Retirement)');
|
||||
expect(copy).toContain('Pulse Mobile and Relay retire on **31 March 2027**');
|
||||
expect(copy).toContain('Relay is no longer sold');
|
||||
expect(copy).toContain('receive Pro features at their current price');
|
||||
expect(copy).toContain('Relay connects the app, not the');
|
||||
expect(copy).toContain('Paired-app access remains license-gated until retirement');
|
||||
expect(copy).toMatch(/Relay connects the app, not the\s+web UI/);
|
||||
expect(copy).not.toContain('Relay Security (Relay and Above)');
|
||||
expect(copy).not.toContain('Relay Security (Pro)');
|
||||
expect(copy).not.toContain('Relay functionality requires a Pro or Cloud license');
|
||||
}
|
||||
expect(publicSecurityDoc).toBe(securityDoc);
|
||||
});
|
||||
|
||||
it('documents self-hosted AI provider transport and resource-policy redaction in the privacy doc', () => {
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { execFileSync, spawnSync } from 'node:child_process';
|
||||
import {
|
||||
chmodSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
symlinkSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { join } from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
buildUnixAgentInstallCommand,
|
||||
@@ -20,8 +29,8 @@ describe('agentInstallCommand', () => {
|
||||
|
||||
expect(command).toContain("--url 'http://pulse.example:7655'");
|
||||
expect(command).toContain('token_dir=$(mktemp -d /tmp/pulse-agent-bootstrap.XXXXXX)');
|
||||
expect(command).toContain('token_dir=$(sudo mktemp -d /tmp/pulse-agent-bootstrap.XXXXXX)');
|
||||
expect(command).toContain('printf %s \'token-123\' | sudo tee "$token_file" >/dev/null');
|
||||
expect(command).toContain('sudo bash -c');
|
||||
expect(command).not.toContain('token-123');
|
||||
expect(command).toContain('--token-file "$token_file"');
|
||||
expect(command).toContain('--insecure');
|
||||
});
|
||||
@@ -36,8 +45,8 @@ describe('agentInstallCommand', () => {
|
||||
"curl -fsSL 'https://pulse.example/base path/agent'\"'\"'s/install.sh' -o \"$install_script\"",
|
||||
);
|
||||
expect(command).toContain("--url 'https://pulse.example/base path/agent'\"'\"'s'");
|
||||
expect(command).toContain("printf %s 'tok'\"'\"'en' > \"$token_file\"");
|
||||
expect(command).toContain("printf %s 'tok'\"'\"'en' | sudo tee \"$token_file\" >/dev/null");
|
||||
expect(command).not.toContain("tok'en");
|
||||
expect(command).not.toContain("tok'\"'\"'en");
|
||||
expect(command).toContain('--token-file "$token_file"');
|
||||
expect(command).not.toContain("--token 'tok");
|
||||
});
|
||||
@@ -49,103 +58,20 @@ describe('agentInstallCommand', () => {
|
||||
});
|
||||
|
||||
const preflightIndex = command.indexOf('--preflight-only');
|
||||
const sudoIndex = command.indexOf('sudo bash "$install_script"');
|
||||
const sudoIndex = command.indexOf('sudo bash -c');
|
||||
|
||||
expect(command).toContain('tmp_dir=$(mktemp -d)');
|
||||
expect(command).toContain('trap cleanup EXIT HUP INT TERM');
|
||||
expect(command).toContain('bootstrap_dir=$(mktemp -d /tmp/pulse-bootstrap.XXXXXX)');
|
||||
expect(command).toContain('trap cleanup EXIT');
|
||||
expect(command).toContain('bash "$install_script" --url');
|
||||
expect(command).toContain('--output json');
|
||||
expect(command).toContain('--non-interactive');
|
||||
expect(preflightIndex).toBeGreaterThan(-1);
|
||||
expect(sudoIndex).toBeGreaterThan(preflightIndex);
|
||||
expect(
|
||||
command.indexOf('token_dir=$(sudo mktemp -d /tmp/pulse-agent-bootstrap.XXXXXX)'),
|
||||
command.indexOf('token_dir=$(mktemp -d /tmp/pulse-agent-bootstrap.XXXXXX)'),
|
||||
).toBeGreaterThan(preflightIndex);
|
||||
});
|
||||
|
||||
it('executes root and sudo token bootstraps from trusted private directories', () => {
|
||||
if (process.platform === 'win32') return;
|
||||
|
||||
const fixtureDir = mkdtempSync(join(tmpdir(), 'pulse-install-command-'));
|
||||
try {
|
||||
const binDir = join(fixtureDir, 'bin');
|
||||
const installer = join(fixtureDir, 'installer.sh');
|
||||
const capture = join(fixtureDir, 'captured-token');
|
||||
execFileSync('mkdir', ['-p', binDir]);
|
||||
writeFileSync(
|
||||
installer,
|
||||
`#!/usr/bin/env bash
|
||||
set -e
|
||||
token_file=""
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
--preflight-only) exit 0 ;;
|
||||
--token-file) token_file="$2"; shift 2 ;;
|
||||
*) shift ;;
|
||||
esac
|
||||
done
|
||||
[ -n "$token_file" ]
|
||||
[ "$(stat -c %a "$token_file" 2>/dev/null || stat -f %Lp "$token_file")" = "600" ]
|
||||
parent_dir=$(dirname "$token_file")
|
||||
[ "$(stat -c %a "$parent_dir" 2>/dev/null || stat -f %Lp "$parent_dir")" = "700" ]
|
||||
[ "$(stat -c %u "$token_file" 2>/dev/null || stat -f %u "$token_file")" = "$(stat -c %u "$parent_dir" 2>/dev/null || stat -f %u "$parent_dir")" ]
|
||||
cat "$token_file" > "$FAKE_CAPTURE"
|
||||
`,
|
||||
);
|
||||
chmodSync(installer, 0o700);
|
||||
writeFileSync(
|
||||
join(binDir, 'curl'),
|
||||
`#!/bin/sh
|
||||
output=""
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
-o) output="$2"; shift 2 ;;
|
||||
*) shift ;;
|
||||
esac
|
||||
done
|
||||
cp "$FAKE_INSTALLER" "$output"
|
||||
`,
|
||||
);
|
||||
writeFileSync(
|
||||
join(binDir, 'sudo'),
|
||||
`#!/bin/sh
|
||||
exec "$@"
|
||||
`,
|
||||
);
|
||||
writeFileSync(
|
||||
join(binDir, 'id'),
|
||||
`#!/bin/sh
|
||||
if [ "$1" = "-u" ]; then
|
||||
printf '%s\n' "$FAKE_ID_UID"
|
||||
else
|
||||
exec /usr/bin/id "$@"
|
||||
fi
|
||||
`,
|
||||
);
|
||||
for (const name of ['curl', 'sudo', 'id']) chmodSync(join(binDir, name), 0o700);
|
||||
|
||||
const command = buildUnixAgentInstallCommand({
|
||||
baseUrl: 'https://pulse.example',
|
||||
token: 'token-123',
|
||||
});
|
||||
for (const fakeUID of ['0', '1000']) {
|
||||
rmSync(capture, { force: true });
|
||||
execFileSync('bash', ['-c', command], {
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: `${binDir}:${process.env.PATH || ''}`,
|
||||
FAKE_CAPTURE: capture,
|
||||
FAKE_ID_UID: fakeUID,
|
||||
FAKE_INSTALLER: installer,
|
||||
},
|
||||
});
|
||||
expect(readFileSync(capture, 'utf8')).toBe('token-123');
|
||||
}
|
||||
} finally {
|
||||
rmSync(fixtureDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('normalizes trailing slashes before building installer transport', () => {
|
||||
expect(normalizeInstallerBaseUrl('https://pulse.example/base///')).toBe(
|
||||
'https://pulse.example/base',
|
||||
@@ -422,100 +348,392 @@ describe('single-line Unix install commands', () => {
|
||||
|
||||
it.each([
|
||||
{ baseUrl: 'https://pulse.invalid/a\nb' },
|
||||
{ baseUrl: 'https://pulse.invalid', token: 'a\rb' },
|
||||
{ baseUrl: 'https://pulse.invalid', tokenFilePath: '/tmp/a\rb' },
|
||||
{ baseUrl: 'https://pulse.invalid', caCertPath: '/tmp/a\nb' },
|
||||
{ baseUrl: 'https://pulse.invalid', extraArgs: ['--a\n--b'] },
|
||||
])('rejects embedded line breaks without silently changing values: %j', (options) => {
|
||||
expect(() => buildUnixAgentInstallCommand(options)).toThrow('must not contain line breaks');
|
||||
});
|
||||
|
||||
it.each(['sh', 'bash'])(
|
||||
'preserves execution and failure ordering under %s after paste',
|
||||
(shell) => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'pulse-single-line-'));
|
||||
const trace = join(dir, 'trace');
|
||||
const tokenPath = join(dir, 'token-path');
|
||||
const scriptPath = join(dir, 'script-path');
|
||||
const token = "tok'en;$(false)`false`\\value";
|
||||
const installer = join(dir, 'fixture-installer');
|
||||
const fake = (name: string, body: string) => {
|
||||
writeFileSync(join(dir, name), `#!/bin/sh\n${body}\n`);
|
||||
chmodSync(join(dir, name), 0o700);
|
||||
};
|
||||
try {
|
||||
fake('id', 'echo "$FAKE_UID"');
|
||||
fake('sudo', 'echo sudo >> "$TRACE"; exec "$@"');
|
||||
fake(
|
||||
'curl',
|
||||
`echo fetch >> "$TRACE"
|
||||
it.each(['sh', 'bash'])('runs tokenless commands under %s', (shell) => {
|
||||
const fixture = makeUnixFixture();
|
||||
try {
|
||||
for (const uid of ['0', '1000']) {
|
||||
const command = buildUnixAgentInstallCommand({ baseUrl: 'https://pulse.invalid' });
|
||||
const result = spawnSync(shell, ['-c', command], {
|
||||
env: fixture.env(uid),
|
||||
encoding: 'utf8',
|
||||
});
|
||||
expect(result.status, result.stderr).toBe(0);
|
||||
expect(readFileSync(fixture.trace, 'utf8')).toContain('install');
|
||||
assertUnixCleanup(fixture.dir);
|
||||
}
|
||||
} finally {
|
||||
fixture.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const unixPTYRunner = `
|
||||
import errno, fcntl, json, os, pty, select, signal, subprocess, sys, termios, time
|
||||
p = json.load(sys.stdin)
|
||||
master, slave = pty.openpty()
|
||||
tty_watch = os.dup(slave)
|
||||
initial_tty = termios.tcgetattr(tty_watch)
|
||||
def session():
|
||||
os.setsid()
|
||||
fcntl.ioctl(0, termios.TIOCSCTTY, 0)
|
||||
history = p.get("history")
|
||||
env = os.environ.copy()
|
||||
if history:
|
||||
env.update(HISTFILE=history, HISTSIZE="100", HISTFILESIZE="100", HISTCONTROL="", PS1="PULSE_TEST$ ")
|
||||
program = ["bash", "--noprofile", "--norc", "-i"] if history else ["sh", "-c", p["command"]]
|
||||
child = subprocess.Popen(program, stdin=slave, stdout=slave, stderr=slave, preexec_fn=session, env=env)
|
||||
if history: os.write(master, (p["command"] + "\\n").encode())
|
||||
os.close(slave)
|
||||
output = b""
|
||||
sent = False
|
||||
deadline = time.monotonic() + 15
|
||||
while time.monotonic() < deadline:
|
||||
ready, _, _ = select.select([master], [], [], .1)
|
||||
if ready:
|
||||
try: chunk = os.read(master, 65536)
|
||||
except OSError as e:
|
||||
if e.errno == errno.EIO: break
|
||||
raise
|
||||
if not chunk: break
|
||||
output += chunk
|
||||
if output.endswith(b"(paste at this prompt, not in the command): ") and not sent:
|
||||
if p.get("read_signal"):
|
||||
time.sleep(.1) # read has displayed its own prompt and is waiting
|
||||
with open(env["READER_PID"]) as f: reader = int(f.read())
|
||||
os.kill(reader, getattr(signal, "SIG" + p["read_signal"]))
|
||||
elif p.get("slow"):
|
||||
for piece in [p["input"][:4], p["input"][4:], "\\n"]:
|
||||
os.write(master, piece.encode())
|
||||
time.sleep(.05)
|
||||
else:
|
||||
os.write(master, (p["input"] + "\\n").encode())
|
||||
sent = True
|
||||
if history: os.write(master, b"exit\\n")
|
||||
if child.poll() is not None and not ready: break
|
||||
else:
|
||||
os.killpg(child.pid, signal.SIGKILL)
|
||||
output += b"\\nPTY fixture deadline expired\\n"
|
||||
code = child.wait()
|
||||
final_tty = termios.tcgetattr(tty_watch)
|
||||
mode_mask = termios.ECHO | termios.ICANON
|
||||
if (initial_tty[3] & mode_mask) != (final_tty[3] & mode_mask):
|
||||
output += b"\\nBootstrap did not restore terminal echo/canonical mode\\n"
|
||||
code = 1
|
||||
os.close(tty_watch)
|
||||
os.close(master)
|
||||
sys.stdout.buffer.write(output)
|
||||
sys.exit(code if code >= 0 else 128 - code)
|
||||
`;
|
||||
|
||||
const makeUnixFixture = () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'pulse-private-unix-'));
|
||||
chmodSync(dir, 0o700);
|
||||
const trace = join(dir, 'trace');
|
||||
writeFileSync(trace, '');
|
||||
const bin = join(dir, 'bin');
|
||||
mkdirSync(bin, { mode: 0o700 });
|
||||
const fake = (name: string, body: string) => {
|
||||
writeFileSync(join(bin, name), `#!/bin/sh\n${body}\n`, { mode: 0o700 });
|
||||
};
|
||||
fake(
|
||||
'stty',
|
||||
'if [ \"$1\" = -echo ]; then echo \"$PPID\" > \"$READER_PID\"; fi; exec /bin/stty \"$@\"',
|
||||
);
|
||||
fake('id', 'if [ "$1" = -u ]; then echo "$FAKE_UID"; else exec /usr/bin/id "$@"; fi');
|
||||
fake(
|
||||
'sudo',
|
||||
'printf "%s\\n" "$@" >> "$ARGV"; [ "$FAIL_AT" != sudo ] || exit 1; [ "$1" != -v ] || exit 0; exec "$@"',
|
||||
);
|
||||
fake(
|
||||
'mktemp',
|
||||
'path=$(/usr/bin/mktemp "$@") || exit 1; printf "%s\\n" "$path" >> "$DIRS"; printf "%s\\n" "$path"',
|
||||
);
|
||||
fake(
|
||||
'curl',
|
||||
`printf '%s\\n' "$@" >> "$ARGV"
|
||||
while [ "$#" -gt 0 ]; do
|
||||
if [ "$1" = -o ]; then output="$2"; shift; fi
|
||||
shift
|
||||
done
|
||||
printf %s "$output" > "$SCRIPT_PATH"
|
||||
[ "$FAIL_AT" != fetch ] || exit 22
|
||||
cp "$INSTALLER" "$output"`,
|
||||
);
|
||||
writeFileSync(
|
||||
installer,
|
||||
`#!/bin/sh
|
||||
set -e
|
||||
case " $* " in
|
||||
*' --preflight-only '*) echo preflight >> "$TRACE"; [ "$FAIL_AT" != preflight ]; exit $? ;;
|
||||
esac
|
||||
cp "$INSTALLER" "$output"
|
||||
echo fetch >> "$TRACE"
|
||||
[ "$FAIL_AT" != fetch ] || exit 22`,
|
||||
);
|
||||
writeFileSync(
|
||||
join(dir, 'installer'),
|
||||
`#!/bin/bash
|
||||
set -eu
|
||||
printf '%s\\n' "$@" >> "$ARGV"
|
||||
case " $* " in *' --preflight-only '*) echo preflight >> "$TRACE"; [ "$FAIL_AT" != preflight ]; exit $?;; esac
|
||||
echo install >> "$TRACE"
|
||||
printf '%s:%s' "\${pulse_token:-}" "\${PULSE_TOKEN:-}" > "$SECRET_ENV"
|
||||
token_file=''
|
||||
while [ "$#" -gt 0 ]; do
|
||||
if [ "$1" = --token-file ]; then token_file="$2"; shift; fi
|
||||
shift
|
||||
done
|
||||
printf %s "$token_file" > "$TOKEN_PATH"
|
||||
[ "$(stat -c %a "$token_file")" = 600 ]
|
||||
[ "$(stat -c %a "$(dirname "$token_file")")" = 700 ]
|
||||
[ "$(cat "$token_file")" = "$EXPECTED_TOKEN" ]
|
||||
if [ -n "$token_file" ]; then
|
||||
[ "$(stat -c %a "$token_file")" = 600 ]
|
||||
[ "$(stat -c %a "$(dirname "$token_file")")" = 700 ]
|
||||
[ "$(stat -c %u "$token_file")" = "$(stat -c %u "$(dirname "$token_file")")" ]
|
||||
cat "$token_file" > "$CAPTURE"
|
||||
fi
|
||||
if [ "$FAIL_AT" = TERM ] || [ "$FAIL_AT" = HUP ]; then kill -s "$FAIL_AT" "$PPID"; fi
|
||||
[ "$FAIL_AT" != install ]
|
||||
`,
|
||||
);
|
||||
for (const uid of ['0', '1000']) {
|
||||
for (const failAt of ['', 'fetch', 'preflight', 'install']) {
|
||||
writeFileSync(trace, '');
|
||||
rmSync(tokenPath, { force: true });
|
||||
const command = buildUnixAgentInstallCommand({
|
||||
baseUrl: 'https://pulse.invalid',
|
||||
token,
|
||||
});
|
||||
const input = document.createElement('input');
|
||||
input.value = command;
|
||||
const run = () =>
|
||||
execFileSync(shell, ['-c', input.value], {
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: `${dir}:${process.env.PATH}`,
|
||||
FAKE_UID: uid,
|
||||
FAIL_AT: failAt,
|
||||
TRACE: trace,
|
||||
TOKEN_PATH: tokenPath,
|
||||
SCRIPT_PATH: scriptPath,
|
||||
INSTALLER: installer,
|
||||
EXPECTED_TOKEN: token,
|
||||
},
|
||||
});
|
||||
if (failAt) expect(run).toThrow();
|
||||
else expect(run).not.toThrow();
|
||||
const events = readFileSync(trace, 'utf8').trim().split('\n');
|
||||
expect(events[0]).toBe('fetch');
|
||||
expect(existsSync(dirname(readFileSync(scriptPath, 'utf8')))).toBe(false);
|
||||
if (failAt === 'fetch' || failAt === 'preflight') {
|
||||
expect(events).toEqual(failAt === 'fetch' ? ['fetch'] : ['fetch', 'preflight']);
|
||||
expect(existsSync(tokenPath)).toBe(false);
|
||||
} else {
|
||||
expect(events[1]).toBe('preflight');
|
||||
expect(events).toContain('install');
|
||||
expect(existsSync(dirname(readFileSync(tokenPath, 'utf8')))).toBe(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
{ mode: 0o700 },
|
||||
);
|
||||
return {
|
||||
dir,
|
||||
trace,
|
||||
env: (uid = '0', failure = '') => ({
|
||||
...process.env,
|
||||
PATH: `${bin}:${process.env.PATH}`,
|
||||
FAKE_UID: uid,
|
||||
FAIL_AT: failure,
|
||||
TRACE: trace,
|
||||
ARGV: join(dir, 'argv'),
|
||||
DIRS: join(dir, 'dirs'),
|
||||
INSTALLER: join(dir, 'installer'),
|
||||
CAPTURE: join(dir, 'capture'),
|
||||
SECRET_ENV: join(dir, 'secret-env'),
|
||||
READER_PID: join(dir, 'reader-pid'),
|
||||
PULSE_TOKEN: '',
|
||||
}),
|
||||
close: () => rmSync(dir, { recursive: true, force: true }),
|
||||
};
|
||||
};
|
||||
|
||||
const assertUnixCleanup = (dir: string) => {
|
||||
if (existsSync(join(dir, 'dirs')))
|
||||
for (const path of readFileSync(join(dir, 'dirs'), 'utf8').trim().split('\n'))
|
||||
expect(existsSync(path), `temporary directory left behind: ${path}`).toBe(false);
|
||||
};
|
||||
const runPrivateUnix = (
|
||||
command: string,
|
||||
input: string,
|
||||
env: NodeJS.ProcessEnv,
|
||||
history?: string,
|
||||
readSignal?: string,
|
||||
slow = false,
|
||||
) =>
|
||||
spawnSync('python3', ['-c', unixPTYRunner], {
|
||||
input: JSON.stringify({ command, input, history, read_signal: readSignal, slow }),
|
||||
env,
|
||||
encoding: 'utf8',
|
||||
timeout: 20000,
|
||||
});
|
||||
|
||||
describe('credential-free Unix lifecycle execution', () => {
|
||||
it.each(['0', '1000'])(
|
||||
'keeps private input out of root/sudo argv, environment, output and history (uid=%s)',
|
||||
(uid) => {
|
||||
const fixture = makeUnixFixture();
|
||||
const marker = join(fixture.dir, 'must-not-execute');
|
||||
const token = `synthetic-' ; $(touch ${marker}) \`false\` \\value`;
|
||||
try {
|
||||
const command = buildUnixAgentInstallCommand({
|
||||
baseUrl: "https://pulse.invalid/agent's path",
|
||||
token,
|
||||
extraArgs: ['--enable-docker', "--agent-id 'agent-42'", "--hostname 'node.example'"],
|
||||
});
|
||||
const history = join(fixture.dir, 'history');
|
||||
const result = runPrivateUnix(command, token, fixture.env(uid), history);
|
||||
expect(result.status, result.stdout + result.stderr).toBe(0);
|
||||
expect(readFileSync(join(fixture.dir, 'capture'), 'utf8')).toBe(token);
|
||||
expect(existsSync(marker)).toBe(false);
|
||||
expect(readFileSync(join(fixture.dir, 'secret-env'), 'utf8')).toBe(':');
|
||||
const argv = readFileSync(join(fixture.dir, 'argv'), 'utf8');
|
||||
expect(argv).toContain("--url\nhttps://pulse.invalid/agent's path\n");
|
||||
expect(argv).toContain('--agent-id\nagent-42\n');
|
||||
expect(argv).toContain('--hostname\nnode.example\n');
|
||||
const hist = readFileSync(history, 'utf8');
|
||||
expect(hist).toContain(command);
|
||||
for (const text of [command, argv, hist, result.stdout, result.stderr])
|
||||
expect(text).not.toContain(token);
|
||||
assertUnixCleanup(fixture.dir);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
fixture.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it('preserves every character of the maximum-size accepted token', () => {
|
||||
const fixture = makeUnixFixture();
|
||||
try {
|
||||
const input = 'a'.repeat(4096);
|
||||
const result = runPrivateUnix(
|
||||
buildUnixAgentInstallCommand({ baseUrl: 'https://pulse.invalid', token: 'present' }),
|
||||
input,
|
||||
fixture.env(),
|
||||
);
|
||||
expect(result.status, result.stdout + result.stderr).toBe(0);
|
||||
expect(readFileSync(join(fixture.dir, 'capture'), 'utf8')).toBe(input);
|
||||
assertUnixCleanup(fixture.dir);
|
||||
} finally {
|
||||
fixture.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('drains oversized input without echoing it or returning it to interactive history', () => {
|
||||
const fixture = makeUnixFixture();
|
||||
try {
|
||||
const input = 'x'.repeat(10000) + '-OVERFLOW_HISTORY_MARKER';
|
||||
const history = join(fixture.dir, 'history');
|
||||
const result = runPrivateUnix(
|
||||
buildUnixAgentInstallCommand({ baseUrl: 'https://pulse.invalid', token: 'present' }),
|
||||
input,
|
||||
fixture.env(),
|
||||
history,
|
||||
);
|
||||
expect(result.status, result.stdout + result.stderr).not.toBe(0);
|
||||
expect(result.stdout).toContain('at most 4096');
|
||||
expect(existsSync(join(fixture.dir, 'capture'))).toBe(false);
|
||||
for (const text of [result.stdout, readFileSync(history, 'utf8')]) {
|
||||
expect(text).not.toContain('OVERFLOW_HISTORY_MARKER');
|
||||
expect(text).not.toContain('x'.repeat(512));
|
||||
}
|
||||
assertUnixCleanup(fixture.dir);
|
||||
} finally {
|
||||
fixture.close();
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
'fetch',
|
||||
'preflight',
|
||||
'sudo',
|
||||
'install',
|
||||
'HUP',
|
||||
'TERM',
|
||||
'blank',
|
||||
'interrupt',
|
||||
'no-terminal',
|
||||
])('stops and cleans up on %s', (failure) => {
|
||||
const fixture = makeUnixFixture();
|
||||
try {
|
||||
const command = buildUnixAgentInstallCommand({
|
||||
baseUrl: 'https://pulse.invalid',
|
||||
token: 'present',
|
||||
});
|
||||
const env = fixture.env(failure === 'sudo' ? '1000' : '0', failure);
|
||||
const result =
|
||||
failure === 'no-terminal'
|
||||
? spawnSync('sh', ['-c', command], { env, encoding: 'utf8' })
|
||||
: runPrivateUnix(
|
||||
command,
|
||||
failure === 'blank' ? '' : failure === 'interrupt' ? '\x03' : 'test-input-secret',
|
||||
env,
|
||||
);
|
||||
expect(result.status, result.stdout + result.stderr).not.toBe(0);
|
||||
if (!['install', 'HUP', 'TERM'].includes(failure))
|
||||
expect(existsSync(join(fixture.dir, 'capture'))).toBe(false);
|
||||
expect(result.stdout).not.toContain('test-input-secret');
|
||||
expect(result.stdout).not.toContain('did not restore');
|
||||
assertUnixCleanup(fixture.dir);
|
||||
} finally {
|
||||
fixture.close();
|
||||
}
|
||||
});
|
||||
|
||||
it.each(['HUP', 'TERM', 'INT'])(
|
||||
'restores the terminal when %s interrupts private input',
|
||||
(readSignal) => {
|
||||
const fixture = makeUnixFixture();
|
||||
try {
|
||||
const command = buildUnixAgentInstallCommand({
|
||||
baseUrl: 'https://pulse.invalid',
|
||||
token: 'present',
|
||||
});
|
||||
const result = runPrivateUnix(command, '', fixture.env(), undefined, readSignal);
|
||||
expect(result.status, result.stdout + result.stderr).not.toBe(0);
|
||||
expect(result.stdout).not.toContain('did not restore');
|
||||
expect(result.stdout).not.toContain('deadline expired');
|
||||
expect(existsSync(join(fixture.dir, 'capture'))).toBe(false);
|
||||
assertUnixCleanup(fixture.dir);
|
||||
} finally {
|
||||
fixture.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it('accepts deliberate slow token input without a timeout or truncation', () => {
|
||||
const fixture = makeUnixFixture();
|
||||
try {
|
||||
const command = buildUnixAgentInstallCommand({
|
||||
baseUrl: 'https://pulse.invalid',
|
||||
token: 'present',
|
||||
});
|
||||
const input = 'deliberate-slow-input';
|
||||
const result = runPrivateUnix(command, input, fixture.env(), undefined, undefined, true);
|
||||
expect(result.status, result.stdout + result.stderr).toBe(0);
|
||||
expect(readFileSync(join(fixture.dir, 'capture'), 'utf8')).toBe(input);
|
||||
expect(result.stdout).not.toContain(input);
|
||||
assertUnixCleanup(fixture.dir);
|
||||
} finally {
|
||||
fixture.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('supports non-terminal private-file input without deleting the operator file', () => {
|
||||
const fixture = makeUnixFixture();
|
||||
try {
|
||||
const file = join(fixture.dir, "operator's token");
|
||||
writeFileSync(file, 'private-file-token', { mode: 0o600 });
|
||||
const command = buildUnixAgentInstallCommand({
|
||||
baseUrl: 'https://pulse.invalid',
|
||||
tokenFilePath: file,
|
||||
});
|
||||
expect(command).not.toContain('read -r');
|
||||
expect(command).not.toContain('private-file-token');
|
||||
const result = spawnSync('sh', ['-c', command], { env: fixture.env(), encoding: 'utf8' });
|
||||
expect(result.status, result.stdout + result.stderr).toBe(0);
|
||||
expect(readFileSync(join(fixture.dir, 'capture'), 'utf8')).toBe('private-file-token');
|
||||
expect(readFileSync(file, 'utf8')).toBe('private-file-token');
|
||||
assertUnixCleanup(fixture.dir);
|
||||
} finally {
|
||||
fixture.close();
|
||||
}
|
||||
});
|
||||
|
||||
it.each(['missing', 'symlink', 'fifo', 'directory', 'public-file', 'public-parent'])(
|
||||
'rejects unsafe private-file input: %s',
|
||||
(kind) => {
|
||||
const fixture = makeUnixFixture();
|
||||
try {
|
||||
const file = join(fixture.dir, 'provided-token');
|
||||
const real = join(fixture.dir, 'actual-token');
|
||||
writeFileSync(real, 'private-file-token', { mode: 0o600 });
|
||||
if (kind === 'symlink') symlinkSync(real, file);
|
||||
if (kind === 'fifo') execFileSync('mkfifo', [file]);
|
||||
if (kind === 'directory') mkdirSync(file);
|
||||
if (kind === 'public-file' || kind === 'public-parent') {
|
||||
writeFileSync(file, 'private-file-token', {
|
||||
mode: kind === 'public-file' ? 0o644 : 0o600,
|
||||
});
|
||||
if (kind === 'public-parent') chmodSync(fixture.dir, 0o755);
|
||||
}
|
||||
const command = buildUnixAgentInstallCommand({
|
||||
baseUrl: 'https://pulse.invalid',
|
||||
tokenFilePath: file,
|
||||
});
|
||||
const result = spawnSync('sh', ['-c', command], {
|
||||
env: fixture.env(),
|
||||
encoding: 'utf8',
|
||||
timeout: 3000,
|
||||
});
|
||||
expect(result.status, result.stdout + result.stderr).not.toBe(0);
|
||||
expect(existsSync(join(fixture.dir, 'capture'))).toBe(false);
|
||||
assertUnixCleanup(fixture.dir);
|
||||
} finally {
|
||||
fixture.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -42,7 +42,7 @@ describe('quickstart copy contract', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps existing mobile security guidance aligned with retirement', () => {
|
||||
it('keeps retired Relay guidance about existing pairings, not a current offer', () => {
|
||||
const security = readRepoFile('SECURITY.md');
|
||||
const publicSecurity = readRepoFile('frontend-modern/public/docs/SECURITY.md');
|
||||
const screenshots = readRepoFile('docs/SCREENSHOTS.md');
|
||||
@@ -54,13 +54,16 @@ describe('quickstart copy contract', () => {
|
||||
}
|
||||
|
||||
for (const copy of [security, publicSecurity]) {
|
||||
expect(copy).toContain('Existing Mobile Pairings (Retirement)');
|
||||
expect(copy).toContain('31 March 2027');
|
||||
expect(copy).toContain('### Existing Mobile Pairings (Retirement)');
|
||||
expect(copy).toContain('Pulse Mobile and Relay retire on **31 March 2027**');
|
||||
expect(copy).toContain('Relay is no longer sold');
|
||||
expect(copy).toContain('receive Pro features at their current price');
|
||||
expect(copy).toContain('Relay connects the app, not the');
|
||||
expect(copy).toContain('Paired-app access remains license-gated until retirement');
|
||||
expect(copy).toMatch(/Relay connects the app, not the\s+web UI/);
|
||||
expect(copy).not.toContain('Relay Security (Relay and Above)');
|
||||
expect(copy).not.toContain('Relay, Pro, legacy Pro+, or Cloud license');
|
||||
}
|
||||
expect(publicSecurity).toBe(security);
|
||||
});
|
||||
|
||||
it('keeps public AI docs aligned with model-owned Patrol and Assistant reasoning', () => {
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
// Decode exactly the single-quoted Bash -c argument, including POSIX apostrophe
|
||||
// escapes. Consumers can assert its actual source without depending on the
|
||||
// extra outer quoting layer. Executable PTY tests separately pin real argv.
|
||||
export const unixBootstrapTestSource = (command: string): string => {
|
||||
const child = /bash -c ('(?:[^']|'"'"')*') pulse-bootstrap "\$install_script";/.exec(command);
|
||||
if (!child) throw new Error('Expected a single-quoted private Unix bootstrap child');
|
||||
return child[1].slice(1, -1).replaceAll(`'"'"'`, "'");
|
||||
};
|
||||
@@ -122,6 +122,8 @@ type BuildUnixAgentInstallCommandOptions = {
|
||||
insecure?: boolean;
|
||||
caCertPath?: string | null;
|
||||
extraArgs?: string[];
|
||||
// Existing private file for non-terminal hosts; never contains credential bytes.
|
||||
tokenFilePath?: string | null;
|
||||
};
|
||||
|
||||
type BuildWindowsAgentInstallCommandOptions = {
|
||||
@@ -138,20 +140,25 @@ export const buildUnixAgentInstallCommand = ({
|
||||
insecure = false,
|
||||
caCertPath,
|
||||
extraArgs = [],
|
||||
tokenFilePath,
|
||||
}: BuildUnixAgentInstallCommandOptions) => {
|
||||
const normalizedBaseUrl = normalizeInstallerBaseUrl(baseUrl);
|
||||
if (!normalizedBaseUrl.trim()) {
|
||||
throw new Error('Pulse install endpoint URL is required.');
|
||||
}
|
||||
const normalizedCaCertPath = (caCertPath || '').trim();
|
||||
const normalizedToken = (token || '').trim();
|
||||
const needsToken = Boolean((token || '').trim() || tokenFilePath?.trim());
|
||||
const normalizedTokenFilePath = (tokenFilePath || '').trim();
|
||||
if (normalizedTokenFilePath && !normalizedTokenFilePath.startsWith('/')) {
|
||||
throw new Error('Pulse token file path must be absolute.');
|
||||
}
|
||||
const normalizedExtraArgs = extraArgs.map((arg) => arg.trim()).filter((arg) => arg.length > 0);
|
||||
const installRequiresInsecure = insecure || normalizedBaseUrl.startsWith('http://');
|
||||
const curlFlags = insecure ? '-kfsSL' : '-fsSL';
|
||||
// Build shell grammar explicitly: command fields such as pfSense's remove
|
||||
// pasted newlines. Never flatten a multiline script (or quoted user data).
|
||||
if (
|
||||
[normalizedBaseUrl, normalizedToken, normalizedCaCertPath, ...normalizedExtraArgs].some(
|
||||
[normalizedBaseUrl, normalizedTokenFilePath, normalizedCaCertPath, ...normalizedExtraArgs].some(
|
||||
(value) => /[\r\n]/.test(value),
|
||||
)
|
||||
) {
|
||||
@@ -167,57 +174,57 @@ export const buildUnixAgentInstallCommand = ({
|
||||
].join(' ');
|
||||
const installArgs = [
|
||||
`--url ${shellQuoteArg(normalizedBaseUrl)}`,
|
||||
...(normalizedToken ? ['--token-file "$token_file"'] : []),
|
||||
...(needsToken ? ['--token-file "$token_file"'] : []),
|
||||
...normalizedExtraArgs,
|
||||
'--non-interactive',
|
||||
].join(' ');
|
||||
const rootTokenSetup = normalizedToken
|
||||
? [
|
||||
'token_dir=$(mktemp -d /tmp/pulse-agent-bootstrap.XXXXXX);',
|
||||
'token_file="$token_dir/token";',
|
||||
'umask 077;',
|
||||
`printf %s ${shellQuoteArg(normalizedToken)} > "$token_file";`,
|
||||
]
|
||||
: [];
|
||||
const sudoTokenSetup = normalizedToken
|
||||
? [
|
||||
'token_dir=$(sudo mktemp -d /tmp/pulse-agent-bootstrap.XXXXXX);',
|
||||
'token_file="$token_dir/token";',
|
||||
`printf %s ${shellQuoteArg(normalizedToken)} | sudo tee "$token_file" >/dev/null;`,
|
||||
'sudo chmod 0600 "$token_file";',
|
||||
]
|
||||
: [];
|
||||
|
||||
// Keep this reader aligned with configapi/privateBootstrapCommand. Bash owns
|
||||
// the silent bounded read in the privileged child, not the caller's shell or
|
||||
// sudo argv. A canonical line read silently truncates long pasted tokens.
|
||||
const privileged = ['set +xv;', 'set -eu;', 'umask 077;'];
|
||||
if (normalizedTokenFilePath) {
|
||||
privileged.push(
|
||||
`token_file=${shellQuoteArg(normalizedTokenFilePath)};`,
|
||||
'token_parent=${token_file%/*};',
|
||||
'if [ ! -f "$token_file" ] || [ -L "$token_file" ] || [ ! -O "$token_file" ] || [ ! -d "$token_parent" ] || [ -L "$token_parent" ] || [ ! -O "$token_parent" ] || [ "$(stat -c %a "$token_file" 2>/dev/null || stat -f %Lp "$token_file")" != 600 ] || [ "$(stat -c %a "$token_parent" 2>/dev/null || stat -f %Lp "$token_parent")" != 700 ]; then echo "Use a root-owned 0600 regular token file in a root-owned 0700 directory. No installation was attempted." >&2; exit 1; fi;',
|
||||
);
|
||||
} else if (needsToken) {
|
||||
privileged.push(
|
||||
'token_dir=$(mktemp -d /tmp/pulse-agent-bootstrap.XXXXXX);',
|
||||
'token_file="$token_dir/token";',
|
||||
'unset pulse_token pulse_discard tty_state;',
|
||||
'cleanup() { unset pulse_token pulse_discard; if [ -n "${tty_state:-}" ]; then stty "$tty_state" </dev/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;',
|
||||
`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;',
|
||||
"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;',
|
||||
);
|
||||
}
|
||||
privileged.push(`bash "$1" ${installArgs}${caCertArg}${insecureArg};`);
|
||||
const child = `bash -c ${shellQuoteArg(privileged.join(' '))} pulse-bootstrap "$install_script";`;
|
||||
|
||||
return [
|
||||
'(',
|
||||
'set -e;',
|
||||
'tmp_dir=$(mktemp -d);',
|
||||
'token_dir="";',
|
||||
'install_script="$tmp_dir/install.sh";',
|
||||
'cleanup() {',
|
||||
'rm -rf -- "$tmp_dir";',
|
||||
'if [ -n "${token_dir:-}" ]; then',
|
||||
'if [ "$(id -u)" -eq 0 ]; then',
|
||||
'rm -rf -- "$token_dir";',
|
||||
'elif command -v sudo >/dev/null 2>&1; then',
|
||||
'sudo rm -rf -- "$token_dir" >/dev/null 2>&1 || true;',
|
||||
'fi;',
|
||||
'fi;',
|
||||
'};',
|
||||
'trap cleanup EXIT HUP INT TERM;',
|
||||
'set +xv;',
|
||||
'set -eu;',
|
||||
'umask 077;',
|
||||
'if [ "$(id -u)" -eq 0 ]; then :; elif command -v sudo >/dev/null 2>&1; then sudo -v; else echo "Root privileges required. Run as root (su -) and retry." >&2; exit 1; fi;',
|
||||
'bootstrap_dir=$(mktemp -d /tmp/pulse-bootstrap.XXXXXX);',
|
||||
'install_script="$bootstrap_dir/install.sh";',
|
||||
'cleanup() { rm -f -- "$install_script"; rmdir -- "$bootstrap_dir"; };',
|
||||
"trap cleanup EXIT; trap 'exit 129' HUP; trap 'exit 130' INT; trap 'exit 143' TERM;",
|
||||
`curl ${curlFlags}${caCertArg} ${shellQuoteArg(`${normalizedBaseUrl}/install.sh`)} -o "$install_script";`,
|
||||
'chmod +x "$install_script";',
|
||||
`bash "$install_script" ${preflightArgs}${caCertArg}${insecureArg};`,
|
||||
'if [ "$(id -u)" -eq 0 ]; then',
|
||||
...rootTokenSetup,
|
||||
`bash "$install_script" ${installArgs}${caCertArg}${insecureArg};`,
|
||||
'elif command -v sudo >/dev/null 2>&1; then',
|
||||
...sudoTokenSetup,
|
||||
`sudo bash "$install_script" ${installArgs}${caCertArg}${insecureArg};`,
|
||||
'else',
|
||||
'echo "Root privileges required. Run as root (su -) and retry." >&2;',
|
||||
'exit 1;',
|
||||
'fi;',
|
||||
// Detachment must not require a downloadable new agent binary.
|
||||
...(!normalizedExtraArgs.includes('--uninstall')
|
||||
? [`bash "$install_script" ${preflightArgs}${caCertArg}${insecureArg};`]
|
||||
: []),
|
||||
`if [ "$(id -u)" -eq 0 ]; then ${child} else sudo ${child} fi;`,
|
||||
')',
|
||||
].join(' ');
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user