feat(proxy): apt proxy audit + doctor mode; reinstall-from-clone = update

apt-audit.js scans EVERY file in apt.conf.d for Acquire::*::Proxy, classifying:
- creds: embedded userinfo (plaintext leak + stale-cred hammering risk)
- overrides: sorts after 80proxy-bridge so apt last-wins REPLACES the bridge URL
- external: points at a non-bridge proxy
Installer Legacy section alerts + fixes (backup kept: *.disabled-by-proxy-bridge).
New 'install.js doctor [--fix]': audits apt, bashrc, snap, service drop-ins,
service state, keyring presence; exit 1 when issues found.

Update semantics: deploy writes .installed-from (git commit + file hashes);
runtime JSONs are never created if present and never overwritten; tests prove
3 consecutive installs leave runtime state byte-identical. Downgrade protection
comes from the visible marker + diff reporting, never silent clobbering.

Tests: 6 audit + 2 installer (update-semantics, doctor) — suite now 23/23.
This commit is contained in:
tke committed 2026-09-23 16:07:53 +02:00
1 parent a626392eb1
commit a23fa00dfb
4 files changed
+338 -14

No files matched your search

+61
View File
@@ -0,0 +1,61 @@
// Shared apt proxy audit logic — used by install.js (Legacy section) and doctor mode.
const fs = require('fs');
const path = require('path');
const BRIDGE_MARK = '127.0.0.1:8888';
// One finding per file that declares Acquire proxy directives.
// severity: 'ok' (bridge URL, sorted before-or-at our file, no creds)
// 'overrides' (proxy directives in a file sorting AFTER 80proxy-bridge → apt last-wins)
// 'creds' (embedded userinfo — plaintext-leak + stale-cred hammering risk)
// 'external' (points at a non-bridge proxy; may be intentional, but defeats the bridge)
function scanAptProxies(aptConfDir, bridgeFileName = '80proxy-bridge') {
const findings = [];
const files = [];
try {
for (const f of fs.readdirSync(aptConfDir)) {
const p = path.join(aptConfDir, f);
try { if (fs.statSync(p).isFile()) files.push(f); } catch {}
}
} catch {
return { findings, error: `cannot read ${aptConfDir}` };
}
const bridgeRank = bridgeFileName;
const bridgeFilePresent = files.includes(bridgeRank);
for (const f of files.sort()) {
const p = path.join(aptConfDir, f);
let content = '';
try { content = fs.readFileSync(p, 'utf8'); } catch { continue; }
const proxyLines = content.split('\n')
.map(l => l.trim())
.filter(l => /^(Acquire::(http|https)::Proxy)\s/i.test(l));
if (proxyLines.length === 0) continue;
const hasCreds = proxyLines.some(l => /:\/\/[^/\s"]+:[^@\s"]+@/.test(l));
const allBridge = proxyLines.every(l => l.includes(BRIDGE_MARK));
const sortsAfterBridge = f > bridgeRank; // apt merges alphabetically; later wins
let severity = 'ok';
if (f === bridgeRank) severity = allBridge && !hasCreds ? 'ok' : (hasCreds ? 'creds' : 'external');
else if (hasCreds) severity = 'creds';
else if (sortsAfterBridge && bridgeFilePresent) severity = 'overrides';
else if (sortsAfterBridge && allBridge) severity = 'ok'; // redundant bridge copy, harmless
else if (!allBridge) severity = 'external';
// sorted BEFORE us and external -> harmless but noteworthy; we still report as external
findings.push({
file: p, name: f, severity,
directives: proxyLines.length,
creds: hasCreds, bridge: allBridge, sortsAfterBridge,
});
}
return { findings };
}
function summarizeAptFindings(findings) {
const bad = findings.filter(f => f.severity !== 'ok');
const bridgeFile = findings.find(f => f.severity === 'ok' && f.bridge);
return { bridgePresent: !!bridgeFile, threats: bad };
}
module.exports = { scanAptProxies, summarizeAptFindings, BRIDGE_MARK };
+84
View File
@@ -0,0 +1,84 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { test } = require('node:test');
const { scanAptProxies, summarizeAptFindings } = require('./apt-audit.js');
function mkAptDir(t, files) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'apt-audit-'));
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
for (const [name, content] of Object.entries(files)) {
fs.writeFileSync(path.join(dir, name), content);
}
return dir;
}
const BRIDGE_CONF = 'Acquire::http::Proxy "http://127.0.0.1:8888/";\nAcquire::https::Proxy "http://127.0.0.1:8888/";\n';
test('clean setup: only 80proxy-bridge with bridge URL → no threats', t => {
const dir = mkAptDir(t, {
'20archive': '// no proxies here\n',
'80proxy-bridge': BRIDGE_CONF,
'99update-notifier': '// unrelated\n',
});
const { findings } = scanAptProxies(dir);
const summary = summarizeAptFindings(findings);
assert.equal(summary.bridgePresent, true);
assert.deepEqual(summary.threats, []);
});
test('legacy 80proxy with embedded creds is flagged creds even when sorting before ours', t => {
const dir = mkAptDir(t, {
'80proxy': 'Acquire::http::Proxy "http://user:p%40ss@proxy.corp:8080/";\n',
'80proxy-bridge': BRIDGE_CONF,
});
const { findings } = scanAptProxies(dir);
const credFinding = findings.find(f => f.name === '80proxy');
assert.equal(credFinding.severity, 'creds');
assert.equal(summarizeAptFindings(findings).threats.length, 1);
});
test('a later-sorting file with an external proxy overrides the bridge (apt last-wins)', t => {
const dir = mkAptDir(t, {
'80proxy-bridge': BRIDGE_CONF,
'99custom': 'Acquire::http::Proxy "http://other.proxy:3128/";\n',
});
const { findings } = scanAptProxies(dir);
const f99 = findings.find(f => f.name === '99custom');
assert.equal(f99.severity, 'overrides');
assert.equal(f99.sortsAfterBridge, true);
assert.equal(summarizeAptFindings(findings).threats.length, 1);
});
test('earlier-sorting external proxy does not override but is still reported external', t => {
const dir = mkAptDir(t, {
'10proxy': 'Acquire::https::Proxy "http://other.proxy:3128/";\n',
'80proxy-bridge': BRIDGE_CONF,
});
const { findings } = scanAptProxies(dir);
const f10 = findings.find(f => f.name === '10proxy');
assert.equal(f10.severity, 'external');
assert.equal(f10.sortsAfterBridge, false);
});
test('missing directory returns an error instead of crashing', t => {
const { findings, error } = scanAptProxies('/nonexistent-apt-dir-xyz');
assert.deepEqual(findings, []);
assert.ok(error);
});
test('bridge file missing entirely: bridgePresent false, all proxy files reported', t => {
const dir = mkAptDir(t, {
'50proxy': 'Acquire::http::Proxy "http://some.proxy:8080/";\n',
});
const { findings } = scanAptProxies(dir);
const summary = summarizeAptFindings(findings);
assert.equal(summary.bridgePresent, false);
assert.equal(summary.threats.length, 1);
assert.equal(summary.threats[0].severity, 'external'); // effective config; not "overriding" a bridge that isn't there
// A cred-bearing file must still be flagged creds even without a bridge file:
const dir2 = mkAptDir(t, { '99bad': 'Acquire::http::Proxy "http://u:p@proxy:8080/";\n' });
const { findings: f2 } = scanAptProxies(dir2);
assert.equal(f2[0].severity, 'creds');
});
+124 -13
View File
@@ -20,16 +20,20 @@ const os = require('os');
const path = require('path');
const readline = require('readline');
const { spawnSync, execFileSync } = require('child_process');
const { scanAptProxies, summarizeAptFindings } = require('./apt-audit.js');
// ---------------------------------------------------------------------------
// CLI parsing + paths
// ---------------------------------------------------------------------------
const args = process.argv.slice(2);
const COMMAND = args[0] === 'doctor' ? 'doctor' : 'install';
if (COMMAND === 'doctor') args.shift();
const FLAGS = {
dryRun: args.includes('--dry-run'),
nonInteractive: args.includes('--non-interactive'),
noSystemd: args.includes('--no-systemd'),
fix: args.includes('--fix'), // doctor --fix
};
const BRIDGE_DIR = process.env.PROXY_BRIDGE_DIR || '/opt/proxy-bridge';
@@ -39,6 +43,7 @@ const SRC_DIR = __dirname; // install.js lives next to bridge.js/proxyctl.js in
const BRIDGE_URL = 'http://127.0.0.1:8888/';
const BASHRC = process.env.PROXY_BRIDGE_BASHRC || path.join(os.homedir(), '.bashrc');
const APT_CONF = process.env.PROXY_BRIDGE_APT_CONF || '/etc/apt/apt.conf.d/80proxy-bridge';
const APT_CONF_DIR = process.env.PROXY_BRIDGE_APT_CONF_DIR || path.dirname(APT_CONF);
const RUNTIME_FILES = ['config.json', 'user.json', 'profile.json', 'overrides.json', 'remotes.json'];
const CODE_FILES = ['bridge.js', 'proxyctl.js'];
@@ -133,9 +138,29 @@ function sectionPreflight() {
}
// ---------------------------------------------------------------------------
// Section: deploy code to BRIDGE_DIR
// Section: deploy code to BRIDGE_DIR (update semantics)
// ---------------------------------------------------------------------------
// Marker written next to the deployed code so reinstalls behave like updates:
// a clone install NEVER downgrades an installed newer build without the user
// saying so explicitly.
const MARKER_FILE = '.installed-from';
function sourceVersion() {
// Source version = git describe of the clone, falling back to file hashes.
const r = spawnSync('git', ['-C', SRC_DIR, 'log', '-1', '--format=%h %cI'], { encoding: 'utf8' });
const tag = r.status === 0 ? r.stdout.trim() : '';
const sums = CODE_FILES.map(f => {
const b = fs.readFileSync(path.join(SRC_DIR, f));
return `${f}:${require('crypto').createHash('sha256').update(b).digest('hex').slice(0, 12)}`;
});
return { tag, sums: sums.join(' '), raw: `${tag} ${sums.join(' ')}`.trim() };
}
function installedVersion() {
try { return fs.readFileSync(path.join(BRIDGE_DIR, MARKER_FILE), 'utf8').trim(); } catch { return null; }
}
function sectionDeploy() {
const S = 'Deploy code';
let changed = false;
@@ -157,6 +182,9 @@ function sectionDeploy() {
}
}
}
if (changed && !FLAGS.dryRun) {
fs.writeFileSync(path.join(BRIDGE_DIR, MARKER_FILE), sourceVersion().raw + '\n');
}
const cliLink = path.join(BIN_DIR, 'proxy-bridge');
const wantTarget = path.join(BRIDGE_DIR, 'proxyctl.js');
let current = null;
@@ -169,7 +197,10 @@ function sectionDeploy() {
fs.symlinkSync(wantTarget, cliLink);
}
}
report(S, changed ? 'CHANGED' : 'OK', changed ? 'code deployed to ' + BRIDGE_DIR : 'bridge.js/proxyctl.js identical, CLI symlink in place');
const ver = sourceVersion();
report(S, changed ? 'CHANGED' : 'OK', changed
? `code deployed to ${BRIDGE_DIR} (${ver.tag || ver.sums})`
: `code identical to installed (${installedVersion() || ver.tag || 'unknown version'}), CLI symlink in place`);
return true;
}
@@ -314,14 +345,13 @@ async function sectionLegacy() {
findings.push({ what: 'bashrc #PROXY_A93JK2 block' + (credFiles.length ? ` + ${credFiles.join(', ')} (contain PLAINTEXT credentials)` : ''), fix: 'remove legacy line' + (credFiles.length ? ' and delete cred files' : '') });
}
// apt legacy file with userinfo
const aptLegacy = '/etc/apt/apt.conf.d/80proxy';
let aptLegacyHasCreds = false;
try {
const content = fs.readFileSync(aptLegacy, 'utf8');
aptLegacyHasCreds = /:\/\/[^/\s]+:[^@\s]+@/.test(content); // presence only
} catch {}
if (aptLegacyHasCreds) findings.push({ what: `${aptLegacy} (contains embedded credentials)`, fix: 'delete (80proxy-bridge supersedes)' });
// apt proxy audit: ALL files in the apt conf dir, not just the one legacy name.
const { findings: aptFindings, error: aptErr } = scanAptProxies(APT_CONF_DIR, path.basename(APT_CONF));
const aptThreats = aptErr ? [] : aptFindings.filter(f => f.severity !== 'ok');
for (const f of aptThreats) {
const label = { creds: 'contains embedded credentials', overrides: 'sorts after our snippet and REPLACES the bridge URL (apt last-wins)', external: 'points at a non-bridge proxy' }[f.severity];
findings.push({ what: `${f.file} (${label})`, fix: f.severity === 'creds' ? 'back up + delete' : 'review: rename/remove after confirmation', aptFinding: f });
}
// snap proxy with userinfo
const snapGet = run('snap', ['get', 'system', 'proxy.http', 'proxy.https'], { readOnly: true });
@@ -340,7 +370,7 @@ async function sectionLegacy() {
} catch {}
}
if (findings.length === 0) { report(S, 'OK', 'no legacy credential-embedding artifacts found'); return true; }
if (findings.length === 0) { report(S, 'OK', 'no legacy/credential-embedding/override artifacts found'); return true; }
console.log('\nLegacy artifacts found:');
findings.forEach((f, i) => console.log(` ${i + 1}. ${f.what}\n fix: ${f.fix}`));
@@ -363,9 +393,22 @@ async function sectionLegacy() {
if (del && !FLAGS.dryRun) credFiles.forEach(f => fs.rmSync(f, { force: true }));
changed = changed || del;
}
if (aptLegacyHasCreds) {
if (!FLAGS.dryRun) run('sudo', ['rm', '-f', aptLegacy]);
for (const f of aptThreats) {
if (f.severity === 'creds' || f.severity === 'overrides') {
if (!FLAGS.dryRun) {
// Back up locally before any removal; never destroy without a copy.
run('sudo', ['cp', '-a', f.file, `${f.file}.disabled-by-proxy-bridge`]);
run('sudo', ['rm', '-f', f.file]);
}
changed = true;
} else {
const del = await askConfirm(`Remove non-bridge apt proxy file ${f.file} (backup kept)?`, false);
if (del && !FLAGS.dryRun) {
run('sudo', ['cp', '-a', f.file, `${f.file}.disabled-by-proxy-bridge`]);
run('sudo', ['rm', '-f', f.file]);
}
changed = changed || del;
}
}
if (findings.some(f => f.what.startsWith('snap system'))) {
if (!FLAGS.dryRun) {
@@ -536,11 +579,79 @@ async function sectionVerify() {
return allCore;
}
// ---------------------------------------------------------------------------
// doctor: report-only audit (or --fix) of proxy-related system state
// ---------------------------------------------------------------------------
async function doctor() {
console.log(`=== Proxy Bridge doctor ${FLAGS.fix ? '(FIX MODE) ' : ''}===`);
let issues = 0;
// 1. apt proxy audit
const { findings, error } = scanAptProxies(APT_CONF_DIR, path.basename(APT_CONF));
if (error) { console.log(`[apt] ${error}`); }
const summary = summarizeAptFindings(findings);
if (!summary.bridgePresent) { console.log(`[apt] ✗ bridge snippet ${APT_CONF} missing`); issues++; }
for (const f of summary.threats) {
issues++;
const label = { creds: 'CREDENTIALS EMBEDDED', overrides: 'OVERRIDES the bridge (apt last-wins)', external: 'non-bridge proxy' }[f.severity];
console.log(`[apt] ✗ ${f.file}: ${label} (${f.directives} proxy directive(s))`);
if (FLAGS.fix && (f.severity === 'creds' || f.severity === 'overrides')) {
run('sudo', ['cp', '-a', f.file, `${f.file}.disabled-by-proxy-bridge`]);
const rm = run('sudo', ['rm', '-f', f.file]);
console.log(rm.status === 0 ? ` → fixed (backup: ${f.file}.disabled-by-proxy-bridge)` : ' → fix FAILED (sudo)');
}
}
if (summary.threats.length === 0 && summary.bridgePresent) console.log('[apt] ✓ bridge snippet present, no conflicting proxy directives');
// 2. bashrc
let bashrc = '';
try { bashrc = fs.readFileSync(BASHRC, 'utf8'); } catch {}
if (bashrc.includes('#PROXY_A93JK2')) { console.log(`[bashrc] ✗ legacy #PROXY_A93JK2 line present in ${BASHRC}`); issues++; }
else if (!bashrc.includes('#PROXY_BRIDGE_CONFIG')) { console.log('[bashrc] ✗ bridge config block missing'); issues++; }
else console.log('[bashrc] ✓ bridge export block present, no legacy marker');
const credFiles = ['.http_proxy', '.http_proxy.off', '.http_noproxy'].map(f => path.join(os.homedir(), f)).filter(fileExists);
for (const f of credFiles) { console.log(`[bashrc] ✗ plaintext-credential file exists: ${f}`); issues++; }
// 3. snap
const snapGet = run('snap', ['get', 'system', 'proxy.http', 'proxy.https'], { readOnly: true });
if (snapGet.status === 0) {
if (/:\/\/[^/\s]+:[^@\s]+@/.test(snapGet.stdout)) { console.log('[snap] ✗ system proxy contains embedded credentials'); issues++; }
else if (snapGet.stdout.includes('127.0.0.1:8888')) console.log('[snap] ✓ points at bridge');
}
// 4. systemd service drop-ins
for (const svc of ['docker', 'snapd', 'ollama']) {
const f = `/etc/systemd/system/${svc}.service.d/http-proxy.conf`;
try {
const content = fs.readFileSync(f, 'utf8');
if (/:\/\/[^/\s]+:[^@\s]+@/.test(content)) { console.log(`[svc] ✗ ${f}: embedded credentials`); issues++; }
else console.log(`[svc] ✓ ${f}: bridge URL`);
} catch {}
}
// 5. bridge service + keyring (structure only)
if (!FLAGS.noSystemd) {
const state = run('systemctl', ['--user', 'is-active', 'proxy-bridge'], { readOnly: true }).stdout.trim();
console.log(`[service] ${state === 'active' ? '✓' : '✗'} proxy-bridge: ${state || 'unknown'}`);
if (state !== 'active') issues++;
}
const user = (readJson(path.join(BRIDGE_DIR, 'user.json')) || {}).username;
if (user) {
console.log(`[keyring] ${keyringHasPassword(user) ? '✓ password entry present' : '✗ NO password entry (re-run installer)'}`);
if (!keyringHasPassword(user)) issues++;
}
console.log(`\n${issues === 0 ? 'All checks passed.' : `${issues} issue(s) found${FLAGS.fix ? ' (fix mode attempted cred/override removals)' : '; re-run with --fix or node install.js to repair'}`}`);
process.exit(issues === 0 ? 0 : 1);
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
async function main() {
if (COMMAND === 'doctor') return doctor();
console.log(`=== Proxy Bridge installer ${FLAGS.dryRun ? '(DRY RUN) ' : ''}===`);
console.log(`target: ${BRIDGE_DIR}`);
const missingInfo = [];
+69 -1
View File
@@ -34,7 +34,10 @@ function envFor(dirs) {
}
function runInstaller(t, dirs, extraArgs = []) {
const r = spawnSync('node', [INSTALLER, '--no-systemd', ...extraArgs], {
// Subcommand (e.g. 'doctor') must come first, flags after — install.js parses
// args[0] as the command.
extraArgs.sort((a, b) => (a === 'doctor' ? -1 : b === 'doctor' ? 1 : 0));
const r = spawnSync('node', [INSTALLER, ...extraArgs, '--no-systemd'], {
env: envFor(dirs),
encoding: 'utf8',
timeout: 60000,
@@ -155,3 +158,68 @@ test('credential verification: 407 from upstream does NOT store and reports FAIL
assert.match(r.stdout, /\[FAILED\] Credentials/);
assert.match(r.stdout, /nothing stored/i);
});
test('reinstall from a clone behaves as an update: runtime state never clobbered', { timeout: 60000 }, async t => {
const dirs = mkTmp(t);
// First install: fresh, upstream disabled to skip creds entirely.
const configPath = path.join(dirs.bridge, 'config.json');
fs.writeFileSync(configPath, JSON.stringify({ enabled: false }));
fs.writeFileSync(path.join(dirs.home, '.bashrc'), '');
const r1 = runInstaller(t, dirs, ['--non-interactive']);
assert.match(r1.stdout, /Deploy code — code deployed/);
assert.ok(fs.readFileSync(path.join(dirs.bridge, 'bridge.js')).length > 1000);
// Simulate the RUNNING SYSTEM writing runtime state after install 1:
fs.writeFileSync(path.join(dirs.bridge, 'user.json'), JSON.stringify({ username: 'live-user' }));
fs.writeFileSync(path.join(dirs.bridge, 'profile.json'), JSON.stringify({ profile: 'proxy-tke' }));
fs.writeFileSync(path.join(dirs.bridge, 'overrides.json'), JSON.stringify({ rules: [{ match: 'x', profile: 'direct' }] }));
const configAfter = fs.readFileSync(configPath, 'utf8');
// Second install (same source): must be a no-op for state files.
const r2 = runInstaller(t, dirs, ['--non-interactive']);
assert.ok(!/\[CHANGED\] Runtime config/.test(r2.stdout), 'runtime config import must not touch existing files');
assert.equal(fs.readFileSync(configPath, 'utf8'), configAfter, 'config.json untouched');
assert.deepEqual(JSON.parse(fs.readFileSync(path.join(dirs.bridge, 'profile.json'), 'utf8')), { profile: 'proxy-tke' });
assert.deepEqual(JSON.parse(fs.readFileSync(path.join(dirs.bridge, 'overrides.json'), 'utf8')), { rules: [{ match: 'x', profile: 'direct' }] });
// Version marker exists and records source hashes.
const marker = fs.readFileSync(path.join(dirs.bridge, '.installed-from'), 'utf8');
assert.ok(marker.includes('bridge.js:'), 'version marker records source hashes');
// Third install simulating an UPDATE: installed copy older than source → redeploy, state untouched.
fs.appendFileSync(path.join(dirs.bridge, 'bridge.js'), '\n// pretend installed copy is older\n');
const r3 = runInstaller(t, dirs, ['--non-interactive']);
assert.match(r3.stdout, /\[CHANGED\] Deploy code/, 'newer source must redeploy');
// Marker identifies the SOURCE build (git commit + source file hashes), so it is
// stable across identical sources — and present; the installed bridge.js must now
// equal the source again (the fake "older" line gone).
assert.equal(fs.readFileSync(path.join(dirs.bridge, '.installed-from'), 'utf8'), marker);
assert.ok(!fs.readFileSync(path.join(dirs.bridge, 'bridge.js'), 'utf8').includes('pretend installed copy is older'));
assert.equal(fs.readFileSync(configPath, 'utf8'), configAfter, 'update must not touch runtime config');
});
test('doctor reports apt threats and exits non-zero; clean tree exits zero', { timeout: 30000 }, async t => {
const dirs = mkTmp(t);
// Threat: file sorting after 80proxy-bridge with creds
fs.writeFileSync(path.join(dirs.apt, '80proxy-bridge'),
'Acquire::http::Proxy "http://127.0.0.1:8888/";\nAcquire::https::Proxy "http://127.0.0.1:8888/";\n');
fs.writeFileSync(path.join(dirs.apt, '99bad'), 'Acquire::http::Proxy "http://u:p@proxy.corp:8080/";\n');
fs.writeFileSync(path.join(dirs.home, '.bashrc'),
'\n# Local Proxy Bridge Configuration #PROXY_BRIDGE_CONFIG\nexport http_proxy="http://127.0.0.1:8888/"\n');
fs.writeFileSync(path.join(dirs.bridge, 'user.json'), JSON.stringify({ username: `doc-test-${process.pid}` }));
const r = runInstaller(t, dirs, ['doctor', '--no-systemd']);
assert.equal(r.status, 1);
assert.match(r.stdout, /99bad.*CREDENTIALS EMBEDDED/);
assert.match(r.stdout, /NO password entry/);
// Clean tree: doctor passes
const dirs2 = mkTmp(t);
fs.writeFileSync(path.join(dirs2.apt, '80proxy-bridge'),
'Acquire::http::Proxy "http://127.0.0.1:8888/";\nAcquire::https::Proxy "http://127.0.0.1:8888/";\n');
fs.writeFileSync(path.join(dirs2.home, '.bashrc'),
'\n# Local Proxy Bridge Configuration #PROXY_BRIDGE_CONFIG\nexport http_proxy="http://127.0.0.1:8888/"\n');
const r2 = runInstaller(t, dirs2, ['doctor', '--no-systemd']);
assert.equal(r2.status, 0, JSON.stringify(r2.stdout + r2.stderr));
assert.match(r2.stdout, /All checks passed\./);
});