From 773a3f9cf6ea41aebbd0145832da06b00420bb69 Mon Sep 17 00:00:00 2001 From: Willie Ruemmele Date: Wed, 15 Jul 2026 14:00:03 -0600 Subject: [PATCH] fix: read piped access token from stdin instead of prompting @W-22954140@ When an access token is piped to 'sf org login access-token' and an auth file already exists for that user, the command triggered an interactive overwrite confirm against an already-consumed stdin, crashing with 'User force closed the prompt with 13 null' (Node exit 13). Read the token directly from stdin when it is not a TTY, and skip the overwrite confirm in that non-interactive case. A timeout guards against a stdin stream that never sends data or EOF. Fixes #3573 --- messages/accesstoken.store.md | 7 + src/commands/org/login/access-token.ts | 66 ++++++++- test/commands/org/login/access-token.test.ts | 139 +++++++++++++++++++ 3 files changed, 206 insertions(+), 6 deletions(-) diff --git a/messages/accesstoken.store.md b/messages/accesstoken.store.md index 973c5e6f..1df1c5d0 100644 --- a/messages/accesstoken.store.md +++ b/messages/accesstoken.store.md @@ -6,6 +6,8 @@ Authorize an org using an existing Salesforce access token. By default, the command runs interactively and asks you for the access token. If you previously authorized the org, the command prompts whether you want to overwrite the local file. Specify --no-prompt to not be prompted. +If you pipe the access token to the command (so stdin isn't an interactive terminal), the command reads the token from stdin and doesn't prompt to overwrite an existing file. + To use the command in a CI/CD script, set the SF_ACCESS_TOKEN environment variable to the access token. Then run the command with the --no-prompt parameter. # examples @@ -27,3 +29,8 @@ It should follow this pattern: %s. A file already exists for user "%s", which is associated with the access token you provided. Are you sure you want to overwrite the existing file? + +# stdinTimeout + +Timed out while reading the access token from stdin. +Pipe the access token to the command, or set the SF_ACCESS_TOKEN environment variable and use --no-prompt. diff --git a/src/commands/org/login/access-token.ts b/src/commands/org/login/access-token.ts index bd1083cf..63c43c75 100644 --- a/src/commands/org/login/access-token.ts +++ b/src/commands/org/login/access-token.ts @@ -132,7 +132,10 @@ export default class LoginAccessToken extends SfCommand { } private async overwriteAuthInfo(username: string): Promise { - if (!this.flags['no-prompt']) { + // Only prompt to overwrite when we can actually receive an answer. When stdin is not a + // TTY (e.g. the token was piped in) the interactive prompt has no input to read and would + // reject with "User force closed the prompt with 13 null", so treat it like --no-prompt. + if (!this.flags['no-prompt'] && process.stdin.isTTY) { const stateAggregator = await StateAggregator.getInstance(); if (await stateAggregator.orgs.exists(username)) { return this.confirm({ message: messages.getMessage('overwriteAccessTokenAuthUserFile', [username]) }); @@ -143,14 +146,65 @@ export default class LoginAccessToken extends SfCommand { private async getAccessToken(): Promise { const accessToken = - env.getString('SF_ACCESS_TOKEN') ?? - env.getString('SFDX_ACCESS_TOKEN') ?? - (this.flags['no-prompt'] === true - ? '' // will throw when validating - : await this.secretPrompt({ message: commonMessages.getMessage('accessTokenStdin') })); + env.getString('SF_ACCESS_TOKEN') ?? env.getString('SFDX_ACCESS_TOKEN') ?? (await this.resolveAccessToken()); if (!matchesAccessToken(accessToken)) { throw new SfError(messages.getMessage('invalidAccessTokenFormat', [ACCESS_TOKEN_FORMAT])); } return accessToken; } + + private async resolveAccessToken(): Promise { + if (this.flags['no-prompt']) { + // will throw when validating + return ''; + } + // When stdin is piped (not a TTY) the interactive secret prompt can't be used reliably, + // so read the token directly from the stream. This is the CI/CD "pipe the token" workflow. + if (!process.stdin.isTTY) { + return readPipedStdin(); + } + return this.secretPrompt({ message: commonMessages.getMessage('accessTokenStdin') }); + } +} + +/** + * Read all data piped to stdin and return it trimmed. Returns '' if nothing was piped. + * + * A timeout guards against a stdin stream that is opened but never sends data or EOF (e.g. a + * pipe inherited from a supervisor process, or a terminal that reports a non-TTY stdin such as + * Git Bash/mintty on Windows), which would otherwise hang the command forever. On timeout the + * stream listeners are removed so no read is left dangling. + */ +async function readPipedStdin(ms = 60_000): Promise { + const stdin = process.stdin; + const chunks: string[] = []; + + return new Promise((resolve, reject) => { + const onData = (chunk: Buffer | string): void => { + chunks.push(typeof chunk === 'string' ? chunk : chunk.toString('utf8')); + }; + const cleanup = (): void => { + clearTimeout(timer); + stdin.removeListener('data', onData); + stdin.removeListener('end', onEnd); + stdin.removeListener('error', onError); + }; + const onEnd = (): void => { + cleanup(); + resolve(chunks.join('').trim()); + }; + const onError = (err: Error): void => { + cleanup(); + reject(err); + }; + const timer = setTimeout(() => { + cleanup(); + reject(new SfError(messages.getMessage('stdinTimeout'))); + }, ms); + timer.unref(); + + stdin.on('data', onData); + stdin.once('end', onEnd); + stdin.once('error', onError); + }); } diff --git a/test/commands/org/login/access-token.test.ts b/test/commands/org/login/access-token.test.ts index 778f218c..9dbef422 100644 --- a/test/commands/org/login/access-token.test.ts +++ b/test/commands/org/login/access-token.test.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +import { Readable } from 'node:stream'; import { AuthFields, AuthInfo, StateAggregator } from '@salesforce/core'; import { assert, expect } from 'chai'; import { TestContext } from '@salesforce/core/testSetup'; @@ -49,8 +50,57 @@ describe('org:login:access-token', () => { /* eslint-enable camelcase */ let stubSfCommandUxStubs: ReturnType; let prompterStubs: ReturnType; + const originalStdin = process.stdin; + + const setStdinIsTTY = (isTTY: boolean): void => { + Object.defineProperty(process.stdin, 'isTTY', { value: isTTY, configurable: true }); + }; + + /** Ensure the token env vars don't short-circuit the stdin-reading path. */ + const stubNoTokenEnv = (): void => { + $$.SANDBOX.stub(env, 'getString') + .withArgs('SF_ACCESS_TOKEN') + // @ts-expect-error getString can return undefined + .returns(undefined) + .withArgs('SFDX_ACCESS_TOKEN') + // @ts-expect-error getString can return undefined + .returns(undefined); + }; + + /** Replace process.stdin with a non-TTY readable and clear any token env vars. */ + const useStdin = (readable: Readable): void => { + stubNoTokenEnv(); + Object.defineProperty(readable, 'isTTY', { value: undefined, configurable: true }); + Object.defineProperty(process, 'stdin', { value: readable, configurable: true }); + }; + + /** Simulate a non-TTY stdin that emits `content` then EOF. */ + const pipeToStdin = (content: string): void => { + useStdin(Readable.from([content])); + }; + + /** A non-TTY stdin that is open but never sends data or EOF (would hang without a timeout). */ + const openStdin = (): Readable => { + const stdin = new Readable({ + read(): void { + /* no-op: never emits data or end */ + }, + }); + useStdin(stdin); + return stdin; + }; + + /** Wait until the command under test has attached its stdin 'data' listener. */ + const waitForStdinRead = async (stdin: Readable): Promise => { + while (stdin.listenerCount('data') === 0) { + // eslint-disable-next-line no-await-in-loop + await new Promise((resolve) => setImmediate(resolve)); + } + }; beforeEach(() => { + // default to an interactive terminal; individual tests override as needed + setStdinIsTTY(true); // @ts-expect-error because private method $$.SANDBOX.stub(Store.prototype, 'saveAuthInfo').resolves(userInfo); $$.SANDBOX.stub(AuthInfo.prototype, 'getUsername').returns(authFields.username); @@ -67,6 +117,10 @@ describe('org:login:access-token', () => { prompterStubs = stubPrompter($$.SANDBOX); }); + afterEach(() => { + Object.defineProperty(process, 'stdin', { value: originalStdin, configurable: true }); + }); + it('should return auth fields after successful auth', async () => { prompterStubs.secret.resolves(accessToken); @@ -104,6 +158,91 @@ describe('org:login:access-token', () => { expect(prompterStubs.confirm.callCount).to.equal(1); }); + it('should read the token piped to stdin and not prompt when auth file exists (non-TTY)', async () => { + // Core regression for W-22954140 / GH #3573: token piped via stdin + existing auth file. + // Drives the real stdin-reading path (no env var short-circuit). + pipeToStdin(`${accessToken}\n`); + $$.SANDBOX.stub(StateAggregator, 'getInstance').resolves({ + // @ts-expect-error because incomplete interface + orgs: { + exists: () => Promise.resolve(true), + }, + }); + + const result = await Store.run(['--instance-url', 'https://foo.bar.org.salesforce.com']); + expect(result).to.deep.equal(redactedAuthFields); + // token came from stdin, not the interactive prompt + expect(prompterStubs.secret.callCount).to.equal(0); + // overwrite prompt skipped because stdin isn't a TTY + expect(prompterStubs.confirm.callCount).to.equal(0); + expect(stubSfCommandUxStubs.logSuccess.callCount).to.equal(1); + }); + + it('should trim surrounding whitespace/newlines from a piped token', async () => { + pipeToStdin(` ${accessToken}\n\n`); + $$.SANDBOX.stub(StateAggregator, 'getInstance').resolves({ + // @ts-expect-error because incomplete interface + orgs: { exists: () => Promise.resolve(false) }, + }); + + const result = await Store.run(['--instance-url', 'https://foo.bar.org.salesforce.com']); + expect(result).to.deep.equal(redactedAuthFields); + expect(prompterStubs.secret.callCount).to.equal(0); + }); + + it('should throw invalid-format error when stdin is empty (non-TTY)', async () => { + pipeToStdin(''); + try { + await Store.run(['--instance-url', 'https://foo.bar.org.salesforce.com']); + assert(false, 'should throw error'); + } catch (e) { + assert(e instanceof Error); + expect(e.message).to.include("The access token isn't in the correct format"); + } + // never falls back to an interactive prompt + expect(prompterStubs.secret.callCount).to.equal(0); + }); + + it('should time out (not hang) when non-TTY stdin never sends data or EOF', async () => { + const clock = $$.SANDBOX.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); + const stdin = openStdin(); + + const runPromise = Store.run(['--instance-url', 'https://foo.bar.org.salesforce.com']); + // Wait until the command has reached readPipedStdin and registered its listeners/timer, + // then advance the fake clock past the timeout. Avoids ticking before the timer exists. + await waitForStdinRead(stdin); + await clock.tickAsync(60_000); + + try { + await runPromise; + assert(false, 'should throw error'); + } catch (e) { + assert(e instanceof Error); + expect(e.message).to.include('Timed out while reading the access token from stdin'); + } + expect(prompterStubs.secret.callCount).to.equal(0); + }); + + it('should reject and clean up listeners when non-TTY stdin errors', async () => { + const stdin = openStdin(); + + const runPromise = Store.run(['--instance-url', 'https://foo.bar.org.salesforce.com']); + await waitForStdinRead(stdin); + stdin.emit('error', new Error('stdin boom')); + + try { + await runPromise; + assert(false, 'should throw error'); + } catch (e) { + assert(e instanceof Error); + expect(e.message).to.include('stdin boom'); + } + // listeners removed on cleanup so nothing dangles + expect(stdin.listenerCount('data')).to.equal(0); + expect(stdin.listenerCount('end')).to.equal(0); + expect(stdin.listenerCount('error')).to.equal(0); + }); + it('should show that auth file does not already exist', async () => { prompterStubs.secret.resolves(accessToken); $$.SANDBOX.stub(StateAggregator, 'getInstance').resolves({