fix(#1394): recover-token headless — dual path (--email flag + piped stdin) with documented precedence #1423

Merged
ops-deploy-01 merged 2 commits from fix/1394-recover-token-headless into next 2026-08-25 16:14:56 +00:00
5 changed files with 202 additions and 10 deletions
Showing only changes of commit 96eb0fb010 - Show all commits
+3 -2
View File
@@ -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 <url>', 'Gateway URL (overrides meta.json)')
.action(async (cmdOpts: { gateway?: string }) => {
.option('-e, --email <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 ───────────────────────────────────────────────────────────────
@@ -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([' [email protected] \n', 'pw with spaces \n']),
);
expect(r.email).toBe('[email protected]');
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(['[email protected]\n', 'pw\n', 'extra\n', 'more\n']),
);
expect(r).toEqual({ email: '[email protected]', password: 'pw' });
});
});
@@ -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 });
});
});
}
@@ -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: '[email protected]' } 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: '[email protected]' } as never);
vi.mocked(readCredentialsFromPipedStdin).mockResolvedValue({
email: '[email protected]',
password: 'stdin-pw',
});
await ensureSession(URL, { email: '[email protected]' });
expect(signIn).toHaveBeenCalledWith(URL, '[email protected]', '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: '[email protected]' } as never);
vi.mocked(readCredentialsFromPipedStdin).mockResolvedValue({
email: '[email protected]',
password: 'spw',
});
await ensureSession(URL);
expect(signIn).toHaveBeenCalledWith(URL, '[email protected]', '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: '[email protected]' } as never);
vi.mocked(readCredentialsFromPipedStdin).mockResolvedValue({
email: '[email protected]',
password: 'pw',
});
await ensureSession(URL);
expect(saveSession).toHaveBeenCalledWith(URL, expect.anything());
});
});
@@ -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<string> {
* 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<string> {
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<string> {
// Try the stored session first
const session = loadSession(gatewayUrl);
if (session) {
@@ -119,10 +136,25 @@ export async function ensureSession(gatewayUrl: string): Promise<string> {
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<void> {
}
/**
* `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<void> {
export async function runRecoverToken(gatewayUrl?: string, email?: string): Promise<void> {
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);