fix(#1394): recover-token headless — dual path (flag + piped stdin) with documented precedence
ci/woodpecker/pr/ci Pipeline failed
ci/woodpecker/pr/ci Pipeline failed
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).
This commit is contained in:
@@ -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: ');
|
||||
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
|
||||
const password = await promptSecret('Password: ');
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user