After an ungraceful shutdown removePidFile never runs, so a stale run/openchamber-<port>.pid outlives the process. The kernel can recycle that PID to an unrelated process, and a liveness-only `process.kill(pid, 0)` check then reports OpenChamber as "already running" and aborts startup — an infinite crashloop under systemd Restart=always while the port is actually free (issue #1721). Verify identity, not just liveness, but only where it belongs: - Add isOpenchamberProcessRunning(pid) = liveness + command-line identity, and use it ONLY at the two sites that validate a PID read from a pid file (the "already running" guard and the stale pid-file cleanup sweep). isProcessRunning stays liveness-only for PIDs we know are ours (a freshly spawned daemon child, processes we are stopping), so those paths cannot get a false negative. - Identity works on Linux (/proc/<pid>/cmdline) and macOS (ps -o command=); on Windows or where the command line can't be read it falls back to liveness, so behaviour is unchanged there with no false negatives. - Match the "openchamber" install-path segment (present for both @openchamber/web and a source checkout, foreground and daemon entrypoints alike) so a recycled stranger such as npm-cli.js or agentmemory is not mistaken for us. - Clear the stale pid file once its recorded PID is no longer our process. Adds unit tests for isOpenchamberCmdline and isOpenchamberProcessRunning, covering the recycled-PID cases and a live non-OpenChamber process.
201 lines
7.4 KiB
JavaScript
201 lines
7.4 KiB
JavaScript
import { describe, expect, it } from 'vitest';
|
|
import path from 'path';
|
|
import { spawn } from 'child_process';
|
|
import { pathToFileURL } from 'url';
|
|
|
|
import { isModuleCliExecution, normalizeCliEntryPath } from './cli-entry.js';
|
|
import {
|
|
assertAuthenticatedNetworkExposure,
|
|
isOpenchamberCmdline,
|
|
isOpenchamberProcessRunning,
|
|
parseArgs,
|
|
} from './cli.js';
|
|
|
|
describe('cli args', () => {
|
|
it('accepts legacy daemon flags as no-ops', () => {
|
|
expect(parseArgs(['serve', '--daemon']).removedFlagErrors).toEqual([]);
|
|
expect(parseArgs(['serve', '-d']).removedFlagErrors).toEqual([]);
|
|
});
|
|
|
|
it('parses explicit connect-url server overrides', () => {
|
|
const parsed = parseArgs(['connect-url', '--server', 'https://openchamber.example.com', '--port', '3002']);
|
|
|
|
expect(parsed.command).toBe('connect-url');
|
|
expect(parsed.options.server).toBe('https://openchamber.example.com');
|
|
expect(parsed.options.port).toBe(3002);
|
|
});
|
|
|
|
it('parses connect-url server-url alias', () => {
|
|
const parsed = parseArgs(['connect-url', '--server-url=http://homebridge:3002']);
|
|
|
|
expect(parsed.options.server).toBe('http://homebridge:3002');
|
|
});
|
|
|
|
it('parses connect-url api-only help', () => {
|
|
const parsed = parseArgs(['connect-url', '--api-only', '--help']);
|
|
|
|
expect(parsed.command).toBe('connect-url');
|
|
expect(parsed.options.apiOnly).toBe(true);
|
|
expect(parsed.helpRequested).toBe(true);
|
|
});
|
|
|
|
it('parses startup api-only option', () => {
|
|
const parsed = parseArgs(['startup', 'enable', '--api-only', '--port', '3002']);
|
|
|
|
expect(parsed.command).toBe('startup');
|
|
expect(parsed.startupAction).toBe('enable');
|
|
expect(parsed.options.apiOnly).toBe(true);
|
|
expect(parsed.options.port).toBe(3002);
|
|
});
|
|
|
|
it('parses tunnel auto-start server options', () => {
|
|
const parsed = parseArgs(['tunnel', 'start', '--port', '3002', '--api-only', '--lan', '--ui-password', 'secret']);
|
|
|
|
expect(parsed.command).toBe('tunnel');
|
|
expect(parsed.subcommand).toBe('start');
|
|
expect(parsed.options.port).toBe(3002);
|
|
expect(parsed.options.apiOnly).toBe(true);
|
|
expect(parsed.options.host).toBe('0.0.0.0');
|
|
expect(parsed.options.uiPassword).toBe('secret');
|
|
});
|
|
|
|
it('maps --lan to wildcard bind host', () => {
|
|
const parsed = parseArgs(['serve', '--lan', '--port', '3002']);
|
|
|
|
expect(parsed.options.host).toBe('0.0.0.0');
|
|
expect(parsed.options.lan).toBe(true);
|
|
});
|
|
|
|
it('supports --hostname as top-level bind alias', () => {
|
|
const parsed = parseArgs(['serve', '--hostname', '0.0.0.0']);
|
|
|
|
expect(parsed.options.host).toBe('0.0.0.0');
|
|
});
|
|
|
|
it('keeps --hostname for tunnel commands', () => {
|
|
const parsed = parseArgs(['tunnel', 'start', '--hostname', 'app.example.com']);
|
|
|
|
expect(parsed.options.hostname).toBe('app.example.com');
|
|
expect(parsed.options.host).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('network-exposed auth validation', () => {
|
|
it('allows loopback without a UI password', () => {
|
|
expect(() => assertAuthenticatedNetworkExposure({ host: '127.0.0.1' })).not.toThrow();
|
|
expect(() => assertAuthenticatedNetworkExposure({ host: 'localhost' })).not.toThrow();
|
|
expect(() => assertAuthenticatedNetworkExposure({ host: '::1' })).not.toThrow();
|
|
});
|
|
|
|
it('requires a UI password for LAN and wildcard bind hosts', () => {
|
|
expect(() => assertAuthenticatedNetworkExposure({ host: '0.0.0.0' })).toThrow(/refuses to bind/);
|
|
expect(() => assertAuthenticatedNetworkExposure({ host: '192.168.1.10' })).toThrow(/refuses to bind/);
|
|
});
|
|
|
|
it('allows network-exposed bind hosts with a UI password', () => {
|
|
expect(() => assertAuthenticatedNetworkExposure({ host: '0.0.0.0', uiPassword: 'secret' })).not.toThrow();
|
|
});
|
|
|
|
it('allows explicit unsafe LAN override from process env only', () => {
|
|
const previous = process.env.OPENCHAMBER_ALLOW_UNAUTHENTICATED_LAN;
|
|
process.env.OPENCHAMBER_ALLOW_UNAUTHENTICATED_LAN = 'true';
|
|
try {
|
|
expect(() => assertAuthenticatedNetworkExposure({ host: '0.0.0.0' })).not.toThrow();
|
|
} finally {
|
|
if (typeof previous === 'string') {
|
|
process.env.OPENCHAMBER_ALLOW_UNAUTHENTICATED_LAN = previous;
|
|
} else {
|
|
delete process.env.OPENCHAMBER_ALLOW_UNAUTHENTICATED_LAN;
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('cli entry detection', () => {
|
|
const modulePath = '/tmp/openchamber/bin/cli.js';
|
|
const moduleUrl = pathToFileURL(modulePath).href;
|
|
|
|
it('resolves symlinked entry paths before comparing', () => {
|
|
const symlinkPath = '/usr/local/bin/openchamber';
|
|
const realpath = (filePath) => {
|
|
if (filePath === path.resolve(symlinkPath)) {
|
|
return modulePath;
|
|
}
|
|
return filePath;
|
|
};
|
|
|
|
expect(isModuleCliExecution(symlinkPath, moduleUrl, realpath)).toBe(true);
|
|
});
|
|
|
|
it('falls back to resolved paths when realpath fails', () => {
|
|
const realpath = () => {
|
|
throw new Error('realpath unavailable');
|
|
};
|
|
|
|
expect(isModuleCliExecution(modulePath, moduleUrl, realpath)).toBe(true);
|
|
});
|
|
|
|
it('returns false for non-matching entry path', () => {
|
|
expect(isModuleCliExecution('/tmp/other-cli.js', moduleUrl)).toBe(false);
|
|
});
|
|
|
|
it('returns false for empty entry path', () => {
|
|
expect(isModuleCliExecution('', moduleUrl)).toBe(false);
|
|
});
|
|
|
|
it('returns false when module url is not provided', () => {
|
|
expect(isModuleCliExecution(modulePath)).toBe(false);
|
|
});
|
|
|
|
it('accepts wrapper binary name fallback when requested', () => {
|
|
const wrapperPath = '/home/user/.local/bin/openchamber';
|
|
expect(isModuleCliExecution(wrapperPath, moduleUrl, undefined, 'openchamber')).toBe(true);
|
|
});
|
|
|
|
it('normalizes direct paths when realpath fails', () => {
|
|
const unresolvedPath = './packages/web/bin/cli.js';
|
|
const realpath = () => {
|
|
throw new Error('no symlink resolution');
|
|
};
|
|
|
|
expect(normalizeCliEntryPath(unresolvedPath, realpath)).toBe(path.resolve(unresolvedPath));
|
|
});
|
|
});
|
|
|
|
describe('isOpenchamberCmdline', () => {
|
|
it('accepts OpenChamber CLI and daemon cmdlines', () => {
|
|
expect(isOpenchamberCmdline('node /x/@openchamber/web/bin/cli.js serve')).toBe(true);
|
|
expect(isOpenchamberCmdline('node /x/@openchamber/web/server/index.js --port 9090')).toBe(true);
|
|
expect(isOpenchamberCmdline('bun /home/u/projects/openchamber/packages/web/server/index.js --port 3001')).toBe(true);
|
|
});
|
|
|
|
it('rejects recycled and unrelated processes (issue #1721)', () => {
|
|
expect(isOpenchamberCmdline('node /home/herjarsa/npm-global/bin/agentmemory')).toBe(false);
|
|
expect(isOpenchamberCmdline('node /usr/lib/node_modules/npm/bin/npm-cli.js install')).toBe(false);
|
|
expect(isOpenchamberCmdline('')).toBe(false);
|
|
expect(isOpenchamberCmdline(null)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('isOpenchamberProcessRunning', () => {
|
|
it('returns false for a dead PID', () => {
|
|
expect(isOpenchamberProcessRunning(2147483646)).toBe(false);
|
|
});
|
|
|
|
// Identity verification is available on Linux (/proc) and macOS (ps); on those
|
|
// platforms a live but unrelated process (a recycled stale PID) must read as
|
|
// not-running so it can't trip the "already running" guard (issue #1721).
|
|
it.skipIf(process.platform !== 'linux' && process.platform !== 'darwin')(
|
|
'returns false for a live non-OpenChamber PID',
|
|
async () => {
|
|
const child = spawn('sleep', ['30'], { stdio: 'ignore' });
|
|
try {
|
|
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
expect(isOpenchamberProcessRunning(child.pid)).toBe(false);
|
|
} finally {
|
|
child.kill('SIGKILL');
|
|
}
|
|
}
|
|
);
|
|
});
|