From 96eb0fb010682755dfb2980fb828178f70a04f42 Mon Sep 17 00:00:00 2001 From: ops-deploy-01 Date: Tue, 25 Aug 2026 10:08:12 -0500 Subject: [PATCH 1/2] =?UTF-8?q?fix(#1394):=20recover-token=20headless=20?= =?UTF-8?q?=E2=80=94=20dual=20path=20(flag=20+=20piped=20stdin)=20with=20d?= =?UTF-8?q?ocumented=20precedence?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ensureSession credential resolution when sign-in is needed, in order: 1. explicit --email flag (highest) 2. non-TTY stdin: line 1 email, line 2 password (printf 'email\npassword\n' | …) — for headless callers without argv access 3. interactive prompt (TTY only) Precedence documented on ensureSession and in --help; headless with NO credentials exits 2 with guidance instead of hanging on prompts. Password never read from argv (ps-visible); stdin line kept untrimmed. New piped-credentials.ts module so the reader is testable against real streams (4 stream specs) while token-ops specs mock the seam (5 specs). --- packages/mosaic/src/commands/gateway.ts | 5 +- .../gateway/piped-credentials.spec.ts | 31 ++++++ .../src/commands/gateway/piped-credentials.ts | 26 +++++ .../src/commands/gateway/token-ops.spec.ts | 101 ++++++++++++++++++ .../mosaic/src/commands/gateway/token-ops.ts | 49 +++++++-- 5 files changed, 202 insertions(+), 10 deletions(-) create mode 100644 packages/mosaic/src/commands/gateway/piped-credentials.spec.ts create mode 100644 packages/mosaic/src/commands/gateway/piped-credentials.ts create mode 100644 packages/mosaic/src/commands/gateway/token-ops.spec.ts diff --git a/packages/mosaic/src/commands/gateway.ts b/packages/mosaic/src/commands/gateway.ts index 4a7837d5..b0a1b2e0 100644 --- a/packages/mosaic/src/commands/gateway.ts +++ b/packages/mosaic/src/commands/gateway.ts @@ -172,9 +172,10 @@ export function registerGatewayCommand(program: Command): void { .command('recover-token') .description('Recover an admin token — prompts for login if no valid session exists') .option('-g, --gateway ', 'Gateway URL (overrides meta.json)') - .action(async (cmdOpts: { gateway?: string }) => { + .option('-e, --email ', 'Headless: account email (password read from stdin line 2)') + .action(async (cmdOpts: { gateway?: string; email?: string }) => { const { runRecoverToken } = await import('./gateway/token-ops.js'); - await runRecoverToken(cmdOpts.gateway); + await runRecoverToken(cmdOpts.gateway, cmdOpts.email); }); // ─── logs ─────────────────────────────────────────────────────────────── diff --git a/packages/mosaic/src/commands/gateway/piped-credentials.spec.ts b/packages/mosaic/src/commands/gateway/piped-credentials.spec.ts new file mode 100644 index 00000000..c4f3fa54 --- /dev/null +++ b/packages/mosaic/src/commands/gateway/piped-credentials.spec.ts @@ -0,0 +1,31 @@ +import { describe, it, expect } from 'vitest'; +import { Readable } from 'node:stream'; +import { readCredentialsFromPipedStdin } from './piped-credentials.js'; + +describe('readCredentialsFromPipedStdin — #1394 stdin dual path (real streams)', () => { + it('reads exactly two lines; email trimmed, password as-is', async () => { + const r = await readCredentialsFromPipedStdin( + Readable.from([' a@b.c \n', 'pw with spaces \n']), + ); + expect(r.email).toBe('a@b.c'); + expect(r.password).toBe('pw with spaces '); + }); + + it('empty stdin → nulls (the headless-no-credentials shape)', async () => { + const r = await readCredentialsFromPipedStdin(Readable.from([''])); + expect(r).toEqual({ email: null, password: null }); + }); + + it('single line only → email set, password null', async () => { + const r = await readCredentialsFromPipedStdin(Readable.from(['only-email\n'])); + expect(r.email).toBe('only-email'); + expect(r.password).toBeNull(); + }); + + it('stops after two lines even if more follow', async () => { + const r = await readCredentialsFromPipedStdin( + Readable.from(['e@b.c\n', 'pw\n', 'extra\n', 'more\n']), + ); + expect(r).toEqual({ email: 'e@b.c', password: 'pw' }); + }); +}); diff --git a/packages/mosaic/src/commands/gateway/piped-credentials.ts b/packages/mosaic/src/commands/gateway/piped-credentials.ts new file mode 100644 index 00000000..57ae612f --- /dev/null +++ b/packages/mosaic/src/commands/gateway/piped-credentials.ts @@ -0,0 +1,26 @@ +import { createInterface } from 'node:readline'; + +/** + * Read email + password as two lines from non-TTY stdin (the headless dual + * path for callers that cannot pass argv: printf 'email\npassword\n' | …). + * Caller gates on !isTTY; the password line is kept as-is (no trim — + * whitespace may be intentional). + * + * Separate module (not login.ts) so tests can exercise the REAL reader + * against real streams while token-ops specs mock this seam cleanly. + */ +export function readCredentialsFromPipedStdin( + input: NodeJS.ReadableStream = process.stdin, +): Promise<{ email: string | null; password: string | null }> { + return new Promise((resolve) => { + const lines: string[] = []; + const rl = createInterface({ input }); + rl.on('line', (l) => { + lines.push(l); + if (lines.length >= 2) rl.close(); + }); + rl.on('close', () => { + resolve({ email: (lines[0] ?? '').trim() || null, password: lines[1] ?? null }); + }); + }); +} diff --git a/packages/mosaic/src/commands/gateway/token-ops.spec.ts b/packages/mosaic/src/commands/gateway/token-ops.spec.ts new file mode 100644 index 00000000..0f1e0f98 --- /dev/null +++ b/packages/mosaic/src/commands/gateway/token-ops.spec.ts @@ -0,0 +1,101 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('../../auth.js', () => ({ + loadSession: vi.fn(), + validateSession: vi.fn(), + signIn: vi.fn(), + saveSession: vi.fn(), +})); + +vi.mock('./login.js', () => ({ + getGatewayUrl: vi.fn().mockReturnValue('http://localhost:14242'), + promptLine: vi.fn(), + promptSecret: vi.fn(), +})); + +vi.mock('./piped-credentials.js', () => ({ + readCredentialsFromPipedStdin: vi.fn(), +})); + +vi.mock('./daemon.js', () => ({ + readMeta: vi.fn(), + writeMeta: vi.fn(), +})); + +import { ensureSession } from './token-ops.js'; +import { loadSession, validateSession, signIn, saveSession } from '../../auth.js'; +import { promptLine, promptSecret } from './login.js'; +import { readCredentialsFromPipedStdin } from './piped-credentials.js'; + +const URL = 'http://localhost:14242'; + +function asNonTTY(): void { + Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true }); +} + +describe('ensureSession — #1394 credential precedence (flag > piped stdin > prompt)', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(loadSession).mockReturnValue(null); + asNonTTY(); + }); + + it('stored valid session wins; no credentials touched', async () => { + vi.mocked(loadSession).mockReturnValue({ cookie: 'SESS', email: 'a@b.c' } as never); + vi.mocked(validateSession).mockResolvedValue(true); + await expect(ensureSession(URL)).resolves.toBe('SESS'); + expect(signIn).not.toHaveBeenCalled(); + }); + + it('flag email + stdin password: FLAG wins for email, stdin supplies the password', async () => { + vi.mocked(signIn).mockResolvedValue({ cookie: 'NEW', email: 'flag@b.c' } as never); + vi.mocked(readCredentialsFromPipedStdin).mockResolvedValue({ + email: 'stdin@b.c', + password: 'stdin-pw', + }); + + await ensureSession(URL, { email: 'flag@b.c' }); + + expect(signIn).toHaveBeenCalledWith(URL, 'flag@b.c', 'stdin-pw'); + expect(promptLine).not.toHaveBeenCalled(); + expect(promptSecret).not.toHaveBeenCalled(); + }); + + it('stdin-only path (no flag): both credentials from piped lines', async () => { + vi.mocked(signIn).mockResolvedValue({ cookie: 'NEW2', email: 's@b.c' } as never); + vi.mocked(readCredentialsFromPipedStdin).mockResolvedValue({ + email: 's@b.c', + password: 'spw', + }); + + await ensureSession(URL); + expect(signIn).toHaveBeenCalledWith(URL, 's@b.c', 'spw'); + }); + + it('no credentials headless → exit(2) with --email guidance; signIn untouched', async () => { + vi.mocked(readCredentialsFromPipedStdin).mockResolvedValue({ email: null, password: null }); + const exit = vi.spyOn(process, 'exit').mockImplementation((() => { + throw new Error('EXIT'); + }) as never); + const err = vi.spyOn(console, 'error').mockImplementation(() => {}); + + await expect(ensureSession(URL)).rejects.toThrow('EXIT'); + expect(exit).toHaveBeenCalledWith(2); + expect(err).toHaveBeenCalledWith(expect.stringContaining('--email')); + expect(signIn).not.toHaveBeenCalled(); + + exit.mockRestore(); + err.mockRestore(); + }); + + it('successful sign-in persists the session', async () => { + vi.mocked(signIn).mockResolvedValue({ cookie: 'C', email: 'a@b.c' } as never); + vi.mocked(readCredentialsFromPipedStdin).mockResolvedValue({ + email: 'a@b.c', + password: 'pw', + }); + + await ensureSession(URL); + expect(saveSession).toHaveBeenCalledWith(URL, expect.anything()); + }); +}); diff --git a/packages/mosaic/src/commands/gateway/token-ops.ts b/packages/mosaic/src/commands/gateway/token-ops.ts index 412dd318..79e4b1bd 100644 --- a/packages/mosaic/src/commands/gateway/token-ops.ts +++ b/packages/mosaic/src/commands/gateway/token-ops.ts @@ -1,6 +1,7 @@ import { loadSession, validateSession, signIn, saveSession } from '../../auth.js'; import { readMeta, writeMeta } from './daemon.js'; import { getGatewayUrl, promptLine, promptSecret } from './login.js'; +import { readCredentialsFromPipedStdin } from './piped-credentials.js'; interface MintedToken { id: string; @@ -107,8 +108,24 @@ export async function requireSession(gatewayUrl: string): Promise { * Ensure a valid session for the gateway, prompting for credentials if needed. * On sign-in failure, prints the error and exits non-zero. * Returns the session cookie. + * + * Credential precedence when sign-in is needed (#1394): + * 1. explicit opts (--email flag; highest) + * 2. non-TTY stdin — first line email, second line password (headless dual + * path for callers without argv access: printf 'email\npassword\n' | …) + * 3. interactive prompt (TTY only) */ -export async function ensureSession(gatewayUrl: string): Promise { +export interface SessionCredentialOptions { + /** Email from an explicit flag (argv). Highest precedence. */ + email?: string; + /** Password from an explicit source. Rare; passwords normally come via stdin/prompt. */ + password?: string; +} + +export async function ensureSession( + gatewayUrl: string, + opts: SessionCredentialOptions = {}, +): Promise { // Try the stored session first const session = loadSession(gatewayUrl); if (session) { @@ -119,10 +136,25 @@ export async function ensureSession(gatewayUrl: string): Promise { console.log(`No session found for ${gatewayUrl}. Please sign in.`); } - // Prompt for credentials — password must not be echoed to the terminal - const email = await promptLine('Email: '); - // Do not trim password — it may contain intentional leading/trailing whitespace - const password = await promptSecret('Password: '); + let email = opts.email; + let password = opts.password; + if ((!email || !password) && !process.stdin.isTTY) { + const piped = await readCredentialsFromPipedStdin(); + email = email ?? piped.email ?? undefined; + password = password ?? piped.password ?? undefined; + } + if (!email || !password) { + if (!process.stdin.isTTY) { + console.error( + 'No valid session and no credentials available headlessly. Provide --email plus ' + + "a password line on stdin (printf 'email\\npassword\\n' | …), or run interactively.", + ); + process.exit(2); + } + email = await promptLine('Email: '); + // Do not trim password — it may contain intentional leading/trailing whitespace + password = await promptSecret('Password: '); + } const auth = await signIn(gatewayUrl, email, password).catch((err: unknown) => { console.error(err instanceof Error ? err.message : String(err)); @@ -146,11 +178,12 @@ export async function runRotateToken(gatewayUrl?: string): Promise { } /** - * `mosaic gateway config recover-token` — prompts for login if no session exists. + * `mosaic gateway config recover-token` — signs in if no session exists. + * Passes the --email flag through to ensureSession (#1394 dual path). */ -export async function runRecoverToken(gatewayUrl?: string): Promise { +export async function runRecoverToken(gatewayUrl?: string, email?: string): Promise { const url = getGatewayUrl(gatewayUrl); - const cookie = await ensureSession(url); + const cookie = await ensureSession(url, { email }); const label = `CLI recovery token (${new Date().toISOString().slice(0, 16).replace('T', ' ')})`; const minted = await mintAdminToken(url, cookie, label); persistToken(url, minted); -- 2.54.0 From 4dfbab868d774b8aa9c70470c44b5836c4f1c49a Mon Sep 17 00:00:00 2001 From: ops-deploy-01 Date: Tue, 25 Aug 2026 10:41:14 -0500 Subject: [PATCH 2/2] test(#1394): pre-existing recover-token specs follow the new non-TTY path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The old specs mocked promptLine/promptSecret and relied on the prompt path being taken unconditionally — which is precisely the behavior #1394 removes (non-TTY must NOT sit on prompts). Under vitest stdin is non-TTY, so the new code correctly routed to piped-stdin credentials and the specs hung on the unmocked reader (4/7 timeouts). The specs now mock the piped-credentials seam with the same fixed values and assert that seam (not the prompts) was consulted; titles state the actual path. All 7 pass; suite 1667/1667 (remaining local failure is the known host pi-invariant, CI pins pi 0.84.1). --- .../commands/gateway/recover-token.spec.ts | 21 +++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/packages/mosaic/src/commands/gateway/recover-token.spec.ts b/packages/mosaic/src/commands/gateway/recover-token.spec.ts index d00bc0db..aef6c75b 100644 --- a/packages/mosaic/src/commands/gateway/recover-token.spec.ts +++ b/packages/mosaic/src/commands/gateway/recover-token.spec.ts @@ -16,11 +16,20 @@ vi.mock('./daemon.js', () => ({ vi.mock('./login.js', () => ({ getGatewayUrl: vi.fn().mockReturnValue('http://localhost:14242'), - // promptLine/promptSecret are used by ensureSession; return fixed values so tests don't block on stdin + // promptLine/promptSecret are used by ensureSession on the TTY path; return fixed + // values so tests never block on stdin. promptLine: vi.fn().mockResolvedValue('test@example.com'), promptSecret: vi.fn().mockResolvedValue('test-password'), })); +// #1394: non-TTY runs resolve credentials from piped stdin instead of prompts. +vi.mock('./piped-credentials.js', () => ({ + readCredentialsFromPipedStdin: vi.fn().mockResolvedValue({ + email: 'test@example.com', + password: 'test-password', + }), +})); + const mockFetch = vi.fn(); vi.stubGlobal('fetch', mockFetch); @@ -65,7 +74,7 @@ describe('ensureSession', () => { expect(mockSignIn).not.toHaveBeenCalled(); }); - it('prompts for credentials and signs in when stored session is invalid', async () => { + it('resolves piped-stdin credentials and signs in when stored session is invalid', async () => { mockLoadSession.mockReturnValueOnce({ cookie: 'old-cookie', userId: 'u1', email: 'a@b.com' }); mockValidateSession.mockResolvedValueOnce(false); const newAuth = { cookie: fakeCookie, userId: 'u2', email: 'a@b.com' }; @@ -76,7 +85,7 @@ describe('ensureSession', () => { expect(mockSaveSession).toHaveBeenCalledWith(baseUrl, newAuth); }); - it('prompts for credentials when no session exists', async () => { + it('resolves piped-stdin credentials when no session exists', async () => { mockLoadSession.mockReturnValueOnce(null); const newAuth = { cookie: fakeCookie, userId: 'u2', email: 'a@b.com' }; mockSignIn.mockResolvedValueOnce(newAuth); @@ -84,6 +93,10 @@ describe('ensureSession', () => { const cookie = await ensureSession(baseUrl); expect(cookie).toBe(fakeCookie); expect(mockSignIn).toHaveBeenCalled(); + // The non-TTY path resolves credentials from the piped-stdin seam, not prompts. + expect( + vi.mocked(await import('./piped-credentials.js')).readCredentialsFromPipedStdin, + ).toHaveBeenCalled(); }); it('exits non-zero when signIn fails', async () => { @@ -111,7 +124,7 @@ describe('runRecoverToken', () => { vi.spyOn(console, 'error').mockImplementation(() => {}); }); - it('prompts for login, mints a token, and persists it when no session exists', async () => { + it('signs in via piped stdin, mints a token, and persists it when no session exists', async () => { mockLoadSession.mockReturnValueOnce(null); const newAuth = { cookie: fakeCookie, userId: 'u2', email: 'admin@test.com' }; mockSignIn.mockResolvedValueOnce(newAuth); -- 2.54.0