From 01e6ea87a71a5f26ea10e6ccaf27d81827551717 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Aug 2026 12:07:55 +0000 Subject: [PATCH] fix(cli): ready banner prints the reachable origin, not the bound port (#10646) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The banner composed `http://localhost:` from the port the process binds. That stops being the address a human can open the moment anything sits in front of it. On the EE 4.1.0 compose stack the app container `expose`s :3000 with no `ports:` mapping while Caddy publishes :80 and `OS_AUTH_URL` is already `http://localhost` — so the Console link failed outright and the `MCP:` line, which customers paste into an AI client, named an address that can never connect and never says so. The origin now comes from `resolveAuthBaseUrl`, the runtime's own chain (`OS_AUTH_URL` -> legacy `BETTER_AUTH_URL` -> `OS_BASE_URL` -> `http://localhost:`), whose `baseOrigin` is also what is pushed onto the CSRF allow-list — so the banner cannot disagree with the origin the deployment actually trusts. Nothing about what the server listens on, binds to, or advertises to a client changed; this is printed text only. When the chain yields nothing usable (a set-but-empty variable, or a value with no scheme) the banner prints the paths with no origin in front of them, and names `OS_AUTH_URL` as the fix. A missing address sends the operator to look one up; a confident wrong one gets copied. `ServerReadyOptions.port` is replaced by a required `externalBaseOrigin: string | null`, so the banner can no longer compose an address from a port and a caller that forgets to resolve one is a compile error rather than a plausible-looking line of output. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019bmVFqoQPq63zhKrxdYG1r --- .changeset/serve-banner-external-base-url.md | 35 ++++ .../serve-banner-external-base-url.test.ts | 134 ++++++++++++++ packages/cli/src/commands/serve.ts | 23 ++- .../utils/format.config-artifact-row.test.ts | 2 +- .../cli/src/utils/format.seed-summary.test.ts | 2 +- .../format.server-ready-base-url.test.ts | 164 ++++++++++++++++++ packages/cli/src/utils/format.tenancy.test.ts | 8 +- packages/cli/src/utils/format.ts | 68 +++++++- 8 files changed, 423 insertions(+), 13 deletions(-) create mode 100644 .changeset/serve-banner-external-base-url.md create mode 100644 packages/cli/src/commands/serve-banner-external-base-url.test.ts create mode 100644 packages/cli/src/utils/format.server-ready-base-url.test.ts diff --git a/.changeset/serve-banner-external-base-url.md b/.changeset/serve-banner-external-base-url.md new file mode 100644 index 0000000000..5d12acb4d6 --- /dev/null +++ b/.changeset/serve-banner-external-base-url.md @@ -0,0 +1,35 @@ +--- +"@objectstack/cli": patch +--- + +**Bug fix (wrong address printed):** the `os serve` / `os dev` ready banner now builds its API, Console and MCP links from the origin an operator can actually reach, instead of composing `http://localhost:` from the port the process happens to bind (#10646). + +Measured on the EE 4.1.0 published-image compose stack (moved from cloud#1507). The app container `expose`s `:3000` with no `ports:` mapping — unreachable from the host, and less so still under `--scale app=N` — while the published entry point is Caddy on `:80`, and compose has already resolved `OS_AUTH_URL` to `http://localhost`. The banner printed the container-internal address anyway: + +``` + ➜ API: http://localhost:3000/ + ➜ Console: http://localhost:3000/_console/ + ➜ MCP: http://localhost:3000/api/v1/mcp + connect an AI client (Claude Code, Cursor, …) · skill: http://localhost:3000/api/v1/mcp/skill +``` + +Following the Console link failed outright; after moving the deployment to a domain the banner still said `localhost:3000`; and the `MCP:` line is the address customers paste into an AI client, where a wrong absolute URL never fails loudly — it just never connects. + +**The origin is the runtime own answer, not a second one.** The banner resolves it through `resolveAuthBaseUrl` — the same function whose `baseOrigin` is pushed onto the CSRF allow-list a few hundred lines earlier in the same boot — so the banner and the origin the deployment actually trusts cannot drift apart. That chain is `OS_AUTH_URL` → legacy `BETTER_AUTH_URL` → `OS_BASE_URL` → `http://localhost:`; the legacy name sits in the middle and is easy to miss when the chain is restated from memory, which is one reason it is read rather than restated. Nothing about what the server listens on, binds to, or advertises to a client changed: the resolver reads `process.env` and the bound port, and this fix changes only printed text. + +**When no origin can be determined, the banner prints no absolute URL at all.** The chain yields nothing usable when a variable is set-but-empty (`OS_AUTH_URL=` stops the chain rather than falling through) or carries no scheme. The banner then prints the paths bare — + +``` + ➜ API: / + ➜ Console: /_console/ + ➜ MCP: /api/v1/mcp + connect an AI client (Claude Code, Cursor, …) · skill: /api/v1/mcp/skill + paths only — this deployment external base URL could not be resolved; + set OS_AUTH_URL to its public origin (e.g. https://app.example.com) +``` + +— because a missing address sends the operator to look one up, while a confident wrong one gets copied. `http://localhost:3000` was never a neutral default here; it was the wrong answer that shipped. + +The local dev loop is unchanged: with nothing set, the tail of the chain is still `http://localhost:` on the port that was actually bound (past any dev auto-shift), so `os dev` keeps its clickable Console link. + +Structurally, `ServerReadyOptions.port` is replaced by a required `externalBaseOrigin: string | null`. The banner no longer knows the port, so it cannot compose an address from one, and a caller that fails to resolve an origin is a compile error rather than a plausible-looking line of output. diff --git a/packages/cli/src/commands/serve-banner-external-base-url.test.ts b/packages/cli/src/commands/serve-banner-external-base-url.test.ts new file mode 100644 index 0000000000..318b056ad4 --- /dev/null +++ b/packages/cli/src/commands/serve-banner-external-base-url.test.ts @@ -0,0 +1,134 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// framework#10646 — the banner's external base URL, end to end. +// +// `format.server-ready-base-url.test.ts` pins what the banner PRINTS for a +// given origin. This file pins the other half: that the origin comes from the +// runtime's OWN precedence chain and not from a banner-local copy of it. It +// drives `resolveAuthBaseUrl` from a real environment — the same call serve's +// banner site makes — and feeds its `baseOrigin` straight into +// `printServerReady`, so a drift in either half fails here. +// +// The chain is deliberately NOT restated as a literal in this file. It is +// `resolveAuthBaseUrl`'s, whose `baseOrigin` is also what gets pushed onto the +// CSRF allow-list: if the banner and the deployment's trusted origin could +// disagree, one of the two would be wrong, and the banner is the one nobody +// checks. + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { resolveAuthBaseUrl } from './serve.js'; +import { printServerReady, type ServerReadyOptions } from '../utils/format.js'; + +const TOUCHED = ['OS_AUTH_URL', 'BETTER_AUTH_URL', 'OS_BASE_URL'] as const; + +describe('server-ready banner external base URL (#10646)', () => { + const saved: Partial> = {}; + + const bannerOpts: Omit = { + configFile: 'objectstack.config.ts', + isDev: false, + pluginCount: 1, + uiEnabled: true, + consolePath: '/_console', + mcpEnabled: true, + }; + + let lines: string[]; + let spy: ReturnType; + + beforeEach(() => { + for (const n of TOUCHED) { + saved[n] = process.env[n]; + delete process.env[n]; + } + lines = []; + spy = vi.spyOn(console, 'error').mockImplementation((...args: unknown[]) => { + lines.push(args.join(' ').replace(/\u001b\[[0-9;]*m/g, '')); + }); + }); + + afterEach(() => { + spy.mockRestore(); + for (const n of TOUCHED) { + if (saved[n] === undefined) delete process.env[n]; + else process.env[n] = saved[n]; + } + }); + + /** + * Exactly what serve's banner call site does: resolve through the runtime + * chain, print what came back. `port` is the port the server actually bound. + */ + const bootBanner = (port: number, extra: Partial = {}) => { + printServerReady({ + ...bannerOpts, + ...extra, + externalBaseOrigin: resolveAuthBaseUrl(port).baseOrigin, + }); + return lines.join('\n'); + }; + + it('prints the EE compose stack published origin, not the exposed-only port', () => { + // The measured repro (moved from cloud#1507): compose resolves + // OS_AUTH_URL to the Caddy entry point while the app binds :3000 behind it + // with no `ports:` mapping. + process.env.OS_AUTH_URL = 'http://localhost'; + + const banner = bootBanner(3000); + + expect(banner).toContain('http://localhost/_console/'); + expect(banner).toContain('http://localhost/api/v1/mcp'); + expect(banner).not.toContain('localhost:3000'); + }); + + it('follows the deployment onto its domain once OS_AUTH_URL is an https origin', () => { + process.env.OS_AUTH_URL = 'https://app.example.com'; + + const banner = bootBanner(3000); + + expect(banner).toContain('https://app.example.com/api/v1/mcp'); + expect(banner).not.toContain('localhost'); + }); + + it('honours the rest of the chain — the legacy name, then OS_BASE_URL', () => { + process.env.BETTER_AUTH_URL = 'https://legacy.example.com'; + process.env.OS_BASE_URL = 'https://base.example.com'; + expect(bootBanner(3000)).toContain('https://legacy.example.com/api/v1/mcp'); + + lines.length = 0; + delete process.env.BETTER_AUTH_URL; + expect(bootBanner(3000)).toContain('https://base.example.com/api/v1/mcp'); + }); + + it('keeps the local dev loop on the bound port when nothing is set', () => { + // The tail of the chain. Includes the dev auto-shift: 3000 busy -> 3001, + // and the banner must name the port that was actually bound. + expect(bootBanner(3001)).toContain('http://localhost:3001/_console/'); + }); + + it('prints paths only when a set-but-empty OS_AUTH_URL breaks the chain', () => { + // An empty value is NOT an unset one: the chain stops there, so neither + // OS_BASE_URL nor the localhost tail is consulted and nothing parses. The + // old banner printed http://localhost:3000 here with total confidence. + process.env.OS_AUTH_URL = ''; + process.env.OS_BASE_URL = 'https://never-consulted.example.com'; + + const banner = bootBanner(3000); + + expect(banner).toContain('/api/v1/mcp'); + expect(banner).not.toContain('http://localhost:3000'); + expect(banner).not.toContain('never-consulted'); + expect(banner).toContain('OS_AUTH_URL'); + }); + + it('prints paths only when the configured base URL has no scheme', () => { + process.env.OS_AUTH_URL = 'app.example.com'; + + const banner = bootBanner(3000); + + expect(banner).toContain('/api/v1/mcp'); + expect(banner).not.toContain('http://localhost:3000'); + // The bare hostname must not be smuggled in as an origin either. + expect(banner).not.toContain('app.example.com/api/v1/mcp'); + }); +}); diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index b02db0a705..5dece607de 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -3436,7 +3436,28 @@ export default class Serve extends Command { // #8978 — the Config:/Artifact: row must name what actually booted, // never `relativeConfig` unconditionally (see resolveBannerConfigRow). printServerReady({ - port, + // #10646 — the banner used to take `port` and compose + // `http://localhost:` itself, which is where this process + // LISTENS, not where an operator can reach it. On the EE 4.1.0 compose + // stack that address is `expose`-only (no `ports:` mapping, and less + // reachable still under `--scale app=N`) while Caddy publishes `:80` + // and `OS_AUTH_URL` is already `http://localhost` — so the Console link + // 404'd at the shell and the `MCP:` line customers paste into an AI + // client could never connect. + // + // Resolved through the runtime's OWN chain, not a banner-local copy of + // it: `resolveAuthBaseUrl` is the same function whose `baseOrigin` is + // pushed onto the CSRF allow-list a few hundred lines above, so the + // banner cannot disagree with what the deployment actually trusts. It + // reads only `process.env` and the port, so calling it here changes + // nothing about what is bound or advertised — this is printed text. + // + // `port` is the port the server ACTUALLY bound (past any dev auto-shift), + // so the `http://localhost:` tail of the chain still names the + // right address in the local dev loop. `baseOrigin` is `null` when the + // chain produced something unparseable; the banner then prints paths + // with no origin rather than a confident wrong URL. + externalBaseOrigin: resolveAuthBaseUrl(port).baseOrigin, ...resolveBannerConfigRow({ relativeConfig, useArtifactFallback, pinnedArtifact }), isDev, pluginCount: loadedPlugins.length, diff --git a/packages/cli/src/utils/format.config-artifact-row.test.ts b/packages/cli/src/utils/format.config-artifact-row.test.ts index 0564ddc45a..2aabdc96fa 100644 --- a/packages/cli/src/utils/format.config-artifact-row.test.ts +++ b/packages/cli/src/utils/format.config-artifact-row.test.ts @@ -19,7 +19,7 @@ const ANSI_SGR = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, 'g'); describe('printServerReady Config:/Artifact: row (#8978)', () => { const base: Omit = { - port: 3000, + externalBaseOrigin: 'http://localhost:3000', isDev: true, pluginCount: 1, }; diff --git a/packages/cli/src/utils/format.seed-summary.test.ts b/packages/cli/src/utils/format.seed-summary.test.ts index 74e3a5f90e..78899552db 100644 --- a/packages/cli/src/utils/format.seed-summary.test.ts +++ b/packages/cli/src/utils/format.seed-summary.test.ts @@ -14,7 +14,7 @@ import { printServerReady, type ServerReadyOptions, type SeedSourceSummary } fro */ describe('printServerReady seed summary (#3415/#3430)', () => { const base: ServerReadyOptions = { - port: 3000, + externalBaseOrigin: 'http://localhost:3000', configFile: 'objectstack.config.ts', isDev: true, pluginCount: 1, diff --git a/packages/cli/src/utils/format.server-ready-base-url.test.ts b/packages/cli/src/utils/format.server-ready-base-url.test.ts new file mode 100644 index 0000000000..62fa820534 --- /dev/null +++ b/packages/cli/src/utils/format.server-ready-base-url.test.ts @@ -0,0 +1,164 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { printServerReady, type ServerReadyOptions } from './format.js'; + +/** + * framework#10646 — the ready banner's API / Console / MCP links. + * + * The banner used to take a `port` and build `http://localhost:${port}` itself. + * That is where the process LISTENS, which stops being where a human can reach + * it the moment anything sits in front of it. Measured on the EE 4.1.0 compose + * stack (moved from cloud#1507): the app container `expose`s `:3000` with no + * `ports:` mapping — unreachable from the host, and less so still under + * `--scale app=N` — while the published entry point is Caddy on `:80` and + * `OS_AUTH_URL` is already resolved to `http://localhost`. The banner printed + * + * API: http://localhost:3000/ + * Console: http://localhost:3000/_console/ + * MCP: http://localhost:3000/api/v1/mcp + * + * so the Console link failed outright, and the `MCP:` line — the one customers + * paste into an AI client — named an address that can never connect and never + * says so. + * + * The property under test is therefore not "the URL looks right", it is + * **every absolute URL in the banner is the origin the caller resolved, and + * when no origin could be resolved the banner prints no absolute URL at all**. + * The second half is the interesting one: a missing address sends the operator + * to look one up, a confident wrong one gets copied. + */ +describe('printServerReady links (#10646)', () => { + const base: Omit = { + configFile: 'objectstack.config.ts', + isDev: false, + pluginCount: 1, + uiEnabled: true, + consolePath: '/_console', + mcpEnabled: true, + }; + + let lines: string[]; + let spy: ReturnType; + + beforeEach(() => { + lines = []; + // stderr, not stdout (#7915) — the whole banner is a diagnostic. SGR + // escapes stripped so the assertions hold whether or not chalk colors. + spy = vi.spyOn(console, 'error').mockImplementation((...args: unknown[]) => { + lines.push(args.join(' ').replace(/\u001b\[[0-9;]*m/g, '')); + }); + }); + + afterEach(() => spy.mockRestore()); + + /** The one banner line whose label matches, ANSI already stripped. */ + const row = (label: string): string => + lines.find((l) => l.includes(`${label}:`)) ?? ''; + + /** The dim MCP hint — the line customers copy the skill URL out of. */ + const skillRow = (): string => lines.find((l) => l.includes('skill:')) ?? ''; + + describe('a resolved external base is what gets printed', () => { + it('prints the EE compose stack published origin, not the bound port', () => { + // The #10646 repro verbatim: Caddy on :80, app bound to :3000 inside. + printServerReady({ ...base, externalBaseOrigin: 'http://localhost' }); + + expect(row('API')).toContain('http://localhost/'); + expect(row('Console')).toContain('http://localhost/_console/'); + expect(row('MCP')).toContain('http://localhost/api/v1/mcp'); + expect(skillRow()).toContain('http://localhost/api/v1/mcp/skill'); + // The whole defect in one assertion: the internal port must not appear. + expect(lines.join('\n')).not.toContain(':3000'); + }); + + it('follows the deployment onto a real domain (the README HTTPS step)', () => { + printServerReady({ ...base, externalBaseOrigin: 'https://app.example.com' }); + + expect(row('API')).toContain('https://app.example.com/'); + expect(row('Console')).toContain('https://app.example.com/_console/'); + expect(row('MCP')).toContain('https://app.example.com/api/v1/mcp'); + expect(skillRow()).toContain('https://app.example.com/api/v1/mcp/skill'); + expect(lines.join('\n')).not.toContain('localhost'); + }); + + it('keeps a non-default port when the reachable origin carries one', () => { + printServerReady({ ...base, externalBaseOrigin: 'https://app.example.com:8443' }); + expect(row('MCP')).toContain('https://app.example.com:8443/api/v1/mcp'); + }); + + it('leaves the local dev loop exactly as it was', () => { + // The tail of the runtime's chain is still `http://localhost:`, and + // on a laptop that IS the reachable address. Guards the fix against + // over-reach: `os dev` must keep its clickable Console link. + printServerReady({ ...base, isDev: true, externalBaseOrigin: 'http://localhost:3001' }); + + expect(row('API')).toContain('http://localhost:3001/'); + expect(row('Console')).toContain('http://localhost:3001/_console/'); + expect(row('MCP')).toContain('http://localhost:3001/api/v1/mcp'); + }); + }); + + describe('an unresolvable base prints NO absolute URL', () => { + /** Any absolute URL that is not the one this file's own hint text cites. */ + const guessedUrl = /https?:\/\/(?!app\.example\.com\b)/; + + it('prints paths only — never a fabricated origin', () => { + printServerReady({ ...base, externalBaseOrigin: null }); + + const banner = lines.join('\n'); + // The load-bearing assertion. Not "does not contain localhost" — ANY + // absolute URL here would be a guess, and the guess is the defect. The + // one exemption is the hint's own `https://app.example.com` placeholder, + // which is explicitly an example and not a link to this deployment. + expect(banner).not.toMatch(guessedUrl); + expect(banner).not.toContain('localhost'); + }); + + it('still names the paths, so the operator keeps the information', () => { + // "No absolute URL" is not "no line". The operator must still learn that + // MCP is mounted and where — they supply the origin they actually use. + printServerReady({ ...base, externalBaseOrigin: null }); + + expect(row('API').trim()).toMatch(/API:\s+\/$/); + expect(row('Console').trim()).toMatch(/Console:\s+\/_console\/$/); + expect(row('MCP').trim()).toMatch(/MCP:\s+\/api\/v1\/mcp$/); + expect(skillRow()).toContain('skill: /api/v1/mcp/skill'); + }); + + it('says why the origin is missing and names the variable that fixes it', () => { + printServerReady({ ...base, externalBaseOrigin: null }); + + const banner = lines.join('\n'); + expect(banner).toContain('external base URL could not be resolved'); + expect(banner).toContain('OS_AUTH_URL'); + }); + + it('does not print the hint when the base IS resolved', () => { + printServerReady({ ...base, externalBaseOrigin: 'https://app.example.com' }); + expect(lines.join('\n')).not.toContain('could not be resolved'); + }); + + it('drops the origin from the MCP lines even when the Console is off', () => { + // The MCP line is the paste target; it must not keep an origin of its own + // on any boot shape. + printServerReady({ ...base, uiEnabled: false, consolePath: undefined, externalBaseOrigin: null }); + + expect(row('Console')).toBe(''); + expect(row('MCP').trim()).toMatch(/MCP:\s+\/api\/v1\/mcp$/); + expect(skillRow()).toContain('skill: /api/v1/mcp/skill'); + expect(lines.join('\n')).not.toMatch(guessedUrl); + }); + }); + + it('cannot compose an address from a port at COMPILE time', () => { + // The structural half of the fix, checked by `pnpm typecheck` (tests are + // type-checked — AGENTS.md) rather than by the assertion below: `port` is + // gone from the options, so re-deriving `http://localhost:` inside + // the banner is no longer expressible, and a caller that forgets to resolve + // an origin fails to compile instead of silently getting localhost back. + // @ts-expect-error — `port` was removed with #10646; nothing reads it. + printServerReady({ ...base, externalBaseOrigin: null, port: 3000 }); + expect(lines.some((l) => l.includes('Server is ready'))).toBe(true); + }); +}); diff --git a/packages/cli/src/utils/format.tenancy.test.ts b/packages/cli/src/utils/format.tenancy.test.ts index 3e8ca8f0a8..ffe5e645c4 100644 --- a/packages/cli/src/utils/format.tenancy.test.ts +++ b/packages/cli/src/utils/format.tenancy.test.ts @@ -26,7 +26,7 @@ import { printServerReady, type ServerReadyOptions } from './format.js'; */ describe('printServerReady Tenancy row (#4801, ADR-0105 D1)', () => { const base: ServerReadyOptions = { - port: 3000, + externalBaseOrigin: 'http://localhost:3000', configFile: 'objectstack.config.ts', isDev: true, pluginCount: 1, @@ -173,11 +173,11 @@ describe('printServerReady Tenancy row (#4801, ADR-0105 D1)', () => { // re-wiring the banner to the legacy knob can no longer compile, and the // retired field name can no longer be passed in silently ignored. // @ts-expect-error — tenancyPosture is a TenancyPosture, never a boolean. - printServerReady({ port: 1, configFile: 'c', isDev: true, pluginCount: 0, tenancyPosture: true }); + printServerReady({ externalBaseOrigin: null, configFile: 'c', isDev: true, pluginCount: 0, tenancyPosture: true }); // @ts-expect-error — `multiTenant` was removed with #4801; nothing reads it. - printServerReady({ port: 1, configFile: 'c', isDev: true, pluginCount: 0, multiTenant: true }); + printServerReady({ externalBaseOrigin: null, configFile: 'c', isDev: true, pluginCount: 0, multiTenant: true }); // @ts-expect-error — and an arbitrary string is not a posture. - printServerReady({ port: 1, configFile: 'c', isDev: true, pluginCount: 0, tenancyPosture: 'multi' }); + printServerReady({ externalBaseOrigin: null, configFile: 'c', isDev: true, pluginCount: 0, tenancyPosture: 'multi' }); expect(lines.filter((l) => l.includes('Tenancy:'))).toHaveLength(2); }); }); diff --git a/packages/cli/src/utils/format.ts b/packages/cli/src/utils/format.ts index f2eae30076..6108b8f96b 100644 --- a/packages/cli/src/utils/format.ts +++ b/packages/cli/src/utils/format.ts @@ -397,7 +397,47 @@ export function collectMetadataStats(config: any): MetadataStats { // ─── Server Ready Banner ──────────────────────────────────────────── export interface ServerReadyOptions { - port: number; + /** + * The origin an operator can actually reach this deployment on — the base + * every absolute URL in the banner is built from — or `null` when none could + * be determined. + * + * ## Why this is handed in, and why the port is not + * + * This field replaced a `port: number` that the banner turned into + * `http://localhost:${port}` itself. That address is the one the process + * BINDS, which is not the one a human can open the moment anything sits in + * front of it. Measured on the EE 4.1.0 compose stack (#10646): the app + * container `expose`s `:3000` with no `ports:` mapping — unreachable from the + * host, and more so under `--scale app=N` — while the published entry point + * is Caddy on `:80` and `OS_AUTH_URL` is already resolved to + * `http://localhost`. The banner printed `http://localhost:3000/_console/` + * anyway: a Console link that fails outright, and an `MCP:` line that is the + * address customers paste into an AI client, where a wrong absolute URL never + * fails loudly — it just never connects. + * + * So the banner no longer knows the port at all. It cannot compose an address + * from one, which makes the old defect a COMPILE error rather than a + * plausible-looking line of output. The caller resolves the origin through + * the runtime's own chain (`resolveAuthBaseUrl` in `serve`: `OS_AUTH_URL` → + * legacy `BETTER_AUTH_URL` → `OS_BASE_URL` → `http://localhost:`) and + * hands the answer here. That is the same value the CSRF allow-list and + * first-party auth redirects key off, so the banner and the runtime cannot + * disagree about where this deployment lives — a second, banner-local notion + * of "external base" is exactly the drift this field exists to prevent. + * + * ## `null` means "say nothing", never "guess" + * + * `null` is what the resolver reports when the chain produced something that + * will not parse — a set-but-empty `OS_AUTH_URL=` (which does NOT fall + * through to the rest of the chain), or a value with no scheme. The banner + * then prints the PATHS with no origin in front of them. A missing address + * sends the operator to look one up; a confidently wrong one gets copied. + * + * Required on purpose: an absent field must not be able to mean + * `http://localhost:` again by omission. + */ + externalBaseOrigin: string | null; /** * The authored config file, relative to cwd — printed in the `Config:` * row. Omit it when the boot did not actually read a config (#8978): on @@ -570,17 +610,33 @@ export interface AutomationReadySummary { * they serve every command, some of whose stdout IS the program's output. */ export function printServerReady(opts: ServerReadyOptions) { - const base = `http://localhost:${opts.port}`; + // #10646 — the address the OPERATOR can reach, never the one this process + // binds. See ServerReadyOptions.externalBaseOrigin for the measured case + // these two came apart in; `null` there means the deployment's external base + // is UNKNOWN, and the rule for that case is the whole design: print the path + // and no origin. Every absolute URL below goes through `link()`, so there is + // exactly one place that decides, and no line can quietly grow its own base. + const base = opts.externalBaseOrigin; + const link = (path: string) => (base === null ? path : base + path); console.error(''); console.error(chalk.bold.green(' ✓ Server is ready')); console.error(''); - console.error(chalk.cyan(' ➜') + chalk.bold(' API: ') + chalk.cyan(base + '/')); + console.error(chalk.cyan(' ➜') + chalk.bold(' API: ') + chalk.cyan(link('/'))); if (opts.uiEnabled && opts.consolePath) { - console.error(chalk.cyan(' ➜') + chalk.bold(' Console: ') + chalk.cyan(base + opts.consolePath + '/')); + console.error(chalk.cyan(' ➜') + chalk.bold(' Console: ') + chalk.cyan(link(opts.consolePath + '/'))); } if (opts.mcpEnabled) { - console.error(chalk.cyan(' ➜') + chalk.bold(' MCP: ') + chalk.cyan(base + '/api/v1/mcp')); - console.error(chalk.dim(` connect an AI client (Claude Code, Cursor, …) · skill: ${base}/api/v1/mcp/skill`)); + console.error(chalk.cyan(' ➜') + chalk.bold(' MCP: ') + chalk.cyan(link('/api/v1/mcp'))); + console.error(chalk.dim(` connect an AI client (Claude Code, Cursor, …) · skill: ${link('/api/v1/mcp/skill')}`)); + } + if (base === null) { + // Say why the origin is missing and name the one variable that fixes it, + // rather than leaving the operator to infer it from truncated links. The + // boot already complains in more detail (formatUnusableAuthBaseUrlDiagnostic + // in `serve`), but only on the branch that registers auth — so on every + // other boot shape this line is the only notice there is. + console.error(chalk.dim(' paths only — this deployment\'s external base URL could not be resolved;')); + console.error(chalk.dim(' set OS_AUTH_URL to its public origin (e.g. https://app.example.com)')); } if (opts.seededAdmin) { console.error('');