@@ -89,7 +89,28 @@ describe('os dev MCP connect hint — origin (#16734)', () => {
8989 * child's banner (its own call site's expression, verbatim) and then the
9090 * parent's connect hint, both told the port the server ACTUALLY bound.
9191 */
92- const boot = ( boundPort : number , name = 'hotcrm' ) => {
92+ const boot = ( boundPort : number , name = 'hotcrm' , boundProtocol : 'http' | 'https' = 'http' ) => {
93+ printServerReady ( {
94+ ...bannerOpts ,
95+ externalBaseOrigin : resolveAuthBaseUrl ( boundPort , boundProtocol ) . baseOrigin ,
96+ } ) ;
97+ printMcpConnectHint ( { boundPort, name, boundProtocol } ) ;
98+ return lines . join ( '\n' ) ;
99+ } ;
100+
101+ /**
102+ * The same boot with the protocol argument WITHHELD from both printers — the
103+ * expression this file carried before `--cert`/`--key` existed, character for
104+ * character.
105+ *
106+ * ⭐ This is the ABLATION LEG for acceptance 2 (「无 flag 时逐字节等于今天」,
107+ * #16804). The pins below drive it beside {@link boot}'s no-flag case and
108+ * require the two to be byte-identical, so the claim "nothing changes without
109+ * the flags" is measured rather than asserted: it would go red the day the
110+ * derived protocol leaked into the default, and it cannot pass by both legs
111+ * being broken in the same direction.
112+ */
113+ const bootWithoutProtocolArg = ( boundPort : number , name = 'hotcrm' ) => {
93114 printServerReady ( { ...bannerOpts , externalBaseOrigin : resolveAuthBaseUrl ( boundPort ) . baseOrigin } ) ;
94115 printMcpConnectHint ( { boundPort, name } ) ;
95116 return lines . join ( '\n' ) ;
@@ -203,10 +224,117 @@ describe('os dev MCP connect hint — origin (#16734)', () => {
203224 } ) ;
204225 } ) ;
205226
227+ // ── #16804 ─ a TLS listener, and the three surfaces that follow it ──────
228+ describe ( 'under --cert/--key the derived origin is https, and everything follows' , ( ) => {
229+ it ( 'acceptance 1: with OS_AUTH_URL UNSET, banner and hint both give https' , ( ) => {
230+ // Every chain variable is deleted by `beforeEach`, so the only thing
231+ // that can produce `https` here is the listener protocol reaching the
232+ // resolver's built-in tail.
233+ const output = boot ( 3000 , 'hotcrm' , 'https' ) ;
234+
235+ expect ( output ) . toContain ( 'MCP: https://localhost:3000/api/v1/mcp' ) ;
236+ expect ( output ) . toContain ( 'Endpoint https://localhost:3000/api/v1/mcp' ) ;
237+ expect ( output ) . toContain ( 'Skill https://localhost:3000/api/v1/mcp/skill' ) ;
238+ expect ( output ) . toContain (
239+ 'Connect claude mcp add --transport http hotcrm https://localhost:3000/api/v1/mcp' ,
240+ ) ;
241+ // The plain-http address must not appear anywhere in a TLS boot's output:
242+ // it is the one address a client on this port cannot reach.
243+ expect ( output ) . not . toContain ( 'http://localhost:3000' ) ;
244+ expect ( mcpOrigins ( output ) ) . toEqual ( [ 'https://localhost:3000' ] ) ;
245+ } ) ;
246+
247+ it ( "follows dev's auto-shifted port under TLS too" , ( ) => {
248+ expect ( mcpOrigins ( boot ( 3001 , 'hotcrm' , 'https' ) ) ) . toEqual ( [ 'https://localhost:3001' ] ) ;
249+ } ) ;
250+
251+ it ( 'acceptance 3: `OS_AUTH_URL` still WINS over the derived https origin' , ( ) => {
252+ // The override direction that matters in practice: a developer
253+ // terminating TLS locally but reached through a tunnel on another host.
254+ process . env . OS_AUTH_URL = 'https://tunnel.example.com' ;
255+ expect ( mcpOrigins ( boot ( 3000 , 'hotcrm' , 'https' ) ) ) . toEqual ( [ 'https://tunnel.example.com' ] ) ;
256+ } ) ;
257+
258+ it ( 'acceptance 3, the awkward direction: an http OS_AUTH_URL wins as well' , ( ) => {
259+ // ⛔ Deliberately NOT "upgraded" to https. `OS_AUTH_URL` names where the
260+ // deployment is REACHED — behind a TLS-terminating proxy that forwards
261+ // plain http, or in a test harness, that is a deliberate statement about
262+ // a different hop, and a default has no standing to overrule it.
263+ process . env . OS_AUTH_URL = 'http://proxied.example.com' ;
264+ expect ( mcpOrigins ( boot ( 3000 , 'hotcrm' , 'https' ) ) ) . toEqual ( [ 'http://proxied.example.com' ] ) ;
265+ } ) ;
266+
267+ it ( 'the rest of the configured chain keeps its precedence under TLS' , ( ) => {
268+ process . env . OS_BASE_URL = 'https://base.example.com' ;
269+ expect ( mcpOrigins ( boot ( 3000 , 'hotcrm' , 'https' ) ) ) . toEqual ( [ 'https://base.example.com' ] ) ;
270+ } ) ;
271+
272+ it ( 'an unusable value stays unusable — TLS does not manufacture an origin' , ( ) => {
273+ process . env . OS_AUTH_URL = '' ;
274+ const output = boot ( 3000 , 'hotcrm' , 'https' ) ;
275+ expect ( mcpOrigins ( output ) ) . toEqual ( [ ] ) ;
276+ expect ( output ) . not . toContain ( 'claude mcp add' ) ;
277+ expect ( output ) . not . toContain ( 'https://localhost:3000' ) ;
278+ } ) ;
279+ } ) ;
280+
281+ // ── #16804 ─ acceptance 2, as an ABLATION rather than a claim ──────────
282+ describe ( 'without the flags the output is byte-for-byte what it was' , ( ) => {
283+ it ( 'the no-flag boot equals the boot that never passes a protocol at all' , ( ) => {
284+ // Leg A: the call shape this file used before `--cert`/`--key` existed.
285+ const before = bootWithoutProtocolArg ( 3000 , 'my-app' ) ;
286+ lines . length = 0 ;
287+ // Leg B: the same boot through today's call shape, no flags given.
288+ const after = boot ( 3000 , 'my-app' , 'http' ) ;
289+
290+ expect ( after ) . toBe ( before ) ;
291+ // And the byte the two legs are about: still plain http, on the bound port.
292+ expect ( mcpOrigins ( after ) ) . toEqual ( [ 'http://localhost:3000' ] ) ;
293+ } ) ;
294+
295+ it ( 'the two legs also agree on an auto-shifted port and an ephemeral one' , ( ) => {
296+ for ( const port of [ 3001 , 45064 ] ) {
297+ lines . length = 0 ;
298+ const before = bootWithoutProtocolArg ( port , 'my-app' ) ;
299+ lines . length = 0 ;
300+ expect ( boot ( port , 'my-app' , 'http' ) ) . toBe ( before ) ;
301+ }
302+ } ) ;
303+
304+ it ( 'and the legs DISCRIMINATE — the https leg differs from both' , ( ) => {
305+ // Without this, two legs that both silently produced nothing would pass.
306+ const plain = bootWithoutProtocolArg ( 3000 , 'my-app' ) ;
307+ lines . length = 0 ;
308+ const tls = boot ( 3000 , 'my-app' , 'https' ) ;
309+
310+ expect ( tls ) . not . toBe ( plain ) ;
311+ expect ( mcpOrigins ( plain ) ) . toEqual ( [ 'http://localhost:3000' ] ) ;
312+ expect ( mcpOrigins ( tls ) ) . toEqual ( [ 'https://localhost:3000' ] ) ;
313+ } ) ;
314+ } ) ;
315+
206316 // ── What only the source can say ────────────────────────────────────────
207317 describe ( 'the call site feeds the printer the bound port, and nothing else' , ( ) => {
208318 it ( 'hands `printMcpConnectHint` the ACTUALLY BOUND port' , ( ) => {
209- expect ( DEV_SOURCE ) . toContain ( 'printMcpConnectHint({ boundPort: actual,' ) ;
319+ expect ( DEV_SOURCE ) . toContain ( 'printMcpConnectHint({' ) ;
320+ expect ( DEV_SOURCE ) . toContain ( 'boundPort: actual,' ) ;
321+ } ) ;
322+
323+ it ( 'hands it the protocol it FORWARDED, not one derived a second time (#16804)' , ( ) => {
324+ // The parent's `boundProtocol` comes from the same `tlsIntent` that built
325+ // the child's argv, so the scheme the hint prints and the scheme the
326+ // child bound cannot part company. A second `resolveDevTlsIntent` call
327+ // here would be a second reader, free to disagree.
328+ expect ( DEV_SOURCE ) . toContain ( 'boundProtocol,' ) ;
329+ expect ( DEV_SOURCE ) . toContain ( 'const boundProtocol: ListenerProtocol = listenerProtocol(tlsIntent);' ) ;
330+ expect ( DEV_SOURCE . match ( / r e s o l v e D e v T l s I n t e n t \( / g) ?? [ ] ) . toHaveLength ( 1 ) ;
331+ } ) ;
332+
333+ it ( 'forwards the cert/key PATHS to the serve child through the shared contract' , ( ) => {
334+ expect ( DEV_SOURCE ) . toContain ( '...devTlsChildArgs(tlsIntent),' ) ;
335+ // ⛔ and never the bytes: one reader of the file, one owner of the refusal.
336+ expect ( DEV_SOURCE ) . not . toContain ( 'readDevTlsMaterial' ) ;
337+ expect ( DEV_SOURCE ) . not . toContain ( 'readFileSync(flags.cert' ) ;
210338 } ) ;
211339
212340 it ( 'builds no address out of the listening message any more' , ( ) => {
0 commit comments