diff --git a/server.mjs b/server.mjs index 321460a..1c7f872 100644 --- a/server.mjs +++ b/server.mjs @@ -180,4 +180,4 @@ http.createServer(async(req,res)=>{ } res.writeHead(200,{'content-type':types[extname(path)]||'application/octet-stream','cache-control':'no-store','x-content-type-options':'nosniff'});if(req.method==='HEAD')res.end();else res.end(body); }catch(error){if(error instanceof AgentGatewayError)return sendAgentError(res,error);res.writeHead(404,{'content-type':'text/plain; charset=utf-8'});res.end('Not found')} -}).listen(port,host,()=>console.log(`Timmy is listening on http://${host}:${port} · vision ${visionConfig.enabled?'configured':'disabled'} · Hermes ${agentConfig.configured?'locked and ready':'disabled'}`)); +}).listen(port,host,function(){const bound=this.address();console.log(`Timmy is listening on http://${host}:${bound.port} · vision ${visionConfig.enabled?'configured':'disabled'} · Hermes ${agentConfig.configured?'locked and ready':'disabled'}`)}); diff --git a/tests/staging-health.test.js b/tests/staging-health.test.js index 7e324f9..6cd9444 100644 --- a/tests/staging-health.test.js +++ b/tests/staging-health.test.js @@ -229,3 +229,28 @@ test('malformed and escaping base paths are rejected at startup', async () => { assert.match(outcome.stderr, /TIMMY_BASE_PATH/); } }); + +// The startup line is the gate's only evidence of acceptance, so it has to name the port the process +// actually holds. It used to echo the requested port, which reads as ":0" for a kernel-assigned bind +// and sends anyone following it to a port nothing is listening on. +test('the startup line names the port actually bound, not the requested one', async t => { + const child = spawn(process.execPath, ['server.mjs'], { + cwd: root, + env: { ...process.env, PORT: '0', TIMMY_VISION_ENABLED: '0', TIMMY_AGENT_ENABLED: 'false' }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + const outcome = await startupOutcome(child); + try { + assert.equal(outcome.listening, true, `the server must announce listening: ${outcome.stderr}`); + assert.doesNotMatch(outcome.stdout, / listening on http:\/\/[^:\s]+:0\b/); + const bound = /Timmy is listening on http:\/\/[^:]+:(\d+) ·/.exec(outcome.stdout); + assert.ok(bound, `the startup line must carry the bound port: ${outcome.stdout}`); + const response = await fetch(`http://127.0.0.1:${bound[1]}/api/healthz`); + assert.equal(response.status, 200); + assert.equal((await response.json()).ok, true); + } finally { + const closed = new Promise(resolve => child.once('close', resolve)); + if (child.exitCode === null) child.kill('SIGTERM'); + await closed; + } +});