diff --git a/cli/__tests__/lib.test.mjs b/cli/__tests__/lib.test.mjs index 1c5466f513..cac8172d2f 100644 --- a/cli/__tests__/lib.test.mjs +++ b/cli/__tests__/lib.test.mjs @@ -524,6 +524,60 @@ describe('poller.js', () => { jest.clearAllMocks(); }); + test('a rejected token stops the poller with a message that says to check the token, not that it is revoked', async () => { + const authError = Object.assign(new Error('unauthorized'), { status: 401 }); + const mockGet = jest.fn().mockRejectedValue(authError); + createClient.mockReturnValue({ get: mockGet, post: jest.fn() }); + + const errors = []; + startPoller({ + instanceUrl: 'http://localhost:5000', + token: 'cm_test', + agentName: 'my-agent', + intervalMs: 1, + onEvent: async () => ({ outcome: 'acknowledged' }), + onError: (err) => errors.push(err), + }); + + await new Promise((r) => setTimeout(r, 50)); + const stopMsg = errors.map((e) => e.message).find((m) => /stopping poller/.test(m)); + // The three sources `agent connect` reads, in order: --token, COMMONLY_TOKEN, saved login. + expect(stopMsg).toMatch(/--token, then COMMONLY_TOKEN, then your saved login/); + expect(stopMsg).toMatch(/commonly login/); + // connect never reads the token file or detach: naming them would send the reader to the wrong place. + expect(stopMsg).not.toMatch(/detach/); + expect(stopMsg).not.toMatch(/tokens\//); + expect(stopMsg).not.toMatch(/likely revoked/); + }); + + const rejectedMessage = async (loginInstance) => { + const authError = Object.assign(new Error('unauthorized'), { status: 401 }); + createClient.mockReturnValue({ get: jest.fn().mockRejectedValue(authError), post: jest.fn() }); + const errors = []; + startPoller({ + instanceUrl: 'http://localhost:5000', + token: 'cm_test', + agentName: 'my-agent', + intervalMs: 1, + loginInstance, + onEvent: async () => ({ outcome: 'acknowledged' }), + onError: (err) => errors.push(err), + }); + await new Promise((r) => setTimeout(r, 50)); + return errors.map((e) => e.message).find((m) => /stopping poller/.test(m)); + }; + + test('the login hint names the --instance connect ran with, so it refreshes that profile', async () => { + const stopMsg = await rejectedMessage('dev'); + expect(stopMsg).toMatch(/run commonly login --instance dev\./); + }); + + test('with no --instance, the login hint is the bare commonly login', async () => { + const stopMsg = await rejectedMessage(undefined); + expect(stopMsg).toMatch(/run commonly login\./); + expect(stopMsg).not.toMatch(/--instance/); + }); + const makeEvent = (id = 'evt-1') => ({ _id: id, type: 'heartbeat', diff --git a/cli/src/commands/agent.js b/cli/src/commands/agent.js index 01c2ef9c96..d6325b1c65 100644 --- a/cli/src/commands/agent.js +++ b/cli/src/commands/agent.js @@ -3713,6 +3713,7 @@ Docs: agentName: opts.name, instanceId: opts.instanceId, intervalMs: parseInt(opts.interval, 10), + loginInstance: opts.instance, onEvent: async (event) => { const ts = new Date().toTimeString().slice(0, 8); process.stdout.write(`[${ts}] ${event.type.padEnd(18)}`); diff --git a/cli/src/lib/poller.js b/cli/src/lib/poller.js index 9c6e2fd982..da56a9743d 100644 --- a/cli/src/lib/poller.js +++ b/cli/src/lib/poller.js @@ -36,6 +36,7 @@ export const startPoller = ({ agentName, instanceId = 'default', intervalMs = 5000, + loginInstance, // the --instance the connect ran with, so the login hint names it onEvent, // async (event) => { outcome, content? } onError, // (err) => void }) => { @@ -139,7 +140,9 @@ export const startPoller = ({ if (consecutiveAuthErrors >= MAX_AUTH_ERRORS) { onError?.(new Error( `Runtime token rejected ${consecutiveAuthErrors} times in a row — stopping poller. ` - + `The token is likely revoked.`, + + `This connection takes its key from --token, then COMMONLY_TOKEN, then your saved login. ` + + `If the key was rotated or revoked, re-run with the new key in --token or COMMONLY_TOKEN; ` + + `with neither set, run commonly login${loginInstance ? ` --instance ${loginInstance}` : ''}.`, )); running = false; return;