diff --git a/packages/webui/src/cli.mjs b/packages/webui/src/cli.mjs index ef8ce3f9b..513ad3fe8 100644 --- a/packages/webui/src/cli.mjs +++ b/packages/webui/src/cli.mjs @@ -20,10 +20,25 @@ import { consoleReads, redactor } from './reads.mjs'; import { createDevOwner } from './dev-owner.mjs'; import { devOwnerInstance, devLanOptions } from '../../cli/src/dev-local-owner.mjs'; import { createSso, readSlot } from './sso.mjs'; -import { statSync } from 'node:fs'; +import { readFileSync, statSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; import { isAbsolute } from 'node:path'; import { ID_PATTERN } from '../../business/src/vocabulary.mjs'; import { selectorScope } from '../../business/src/selector.mjs'; +// The bus verifies the Console's exact same-tree script in /proc, whose argv preserves a +// relative shell argument even though Node's process.argv resolves it. Re-exec the same +// program with its absolute path before opening anything; preserve PID, ancestry and env so +// the existing human and Console proof checks remain unchanged. +if (process.platform === 'linux') { + const raw = readFileSync('/proc/self/cmdline', 'utf8').split('\0'); + let at = 1 + process.execArgv.length; + if (raw[at] === '--') at += 1; // Node leaves its option delimiter out of execArgv. + const script = raw[at]; + if (script && !isAbsolute(script)) { + if (typeof process.execve !== 'function') throw new Error('relative Console launch requires process.execve; use an absolute script path'); + process.execve(process.execPath, [process.execPath, ...process.execArgv, fileURLToPath(import.meta.url), ...process.argv.slice(2)], process.env); + } +} const args = process.argv.slice(2); const usage = 'node packages/webui/src/cli.mjs serve [--port N] [--tester-socket] [--checkout PATH] [--board http://127.0.0.1:7331] [--business ID] [--sso | --dev-local-owner [--dev-insecure-bind ADDR --dev-allow-host NAME[,NAME...]]] [--send] [--public-host NAME --auth-header HEADER --allow-user ID [--proof-header NAME --proof-file PATH]]'; try { diff --git a/packages/webui/tests/console-relative-launch.test.mjs b/packages/webui/tests/console-relative-launch.test.mjs new file mode 100644 index 000000000..e396ff3f2 --- /dev/null +++ b/packages/webui/tests/console-relative-launch.test.mjs @@ -0,0 +1,53 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { spawn, spawnSync } from 'node:child_process'; +import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { fileURLToPath } from 'node:url'; +import { join } from 'node:path'; + +const root = fileURLToPath(new URL('../../../', import.meta.url)); +const script = 'packages/webui/src/cli.mjs'; + +test('a relative Console launch presents the same-tree absolute parent path to the bus', { timeout: 30000 }, async t => { + const env = { ...process.env, MOSAIC_CONFIG: join(process.env.HOME, '.config/mosaic-reltest/config.json'), MOSAIC_AGENT_RUN: 'synthetic-test-marker' }; + const setup = spawnSync(join(root, 'scripts/bootstrap.sh'), ['--instance', 'reltest'], { env, encoding: 'utf8' }); + assert.equal(setup.status, 0, setup.stderr); + const child = spawn(process.execPath, [script, 'serve', '--dev-local-owner', '--port', '0'], { cwd: root, env }); + t.after(async () => { + if (child.exitCode === null && child.signalCode === null) { + const exited = new Promise(resolve => child.once('exit', resolve)); + child.kill('SIGTERM'); + await exited; + } + }); + let stderr = ''; + child.stderr.on('data', chunk => { stderr += chunk; }); + await new Promise((resolve, reject) => { + let out = ''; + const timer = setTimeout(() => reject(Error('Console did not start: ' + stderr)), 20000); + child.stdout.on('data', chunk => { out += chunk; if (out.includes('Mosaic Console:')) { clearTimeout(timer); resolve(); } }); + child.once('exit', code => { clearTimeout(timer); reject(Error(`Console exited ${code}: ${stderr}`)); }); + child.once('error', reject); + }); + const argv = readFileSync(`/proc/${child.pid}/cmdline`, 'utf8').split('\0'); + assert.equal(argv[1], join(root, script)); + assert.deepEqual(argv.slice(2, 6), ['serve', '--dev-local-owner', '--port', '0']); + // Re-exec must not erase the ancestry marker that makes an agent fail human proof. + assert.ok(readFileSync(`/proc/${child.pid}/environ`, 'utf8').split('\0').includes('MOSAIC_AGENT_RUN=synthetic-test-marker')); +}); + +test('Node\'s -- delimiter: an absolute script loads once, a relative one re-execs once', { timeout: 30000 }, () => { + const dir = mkdtempSync(join(tmpdir(), 'rl')); + const count = join(dir, 'loads'); + const preload = join(dir, 'count.cjs'); + writeFileSync(preload, `require('node:fs').appendFileSync(${JSON.stringify(count)}, 'x');\n`); + const loads = args => { + writeFileSync(count, ''); + const r = spawnSync(process.execPath, ['--require', preload, '--', ...args, '--help'], { cwd: root, env: process.env, encoding: 'utf8' }); + assert.equal(r.status, 0, r.stderr); + return readFileSync(count, 'utf8').length; + }; + assert.equal(loads([join(root, script)]), 1); + assert.equal(loads([script]), 2); +});