Files
Serhii Dziupin 41a2e3781d fix(cli): generate a UI password for bare --ui-password in daemon/serve mode
The grand tunnel restructuring removed the CLI's auto-generated UI
password, so `openchamber -d --ui-password` (no value) silently started
an unauthenticated server instead of creating a password as in 1.8.1.

Restore generation for an explicit --ui-password flag without a value:
the password is generated before either launch path, passed to the
daemon/foreground process via OPENCHAMBER_UI_PASSWORD, persisted in the
instance state file, and surfaced once in human/quiet/json output.

Refs OPE-216
2026-08-05 11:24:10 +03:00

425 lines
16 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import fs from 'fs';
import { pathToFileURL } from 'url';
import { spawn } from 'child_process';
import { EXIT_CODE, TunnelCliError } from './cli-errors.js';
import { buildLocalUrl, resolveServeHost, assertSafeBrowserPort, resolveServeUiPassword, assertAuthenticatedNetworkExposure } from './cli-network.js';
import { fetchSystemInfoFromPort } from './cli-http.js';
import { isPortAvailable, resolveAvailablePort } from './cli-ports.js';
import { ensureLogsDir, getLogFilePath } from './cli-paths.js';
import { rotateLogFile } from './cli-log-files.js';
import { discoverOpenChamberInstanceOnPort, isDesktopRuntimeForPort } from './cli-lifecycle.js';
import { getPidFilePath, getInstanceFilePath, writePidFile, writeInstanceOptions, removePidFile, removeInstanceFile, isProcessRunning, terminateProcessTree } from './cli-process.js';
import { isNetworkExposedBindHost } from '../../server/lib/security/bind-host.js';
import {
intro as clackIntro,
outro as clackOutro,
isJsonMode,
isQuietMode,
shouldRenderHumanOutput,
createSpinner,
printJson,
logStatus,
} from '../cli-output.js';
const DAEMON_READY_TIMEOUT_MS = 30000;
function createServeCommand({
serverPath,
bunBin,
checkOpenCodeCLI,
getPreferredServerRuntime,
setForegroundServerActive,
setForegroundShutdown,
}) {
async function serveCommand(options) {
const showOutput = shouldRenderHumanOutput(options);
const jsonMessages = [];
const emitNotice = (notice) => {
if (!notice || typeof notice !== 'object' || typeof notice.message !== 'string') return;
const level = notice.level === 'error' ? 'error' : (notice.level === 'warning' ? 'warning' : 'info');
if (isJsonMode(options)) {
jsonMessages.push({
level,
code: notice.code,
message: notice.message,
});
return;
}
if (showOutput) {
logStatus(level, notice.message);
return;
}
if (!isQuietMode(options)) {
const prefix = level === 'warning' ? 'Warning' : level === 'error' ? 'Error' : 'Info';
const line = `${prefix}: ${notice.message}`;
if (level === 'error') {
console.error(line);
} else {
console.warn(line);
}
}
};
const explicitPort = options.explicitPort === true;
const effectiveHost = resolveServeHost(options.host);
const targetPort = await resolveAvailablePort(options.port, explicitPort, emitNotice);
if (targetPort !== 0 && !options.suppressUnsafePortWarning) {
assertSafeBrowserPort(targetPort, { context: 'OpenChamber serve' });
}
if (targetPort !== 0) {
const existingInstance = await discoverOpenChamberInstanceOnPort(targetPort, { host: effectiveHost });
if (existingInstance?.runtime === 'desktop') {
throw new Error(
`Port ${targetPort} is used by OpenChamber Desktop app. Choose another port or stop the desktop app.`
);
}
if (existingInstance) {
const pidSuffix = Number.isFinite(existingInstance.pid) ? ` (PID: ${existingInstance.pid})` : '';
if (existingInstance.source === 'probe') {
throw new Error(`OpenChamber is already running on port ${targetPort}. Use \`openchamber status\` or \`openchamber stop --port ${targetPort}\`.`);
}
throw new Error(`OpenChamber is already running on port ${targetPort}${pidSuffix}`);
}
if (explicitPort && !(await isPortAvailable(targetPort, effectiveHost))) {
const systemInfo = await fetchSystemInfoFromPort(targetPort, globalThis.fetch, effectiveHost);
if (isDesktopRuntimeForPort(systemInfo, targetPort)) {
throw new Error(
`Port ${targetPort} is used by OpenChamber Desktop app. Choose another port or stop the desktop app.`
);
}
const systemInfoRuntimeMatchesPort = systemInfo?.runtime !== 'desktop' || isDesktopRuntimeForPort(systemInfo, targetPort);
if (systemInfo?.runtime && systemInfoRuntimeMatchesPort) {
throw new Error(`OpenChamber is already running on port ${targetPort}. Use \`openchamber status\` or \`openchamber stop --port ${targetPort}\`.`);
}
throw new Error(`Port ${targetPort} is already in use by another process.`);
}
}
const opencodeBinary = await checkOpenCodeCLI(emitNotice);
const preferredRuntime = getPreferredServerRuntime();
const runtimeBin = preferredRuntime === 'bun' ? bunBin : process.execPath;
ensureLogsDir();
const initialLogPort = targetPort === 0 ? 'auto' : String(targetPort);
const initialLogPath = getLogFilePath(initialLogPort);
rotateLogFile(initialLogPath);
const logFd = fs.openSync(initialLogPath, 'a');
// Resolve the effective UI password before either launch path so a
// password generated for `--ui-password` (no value) is set in the
// daemon/foreground environment before spawning and persisted in the
// instance state file the server and restart/status flows read.
const resolvedUiPassword = resolveServeUiPassword(options);
const effectiveUiPassword = resolvedUiPassword.password;
const autoGeneratedUiPassword = resolvedUiPassword.generated === true;
assertAuthenticatedNetworkExposure({
host: effectiveHost,
uiPassword: effectiveUiPassword,
});
if (!effectiveUiPassword && !options.suppressUiPasswordWarning) {
const bindHost = effectiveHost;
const networkExposed = isNetworkExposedBindHost(bindHost);
const warningLine = 'OPENCHAMBER_UI_PASSWORD is not set';
const warningDetail = networkExposed
? `server is bound to ${bindHost} and reachable on your network with no UI auth. `
+ 'Set --ui-password or OPENCHAMBER_UI_PASSWORD before exposing it over LAN.'
: 'browser UI is unsecured. Use --ui-password or OPENCHAMBER_UI_PASSWORD.';
if (showOutput) {
logStatus('warning', warningLine, warningDetail);
} else if (isJsonMode(options)) {
emitNotice({
level: 'warning',
code: 'UI_PASSWORD_MISSING',
message: `${warningLine}; ${warningDetail}`,
});
} else if (!isQuietMode(options)) {
console.warn(`Warning: ${warningLine}; ${warningDetail}`);
}
}
// Foreground mode: run server inline so the CLI process is the server process.
// Required for process managers like systemd (Type=simple) that track the
// direct child rather than a detached grandchild.
// IMPORTANT: foreground MUST remain inline (in-process). Do not convert to
// child-process orchestration — that causes shell job-control suspension.
if (options.foreground) {
if (isJsonMode(options)) {
throw new TunnelCliError(
'--json is not supported with --foreground. Use --json with background (daemon) mode instead.',
EXIT_CODE.USAGE_ERROR
);
}
// Propagate resolved values into env before importing the server module.
if (opencodeBinary) {
process.env.OPENCODE_BINARY = opencodeBinary;
}
if (effectiveUiPassword) {
process.env.OPENCHAMBER_UI_PASSWORD = effectiveUiPassword;
}
process.env.OPENCHAMBER_HOST = effectiveHost;
process.env.OPENCHAMBER_RUNTIME = 'web';
// In --quiet mode, redirect stdout/stderr to the log file so that
// server runtime output (console.log calls) does not pollute the
// deterministic CLI output contract. In plain human mode, close the
// log fd and let output go to the inherited terminal as before.
const suppressServerOutput = isQuietMode(options);
// Keep a reference to the real stdout.write so CLI output (port, JSON)
// can bypass the log-file redirect.
const realStdoutWrite = process.stdout.write.bind(process.stdout);
if (suppressServerOutput) {
const logStream = fs.createWriteStream(null, { fd: logFd });
process.stdout.write = (chunk, encoding, callback) => {
return logStream.write(chunk, encoding, callback);
};
process.stderr.write = (chunk, encoding, callback) => {
return logStream.write(chunk, encoding, callback);
};
} else {
// Close the log fd in foreground human mode stdout/stderr are
// inherited from the parent (e.g. journald/terminal).
try {
fs.closeSync(logFd);
} catch {
}
}
if (!isQuietMode(options)) {
console.log(`Starting OpenChamber on port ${targetPort === 0 ? 'auto' : targetPort} (foreground)`);
}
const { startWebUiServer } = await import(pathToFileURL(serverPath).href);
const controller = await startWebUiServer({
port: targetPort,
host: effectiveHost,
uiPassword: effectiveUiPassword,
apiOnly: options.apiOnly === true,
attachSignals: false,
exitOnShutdown: false,
});
const resolvedPort = controller.getPort();
// Write PID / instance files so status, stop, and restart can discover
// this foreground instance the same way they discover daemon instances.
const fgPidFilePath = await getPidFilePath(resolvedPort);
const fgInstanceFilePath = await getInstanceFilePath(resolvedPort);
writePidFile(fgPidFilePath, process.pid, emitNotice);
writeInstanceOptions(fgInstanceFilePath, {
port: resolvedPort,
host: effectiveHost,
launchMode: 'foreground',
uiPassword: effectiveUiPassword,
apiOnly: options.apiOnly === true,
}, emitNotice);
if (isQuietMode(options)) {
if (!options.suppressQuietOutput) {
realStdoutWrite(
autoGeneratedUiPassword
? `${resolvedPort} pass:${effectiveUiPassword}\n`
: `${resolvedPort}\n`
);
}
} else if (autoGeneratedUiPassword && showOutput && !options.suppressStartupSummary) {
console.log(`Generated UI password: ${effectiveUiPassword}`);
console.log('Save this password — it is not shown again.');
}
// Clean up PID / instance files.
const cleanupFiles = () => {
removePidFile(fgPidFilePath);
removeInstanceFile(fgInstanceFilePath);
};
process.on('exit', cleanupFiles);
// Idempotent graceful shutdown with deterministic exit codes.
let shutdownInProgress = false;
const shutdownForegroundServer = async (signal = 'SIGTERM') => {
if (shutdownInProgress) return;
shutdownInProgress = true;
try {
await controller.stop({ exitProcess: false });
} catch {
}
cleanupFiles();
setForegroundServerActive(false);
setForegroundShutdown(null);
const exitCode = signal === 'SIGINT' ? 130 : signal === 'SIGQUIT' ? 131 : 143;
process.exit(exitCode);
};
// Expose shutdown to the global SIGINT handler.
setForegroundShutdown(shutdownForegroundServer);
setForegroundServerActive(true);
// Register signal handlers (additive, no removeAllListeners).
process.on('SIGINT', () => { void shutdownForegroundServer('SIGINT'); });
process.on('SIGTERM', () => { void shutdownForegroundServer('SIGTERM'); });
process.on('SIGQUIT', () => { void shutdownForegroundServer('SIGQUIT'); });
// Block forever the process stays alive until signalled.
await new Promise(() => {});
}
const serverArgs = [serverPath, '--port', String(targetPort)];
serverArgs.push('--host', effectiveHost);
if (options.apiOnly === true) {
serverArgs.push('--api-only');
}
const serveSpin = showOutput ? createSpinner(options) : null;
const child = spawn(runtimeBin, serverArgs, {
detached: true,
windowsHide: true,
stdio: ['ignore', logFd, logFd, 'ipc'],
env: {
...process.env,
OPENCHAMBER_PORT: String(targetPort),
OPENCHAMBER_RUNTIME: 'web',
OPENCODE_BINARY: opencodeBinary,
OPENCHAMBER_HOST: effectiveHost,
...(effectiveUiPassword ? { OPENCHAMBER_UI_PASSWORD: effectiveUiPassword } : {}),
...(options.apiOnly === true ? { OPENCHAMBER_API_ONLY: 'true' } : {}),
...(process.env.OPENCODE_SKIP_START ? { OPENCHAMBER_SKIP_OPENCODE_START: process.env.OPENCODE_SKIP_START } : {}),
},
});
child.unref();
serveSpin?.start(`Starting OpenChamber on port ${targetPort === 0 ? 'auto' : targetPort}...`);
let resolvedPort;
try {
resolvedPort = await new Promise((resolve, reject) => {
let settled = false;
const timeout = setTimeout(() => {
if (settled) return;
settled = true;
reject(new Error(`OpenChamber daemon did not report ready within ${DAEMON_READY_TIMEOUT_MS / 1000}s`));
}, DAEMON_READY_TIMEOUT_MS);
child.on('message', (msg) => {
if (settled) return;
if (msg && msg.type === 'openchamber:ready' && typeof msg.port === 'number') {
settled = true;
clearTimeout(timeout);
resolve(msg.port);
}
});
child.on('error', (error) => {
if (settled) return;
settled = true;
clearTimeout(timeout);
reject(error);
});
child.on('exit', (code, signal) => {
if (settled) return;
settled = true;
clearTimeout(timeout);
reject(new Error(`OpenChamber daemon exited before reporting ready${signal ? ` (${signal})` : ` (code ${code ?? 'unknown'})`}`));
});
});
} catch (error) {
await terminateProcessTree(child.pid, { gracefulTimeoutMs: 1500, forceTimeoutMs: 1500 });
throw error;
}
try {
if (typeof child.disconnect === 'function' && child.connected) {
child.disconnect();
}
} catch {
}
try {
fs.closeSync(logFd);
} catch {
}
const resolvedLogPath = getLogFilePath(resolvedPort);
if (initialLogPath !== resolvedLogPath && !fs.existsSync(resolvedLogPath)) {
try {
fs.renameSync(initialLogPath, resolvedLogPath);
} catch {
}
}
if (!isProcessRunning(child.pid)) {
serveSpin?.error('Failed to start OpenChamber');
throw new Error('Failed to start server in daemon mode');
}
const pidFilePath = await getPidFilePath(resolvedPort);
const instanceFilePath = await getInstanceFilePath(resolvedPort);
writePidFile(pidFilePath, child.pid, emitNotice);
writeInstanceOptions(instanceFilePath, {
port: resolvedPort,
host: effectiveHost,
launchMode: 'daemon',
uiPassword: effectiveUiPassword,
apiOnly: options.apiOnly === true,
}, emitNotice);
const serveResult = {
port: resolvedPort,
pid: child.pid,
url: buildLocalUrl(resolvedPort, '/'),
logs: `openchamber logs -p ${resolvedPort}`,
launchMode: 'daemon',
};
if (isJsonMode(options)) {
printJson({
...serveResult,
messages: jsonMessages,
...(autoGeneratedUiPassword ? { password: effectiveUiPassword } : {}),
});
return resolvedPort;
}
if (isQuietMode(options)) {
if (options.suppressQuietOutput) {
return resolvedPort;
}
// A generated password is essential result data for scripts: include it
// in the same compact `pass:` token form `openchamber status --quiet`
// already emits. Configured passwords are never echoed.
process.stdout.write(
autoGeneratedUiPassword
? `${resolvedPort} pass:${effectiveUiPassword}\n`
: `${resolvedPort}\n`
);
return resolvedPort;
}
serveSpin?.clear();
if (!options.suppressStartupSummary && showOutput) {
clackIntro('OpenChamber Started');
logStatus('success', `port ${serveResult.port} (PID: ${serveResult.pid})`);
if (autoGeneratedUiPassword) {
logStatus('success', 'UI password', effectiveUiPassword);
logStatus('warning', 'save this password', 'it is not shown again');
}
logStatus('info', `visit: ${serveResult.url}`);
logStatus('info', `logs: ${serveResult.logs}`);
clackOutro('daemon running');
}
return resolvedPort;
}
return serveCommand;
}
export { createServeCommand };