From 43a1e4ce9789404f8308cfa09c3624d067ef3147 Mon Sep 17 00:00:00 2001 From: Or Dvir Date: Wed, 1 Jul 2026 15:46:11 +0300 Subject: [PATCH 1/7] feat(storage): carry agent credential kind, public id, and scopes --- src/storage/__tests__/agent-secrets.test.ts | 37 +++++++++++++++++++++ src/storage/secrets.ts | 11 ++++++ 2 files changed, 48 insertions(+) create mode 100644 src/storage/__tests__/agent-secrets.test.ts diff --git a/src/storage/__tests__/agent-secrets.test.ts b/src/storage/__tests__/agent-secrets.test.ts new file mode 100644 index 0000000..000c0ee --- /dev/null +++ b/src/storage/__tests__/agent-secrets.test.ts @@ -0,0 +1,37 @@ +import { expect, test, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +let DIR: string; +const SAVED = process.env.HOOKMYAPP_CONFIG_DIR; +beforeEach(() => { + DIR = mkdtempSync(join(tmpdir(), 'hma-agent-secrets-')); + process.env.HOOKMYAPP_CONFIG_DIR = DIR; +}); +afterEach(() => { + rmSync(DIR, { recursive: true, force: true }); + if (SAVED) process.env.HOOKMYAPP_CONFIG_DIR = SAVED; + else delete process.env.HOOKMYAPP_CONFIG_DIR; +}); + +test('round-trips an agent credential and flags it', async () => { + const { writeSecrets, readSecrets, isAgentCredential } = await import('../secrets.js'); + await writeSecrets({ + accessToken: 'ac_abc', + refreshToken: '', + expiresAt: 0, + kind: 'agent', + credentialPublicId: 'ac_pub1', + scopes: ['workspace.read'], + }); + const got = await readSecrets(); + expect(got?.accessToken).toBe('ac_abc'); + expect(got?.credentialPublicId).toBe('ac_pub1'); + expect(isAgentCredential(got!)).toBe(true); +}); + +test('a legacy workos credential is not flagged as agent', async () => { + const { isAgentCredential } = await import('../secrets.js'); + expect(isAgentCredential({ accessToken: 'ey.j.s', refreshToken: 'r', expiresAt: 123 })).toBe(false); +}); diff --git a/src/storage/secrets.ts b/src/storage/secrets.ts index 2b4be76..206b516 100644 --- a/src/storage/secrets.ts +++ b/src/storage/secrets.ts @@ -24,6 +24,17 @@ export interface Secrets { accessToken: string; refreshToken: string; expiresAt: number; + /** Credential kind. Undefined = legacy WorkOS session (device-code / bootstrap). */ + kind?: 'workos' | 'agent'; + /** Agent credentials only: the ac_ credential's public id (for revoke). */ + credentialPublicId?: string; + /** Agent credentials only: scopes granted at issue time. */ + scopes?: string[]; +} + +/** True for an auth.md-issued org-scoped `ac_` credential (no refresh token). */ +export function isAgentCredential(creds: Secrets): boolean { + return creds.kind === 'agent'; } export async function writeSecrets(secrets: Secrets): Promise { From fa9b64b472a956d84d839f9a30e7acaf0c831302 Mon Sep 17 00:00:00 2001 From: Or Dvir Date: Wed, 1 Jul 2026 15:46:50 +0300 Subject: [PATCH 2/7] feat(api): add auth.md claim + scope-discovery client --- src/api/__tests__/agent-auth.test.ts | 53 ++++++++++++++++++++++++ src/api/agent-auth.ts | 60 ++++++++++++++++++++++++++++ 2 files changed, 113 insertions(+) create mode 100644 src/api/__tests__/agent-auth.test.ts create mode 100644 src/api/agent-auth.ts diff --git a/src/api/__tests__/agent-auth.test.ts b/src/api/__tests__/agent-auth.test.ts new file mode 100644 index 0000000..0ce104c --- /dev/null +++ b/src/api/__tests__/agent-auth.test.ts @@ -0,0 +1,53 @@ +import { expect, test, beforeEach, afterEach, vi } from 'vitest'; + +const SAVED = process.env.HOOKMYAPP_API_URL; +beforeEach(() => { + process.env.HOOKMYAPP_API_URL = 'https://test.example.com'; +}); +afterEach(() => { + if (SAVED) process.env.HOOKMYAPP_API_URL = SAVED; + else delete process.env.HOOKMYAPP_API_URL; + vi.unstubAllGlobals(); +}); + +function okJson(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }); +} + +test('fetchSupportedScopes reads scopes_supported from the well-known', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(okJson({ scopes_supported: ['workspace.read', 'message.send'] }))); + const { fetchSupportedScopes } = await import('../agent-auth.js'); + expect(await fetchSupportedScopes()).toEqual(['workspace.read', 'message.send']); +}); + +test('initiateClaim POSTs email + scopes and returns registrationId + expiresAt', async () => { + const fetchMock = vi.fn().mockResolvedValue( + okJson({ registrationId: '11111111-1111-1111-1111-111111111111', expiresAt: '2026-07-01T00:10:00.000Z', message: 'sent' }, 202), + ); + vi.stubGlobal('fetch', fetchMock); + const { initiateClaim } = await import('../agent-auth.js'); + const out = await initiateClaim({ email: 'a@b.com', scopes: ['workspace.read'] }); + const [url, init] = fetchMock.mock.calls[0]; + expect(String(url)).toBe('https://test.example.com/agent/auth/claim'); + expect(init.method).toBe('POST'); + expect(JSON.parse(init.body as string)).toEqual({ email: 'a@b.com', scopes: ['workspace.read'] }); + expect(out.registrationId).toBe('11111111-1111-1111-1111-111111111111'); +}); + +test('completeClaim POSTs registrationId + otp and returns the ac_ credential', async () => { + const fetchMock = vi.fn().mockResolvedValue( + okJson({ accessToken: 'ac_live_x', tokenType: 'Bearer', scopes: ['workspace.read'], credentialPublicId: 'ac_pub1' }), + ); + vi.stubGlobal('fetch', fetchMock); + const { completeClaim } = await import('../agent-auth.js'); + const out = await completeClaim({ registrationId: '11111111-1111-1111-1111-111111111111', otp: '123456' }); + expect(String(fetchMock.mock.calls[0][0])).toBe('https://test.example.com/agent/auth/claim/complete'); + expect(out.accessToken).toBe('ac_live_x'); + expect(out.credentialPublicId).toBe('ac_pub1'); +}); + +test('completeClaim maps a 429 to a typed rate-limit error (exitCode 6)', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(okJson({ code: 'RATE_LIMITED', message: 'slow down' }, 429))); + const { completeClaim } = await import('../agent-auth.js'); + await expect(completeClaim({ registrationId: '11111111-1111-1111-1111-111111111111', otp: '000000' })).rejects.toMatchObject({ exitCode: 6 }); +}); diff --git a/src/api/agent-auth.ts b/src/api/agent-auth.ts new file mode 100644 index 0000000..623b037 --- /dev/null +++ b/src/api/agent-auth.ts @@ -0,0 +1,60 @@ +import { getEffectiveApiUrl } from '../config/env-profiles.js'; +import { NetworkError } from '../output/error.js'; +import { mapApiError, isNetworkFailure } from './client.js'; + +// Re-declared wire DTOs (the backend is never imported). Keep field names in +// lockstep with the auth.md endpoints; integration drift is caught by tests. +export interface ClaimInitiated { + registrationId: string; // UUID + expiresAt: string; // ISO timestamp +} + +export interface AgentCredentialResponse { + accessToken: string; // "ac_…" Bearer credential + tokenType: string; // "Bearer" + scopes: string[]; + credentialPublicId: string; + expiresAt?: string; + orgId?: string; +} + +async function postJson(path: string, body: unknown): Promise { + const url = `${getEffectiveApiUrl()}${path}`; + let res: Response; + try { + res = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + } catch (err) { + if (isNetworkFailure(err)) throw new NetworkError(); + throw err; + } + if (!res.ok) throw await mapApiError(res); + return res.json(); +} + +/** Full scope vocabulary advertised by the backend (drift-free default). */ +export async function fetchSupportedScopes(): Promise { + const url = `${getEffectiveApiUrl()}/.well-known/oauth-protected-resource`; + let res: Response; + try { + res = await fetch(url, { method: 'GET' }); + } catch (err) { + if (isNetworkFailure(err)) throw new NetworkError(); + throw err; + } + if (!res.ok) throw await mapApiError(res); + const body = (await res.json()) as { scopes_supported?: string[] }; + return Array.isArray(body.scopes_supported) ? body.scopes_supported : []; +} + +export async function initiateClaim(input: { email: string; scopes: string[] }): Promise { + const data = (await postJson('/agent/auth/claim', input)) as ClaimInitiated; + return { registrationId: data.registrationId, expiresAt: data.expiresAt }; +} + +export async function completeClaim(input: { registrationId: string; otp: string }): Promise { + return (await postJson('/agent/auth/claim/complete', input)) as AgentCredentialResponse; +} From cbc73348ef5fa2663563eefda21a3c6a4eed5a8c Mon Sep 17 00:00:00 2001 From: Or Dvir Date: Wed, 1 Jul 2026 15:48:27 +0300 Subject: [PATCH 3/7] feat(login): browser-free --email login via auth.md email code --- src/auth/__tests__/agentmd-login.test.ts | 94 ++++++++++++++ src/auth/login.ts | 150 +++++++++++++++++++++++ 2 files changed, 244 insertions(+) create mode 100644 src/auth/__tests__/agentmd-login.test.ts diff --git a/src/auth/__tests__/agentmd-login.test.ts b/src/auth/__tests__/agentmd-login.test.ts new file mode 100644 index 0000000..7869234 --- /dev/null +++ b/src/auth/__tests__/agentmd-login.test.ts @@ -0,0 +1,94 @@ +import { expect, test, beforeEach, afterEach, vi } from 'vitest'; +import { mkdtempSync, rmSync, readFileSync, existsSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +let DIR: string; +const SAVED_DIR = process.env.HOOKMYAPP_CONFIG_DIR; +function okJson(b: unknown, s = 200): Response { + return new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } }); +} +function readCreds() { + return JSON.parse(readFileSync(join(DIR, 'credentials.json'), 'utf-8')); +} + +const inputMock = vi.fn(); +vi.mock('@inquirer/prompts', () => ({ + input: (...a: unknown[]) => inputMock(...a), + select: vi.fn(), + confirm: vi.fn(), +})); + +beforeEach(() => { + DIR = mkdtempSync(join(tmpdir(), 'hma-agentlogin-')); + process.env.HOOKMYAPP_CONFIG_DIR = DIR; + process.env.HOOKMYAPP_API_URL = 'https://test.example.com'; + inputMock.mockReset(); +}); +afterEach(() => { + rmSync(DIR, { recursive: true, force: true }); + if (SAVED_DIR) process.env.HOOKMYAPP_CONFIG_DIR = SAVED_DIR; + else delete process.env.HOOKMYAPP_CONFIG_DIR; + delete process.env.HOOKMYAPP_API_URL; + vi.unstubAllGlobals(); +}); + +test('interactive: claims with full scopes, prompts OTP, completes, saves ac_ credential', async () => { + const fetchMock = vi.fn() + .mockResolvedValueOnce(okJson({ scopes_supported: ['workspace.read', 'message.send'] })) // discovery + .mockResolvedValueOnce(okJson({ registrationId: '11111111-1111-1111-1111-111111111111', expiresAt: 'x', message: 'sent' }, 202)) // claim + .mockResolvedValueOnce(okJson({ accessToken: 'ac_live_x', tokenType: 'Bearer', scopes: ['workspace.read', 'message.send'], credentialPublicId: 'ac_pub1' })); // complete + vi.stubGlobal('fetch', fetchMock); + inputMock.mockResolvedValue('123456'); + const origIsTty = Object.getOwnPropertyDescriptor(process.stdin, 'isTTY'); + Object.defineProperty(process.stdin, 'isTTY', { configurable: true, value: true }); + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + const mod = await import('../login.js'); + + await mod.runAgentClaimLogin({ email: 'a@b.com' }); + + expect(JSON.parse(fetchMock.mock.calls[1][1].body).scopes).toEqual(['workspace.read', 'message.send']); + const creds = readCreds(); + expect(creds.accessToken).toBe('ac_live_x'); + expect(creds.kind).toBe('agent'); + expect(creds.credentialPublicId).toBe('ac_pub1'); + logSpy.mockRestore(); + if (origIsTty) Object.defineProperty(process.stdin, 'isTTY', origIsTty); + else delete (process.stdin as { isTTY?: boolean }).isTTY; +}); + +test('json split step 1: no --otp prints registrationId + expiresAt and does NOT prompt or save', async () => { + const fetchMock = vi.fn() + .mockResolvedValueOnce(okJson({ scopes_supported: ['workspace.read'] })) + .mockResolvedValueOnce(okJson({ registrationId: '2222', expiresAt: 'later', message: 'sent' }, 202)); + vi.stubGlobal('fetch', fetchMock); + const outSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + const mod = await import('../login.js'); + + await mod.runAgentClaimLogin({ email: 'a@b.com', json: true }); + + expect(inputMock).not.toHaveBeenCalled(); + expect(existsSync(join(DIR, 'credentials.json'))).toBe(false); + const printed = outSpy.mock.calls.flat().join(''); + expect(JSON.parse(printed)).toMatchObject({ registrationId: '2222', expiresAt: 'later' }); + outSpy.mockRestore(); +}); + +test('json split step 2: --registration-id + --otp completes without a new claim call', async () => { + const fetchMock = vi.fn().mockResolvedValue( + okJson({ accessToken: 'ac_live_y', tokenType: 'Bearer', scopes: ['workspace.read'], credentialPublicId: 'ac_pub2' }), + ); + vi.stubGlobal('fetch', fetchMock); + const mod = await import('../login.js'); + + await mod.runAgentClaimLogin({ email: 'a@b.com', registrationId: '2222', otp: '654321', json: true }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(String(fetchMock.mock.calls[0][0])).toBe('https://test.example.com/agent/auth/claim/complete'); + expect(readCreds().accessToken).toBe('ac_live_y'); +}); + +test('--otp without --registration-id is a ValidationError (exit 2)', async () => { + const mod = await import('../login.js'); + await expect(mod.runAgentClaimLogin({ email: 'a@b.com', otp: '123456' })).rejects.toMatchObject({ exitCode: 2 }); +}); diff --git a/src/auth/login.ts b/src/auth/login.ts index 52764a2..dee3ecc 100644 --- a/src/auth/login.ts +++ b/src/auth/login.ts @@ -477,6 +477,121 @@ export async function runBootstrapCodeExchange( await runWizard({ phone: opts.phone, next: opts.next, json: opts.json }); } +/** + * Browser-free login over the auth.md user-claimed email-OTP flow. + * + * TTY: claim -> prompt the 6-digit code -> complete, all in one call. + * Non-interactive / --json: the OTP arrives out-of-band, so this splits into + * two invocations keyed by registrationId: + * 1. `login --email --json` -> prints { registrationId, expiresAt } + * 2. `login --email --registration-id --otp --json` -> completes + * Persists an org-scoped `ac_` credential (no refresh token). + */ +export async function runAgentClaimLogin(opts: { + email: string; + otp?: string; + registrationId?: string; + scopes?: string[]; + json?: boolean; +}): Promise { + const { fetchSupportedScopes, initiateClaim, completeClaim } = await import( + '../api/agent-auth.js' + ); + + if (opts.otp && !opts.registrationId) { + throw new ValidationError( + '--otp requires --registration-id (from the first `login --email` call).', + ); + } + + // Split step 2 (or interactive completion): registrationId already known. + if (opts.registrationId) { + const otp = opts.otp ?? (await promptOtp()); + await persistAgentCredential( + await completeClaim({ registrationId: opts.registrationId, otp }), + opts.email, + opts.json, + ); + return; + } + + // Step 1: request scopes (full vocabulary unless narrowed) and initiate. + const scopes = + opts.scopes && opts.scopes.length > 0 + ? opts.scopes + : await fetchSupportedScopes(); + if (scopes.length === 0) { + throw new ValidationError( + 'Could not resolve any scopes to request. Pass --scope explicitly.', + ); + } + const claim = await initiateClaim({ email: opts.email, scopes }); + + // Non-interactive: cannot prompt. Emit the handle and stop; caller completes + // with --registration-id + --otp. + const interactive = Boolean(process.stdin.isTTY) && !opts.json; + if (!interactive) { + process.stdout.write( + JSON.stringify({ + ok: true, + registrationId: claim.registrationId, + expiresAt: claim.expiresAt, + next: 'login --email --registration-id --otp ', + }) + '\n', + ); + return; + } + + console.log( + `\n${c.success(icon.success)} We emailed a 6-digit code to ${opts.email}\n`, + ); + const otp = await promptOtp(); + await persistAgentCredential( + await completeClaim({ registrationId: claim.registrationId, otp }), + opts.email, + opts.json, + ); +} + +async function promptOtp(): Promise { + const { input } = await import('@inquirer/prompts'); + const value = await input({ + message: 'Enter the 6-digit code', + validate: (v: string) => + /^\d{6}$/.test(v.trim()) ? true : 'Enter the 6-digit code from the email.', + }); + return value.trim(); +} + +async function persistAgentCredential( + cred: { accessToken: string; scopes: string[]; credentialPublicId: string }, + email: string, + json?: boolean, +): Promise { + await saveCredentials({ + accessToken: cred.accessToken, + refreshToken: '', + expiresAt: 0, + kind: 'agent', + credentialPublicId: cred.credentialPublicId, + scopes: cred.scopes, + }); + if (json) { + process.stdout.write( + JSON.stringify({ + ok: true, + credentialPublicId: cred.credentialPublicId, + scopes: cred.scopes, + }) + '\n', + ); + return; + } + const n = cred.scopes.length; + console.log( + `${c.success(icon.success)} Logged in as ${email} (${n} scope${n === 1 ? '' : 's'})`, + ); +} + export function loginCommand(program: Command): void { const login = program .command('login') @@ -495,12 +610,29 @@ export function loginCommand(program: Command): void { '--code ', 'Exchange a dashboard-minted bootstrap code (zero browser interaction)', ) + .option('--email ', 'Browser-free sign-in via auth.md email code') + .option( + '--otp ', + 'The 6-digit code (with --registration-id, for non-interactive login)', + ) + .option( + '--registration-id ', + 'Registration id from the first `login --email` call', + ) + .option( + '--scope ', + 'Request specific scopes instead of the full set (repeatable)', + ) .action( async (opts: { phone?: string; wizard?: boolean; next?: string; code?: string; + email?: string; + otp?: string; + registrationId?: string; + scope?: string[]; }) => { // Reject an invalid --next locally (exit 2) instead of silently // coercing it to undefined — `login --next bogus` previously ran the @@ -522,6 +654,24 @@ export function loginCommand(program: Command): void { | undefined; const json = program.opts().json === true; + // auth.md browser-free branch. Mutually exclusive with --code/--wizard; + // runs before them so a bad combination is rejected up front. + if (opts.email) { + if (opts.wizard || opts.code) { + throw new ValidationError( + '--email cannot be combined with --code or --wizard.', + ); + } + await runAgentClaimLogin({ + email: opts.email, + otp: opts.otp, + registrationId: opts.registrationId, + scopes: opts.scope, + json, + }); + return; + } + // Phase 122 — bootstrap-code branch. MUST run BEFORE the wizard // fast-path and BEFORE device-flow initiation so --code --wizard is // flagged as a programming error (mutually exclusive). From 74d23b4e78cb66f7e24ae85e351e0a8d0e69e96b Mon Sep 17 00:00:00 2001 From: Or Dvir Date: Wed, 1 Jul 2026 15:49:10 +0300 Subject: [PATCH 4/7] fix(api): skip token refresh for org-scoped agent credentials --- src/api/__tests__/agent-refresh.test.ts | 30 +++++++++++++++++++++++++ src/api/client.ts | 6 +++++ 2 files changed, 36 insertions(+) create mode 100644 src/api/__tests__/agent-refresh.test.ts diff --git a/src/api/__tests__/agent-refresh.test.ts b/src/api/__tests__/agent-refresh.test.ts new file mode 100644 index 0000000..634c704 --- /dev/null +++ b/src/api/__tests__/agent-refresh.test.ts @@ -0,0 +1,30 @@ +import { expect, test, beforeEach, afterEach, vi } from 'vitest'; +import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +let DIR: string; +const SAVED = process.env.HOOKMYAPP_CONFIG_DIR; +beforeEach(() => { + DIR = mkdtempSync(join(tmpdir(), 'hma-agent-refresh-')); + process.env.HOOKMYAPP_CONFIG_DIR = DIR; + mkdirSync(DIR, { recursive: true }); +}); +afterEach(() => { + rmSync(DIR, { recursive: true, force: true }); + if (SAVED) process.env.HOOKMYAPP_CONFIG_DIR = SAVED; + else delete process.env.HOOKMYAPP_CONFIG_DIR; + vi.unstubAllGlobals(); +}); + +test('forceTokenRefresh is a no-op for an agent credential (no WorkOS call)', async () => { + writeFileSync( + join(DIR, 'credentials.json'), + JSON.stringify({ accessToken: 'ac_live_x', refreshToken: '', expiresAt: 0, kind: 'agent', credentialPublicId: 'ac_pub1', scopes: [] }), + ); + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + const { forceTokenRefresh } = await import('../client.js'); + await expect(forceTokenRefresh('org_123')).resolves.toBeUndefined(); + expect(fetchMock).not.toHaveBeenCalled(); +}); diff --git a/src/api/client.ts b/src/api/client.ts index debda5d..bf7c855 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -1,4 +1,5 @@ import { readCredentials, saveCredentials } from '../auth/store.js'; +import { isAgentCredential } from '../storage/secrets.js'; import { AuthError, ApiError, @@ -77,6 +78,11 @@ export async function forceTokenRefresh(organizationId?: string): Promise if (!creds) { throw new AuthError('Not logged in. Run: hookmyapp login'); } + // Agent (auth.md) credentials are org-scoped Bearer tokens with no refresh + // token; there is nothing to refresh and no org re-scope to perform. + if (isAgentCredential(creds)) { + return; + } try { const refreshed = await refreshToken(creds.refreshToken, organizationId); await saveCredentials(refreshed); From 679a2d8d2e19df0fd7482fb8ebdc53b9cf4921ed Mon Sep 17 00:00:00 2001 From: Or Dvir Date: Wed, 1 Jul 2026 15:50:13 +0300 Subject: [PATCH 5/7] feat(credentials): add credentials list and revoke commands --- src/commands/__tests__/credentials.test.ts | 48 +++++++++++++++ src/commands/credentials.ts | 71 ++++++++++++++++++++++ src/index.ts | 2 + 3 files changed, 121 insertions(+) create mode 100644 src/commands/__tests__/credentials.test.ts create mode 100644 src/commands/credentials.ts diff --git a/src/commands/__tests__/credentials.test.ts b/src/commands/__tests__/credentials.test.ts new file mode 100644 index 0000000..7641fe7 --- /dev/null +++ b/src/commands/__tests__/credentials.test.ts @@ -0,0 +1,48 @@ +import { expect, test, beforeEach, afterEach, vi } from 'vitest'; + +const apiClientMock = vi.fn(); +vi.mock('../../api/client.js', () => ({ apiClient: (...a: unknown[]) => apiClientMock(...a) })); +const confirmMock = vi.fn(); +vi.mock('@inquirer/prompts', () => ({ + confirm: (...a: unknown[]) => confirmMock(...a), + select: vi.fn(), + input: vi.fn(), +})); + +async function run(argv: string[]): Promise { + const { Command } = await import('commander'); + const program = new Command(); + program.option('--json'); + program.option('--human'); + const { registerCredentialsCommand } = await import('../credentials.js'); + registerCredentialsCommand(program); + await program.parseAsync(['node', 'hookmyapp', ...argv]); +} + +beforeEach(() => { + apiClientMock.mockReset(); + confirmMock.mockReset(); +}); +afterEach(() => vi.restoreAllMocks()); + +test('list --json prints the credentials array', async () => { + apiClientMock.mockResolvedValue([{ publicId: 'ac_pub1', scopes: ['workspace.read'] }]); + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + await run(['credentials', 'list', '--json']); + expect(apiClientMock).toHaveBeenCalledWith('/agent/credentials'); + expect(JSON.parse(logSpy.mock.calls.flat().join('\n'))).toEqual([{ publicId: 'ac_pub1', scopes: ['workspace.read'] }]); + logSpy.mockRestore(); +}); + +test('revoke -y DELETEs without prompting', async () => { + apiClientMock.mockResolvedValue(undefined); + await run(['credentials', 'revoke', 'ac_pub1', '-y']); + expect(confirmMock).not.toHaveBeenCalled(); + expect(apiClientMock).toHaveBeenCalledWith('/agent/credentials/ac_pub1', { method: 'DELETE' }); +}); + +test('revoke aborts when the user declines the prompt', async () => { + confirmMock.mockResolvedValue(false); + await run(['credentials', 'revoke', 'ac_pub1']); + expect(apiClientMock).not.toHaveBeenCalled(); +}); diff --git a/src/commands/credentials.ts b/src/commands/credentials.ts new file mode 100644 index 0000000..5d72b73 --- /dev/null +++ b/src/commands/credentials.ts @@ -0,0 +1,71 @@ +import { Command } from 'commander'; +import { apiClient } from '../api/client.js'; +import { addExamples } from '../output/help.js'; + +interface AgentCredentialRow { + publicId: string; + scopes?: string[]; +} + +/** Manage auth.md agent credentials (ac_ Bearer tokens). */ +export function registerCredentialsCommand(program: Command): void { + const credentials = program + .command('credentials') + .description('List and revoke agent credentials'); + + credentials + .command('list') + .description('List your agent credentials') + .action(async () => { + const data = await apiClient('/agent/credentials'); + if (program.opts().json) { + console.log(JSON.stringify(data, null, 2)); + return; + } + const rows: AgentCredentialRow[] = Array.isArray(data) ? data : []; + if (rows.length === 0) { + console.log( + 'No agent credentials. Create one with: hookmyapp login --email ', + ); + return; + } + for (const r of rows) { + console.log(`${r.publicId} ${(r.scopes ?? []).join(', ')}`); + } + }); + + credentials + .command('revoke ') + .description('Revoke an agent credential') + .option('-y, --yes', 'Skip the confirmation prompt') + .action(async (publicId: string, opts: { yes?: boolean }) => { + const isJson = Boolean(program.opts().json); + if (!opts.yes && !isJson) { + const { confirm } = await import('@inquirer/prompts'); + const ok = await confirm({ + message: `Revoke credential ${publicId}?`, + default: false, + }); + if (!ok) { + console.log('Aborted.'); + return; + } + } + await apiClient(`/agent/credentials/${publicId}`, { method: 'DELETE' }); + if (isJson) { + console.log(JSON.stringify({ ok: true, revoked: publicId })); + return; + } + console.log(`Revoked ${publicId}`); + }); + + addExamples( + credentials, + ` +EXAMPLES: + $ hookmyapp credentials list + $ hookmyapp credentials list --json + $ hookmyapp credentials revoke ac_ab12cd34 -y +`, + ); +} diff --git a/src/index.ts b/src/index.ts index 5ec52f8..731110c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,6 +3,7 @@ import { fileURLToPath } from 'node:url'; import { Command, CommanderError, Option } from 'commander'; import { loginCommand } from './auth/login.js'; import { logoutCommand } from './auth/logout.js'; +import { registerCredentialsCommand } from './commands/credentials.js'; import { registerChannelsCommand } from './commands/channels.js'; import { registerWhatsappCommand, registerWhatsappMessages } from './commands/whatsapp.js'; import { registerWhatsappTemplates } from './commands/whatsapp-templates.js'; @@ -159,6 +160,7 @@ program.configureOutput({ // Auth commands loginCommand(program); logoutCommand(program); +registerCredentialsCommand(program); // Channel management registerChannelsCommand(program); From d60b09cad409428017227bae7a589f333100a03c Mon Sep 17 00:00:00 2001 From: Or Dvir Date: Wed, 1 Jul 2026 15:52:53 +0300 Subject: [PATCH 6/7] docs: document browser-free login and credentials commands --- README.md | 28 ++++++++++++++++++++++++++++ src/auth/login.ts | 3 +++ src/commands/credentials.ts | 21 +++++++++++++++++++-- 3 files changed, 50 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 8ce433d..1b9c52a 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,34 @@ hookmyapp channels connect whatsapp # or name the type directly hookmyapp channels connect instagram ``` +## Authentication + +`hookmyapp login` signs you in through your browser and is the default for +interactive use. + +**Browser-free sign-in** (for headless environments and AI agents) uses an +emailed one-time code instead of a browser: + +```bash +# Interactive terminal: prompts you for the 6-digit code from your email +hookmyapp login --email you@example.com + +# Non-interactive / agent: two steps, because the code arrives out of band +hookmyapp login --email you@example.com --json +# -> { "registrationId": "...", "expiresAt": "..." } +hookmyapp login --email you@example.com --registration-id --otp 123456 --json +``` + +This stores an organization-scoped credential (`ac_…`). Pass `--scope ` +(repeatable) to request a narrower set than the default full access. + +Manage those credentials: + +```bash +hookmyapp credentials list +hookmyapp credentials revoke ac_ab12cd34 -y +``` + ## Listening for webhooks on localhost Two flavors — pick based on which channel you have. diff --git a/src/auth/login.ts b/src/auth/login.ts index dee3ecc..3923573 100644 --- a/src/auth/login.ts +++ b/src/auth/login.ts @@ -762,6 +762,9 @@ export function loginCommand(program: Command): void { ` EXAMPLES: $ hookmyapp login + $ hookmyapp login --email you@example.com # browser-free (prompts for a code) + $ hookmyapp login --email you@example.com --json # step 1: prints registrationId + $ hookmyapp login --email you@example.com --registration-id --otp 123456 --json # step 2 $ hookmyapp login --code hma_boot_xxx # zero-browser AI paste $ hookmyapp login --workspace acme-corp # preselect workspace $ hookmyapp login --next sandbox --phone +15551234567 # scripts / CI diff --git a/src/commands/credentials.ts b/src/commands/credentials.ts index 5d72b73..93c5abe 100644 --- a/src/commands/credentials.ts +++ b/src/commands/credentials.ts @@ -13,7 +13,7 @@ export function registerCredentialsCommand(program: Command): void { .command('credentials') .description('List and revoke agent credentials'); - credentials + const list = credentials .command('list') .description('List your agent credentials') .action(async () => { @@ -34,7 +34,7 @@ export function registerCredentialsCommand(program: Command): void { } }); - credentials + const revoke = credentials .command('revoke ') .description('Revoke an agent credential') .option('-y, --yes', 'Skip the confirmation prompt') @@ -62,9 +62,26 @@ export function registerCredentialsCommand(program: Command): void { addExamples( credentials, ` +EXAMPLES: + $ hookmyapp credentials list + $ hookmyapp credentials revoke ac_ab12cd34 -y +`, + ); + + addExamples( + list, + ` EXAMPLES: $ hookmyapp credentials list $ hookmyapp credentials list --json +`, + ); + + addExamples( + revoke, + ` +EXAMPLES: + $ hookmyapp credentials revoke ac_ab12cd34 $ hookmyapp credentials revoke ac_ab12cd34 -y `, ); From f076ad761ec403eaa3de1b40c788f7cf2e893685 Mon Sep 17 00:00:00 2001 From: Or Dvir Date: Wed, 1 Jul 2026 20:09:57 +0300 Subject: [PATCH 7/7] fix(login): harden non-interactive auth.md flow and credential revoke - require --otp with --registration-id in non-interactive/--json mode instead of hanging on a prompt - reject --otp/--registration-id/--scope when --email is absent instead of silently falling through to the browser flow - clear the locally stored credential when revoking the one in use - URL-encode the credential id in the revoke request path - bound auth requests with a 30s timeout mapped to a network error --- src/api/__tests__/agent-auth.test.ts | 15 +++++++ src/api/agent-auth.ts | 39 +++++++++------- src/auth/__tests__/agentmd-login.test.ts | 52 +++++++++++++++++----- src/auth/login.ts | 20 ++++++++- src/commands/__tests__/credentials.test.ts | 35 ++++++++++++++- src/commands/credentials.ts | 12 ++++- 6 files changed, 143 insertions(+), 30 deletions(-) diff --git a/src/api/__tests__/agent-auth.test.ts b/src/api/__tests__/agent-auth.test.ts index 0ce104c..fac998f 100644 --- a/src/api/__tests__/agent-auth.test.ts +++ b/src/api/__tests__/agent-auth.test.ts @@ -51,3 +51,18 @@ test('completeClaim maps a 429 to a typed rate-limit error (exitCode 6)', async const { completeClaim } = await import('../agent-auth.js'); await expect(completeClaim({ registrationId: '11111111-1111-1111-1111-111111111111', otp: '000000' })).rejects.toMatchObject({ exitCode: 6 }); }); + +test('a request timeout maps to a NetworkError (exitCode 5)', async () => { + const timeout = Object.assign(new Error('timed out'), { name: 'TimeoutError' }); + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(timeout)); + const { initiateClaim } = await import('../agent-auth.js'); + await expect(initiateClaim({ email: 'a@b.com', scopes: ['workspace.read'] })).rejects.toMatchObject({ exitCode: 5 }); +}); + +test('the auth fetch is bounded by an abort signal', async () => { + const fetchMock = vi.fn().mockResolvedValue(okJson({ scopes_supported: [] })); + vi.stubGlobal('fetch', fetchMock); + const { fetchSupportedScopes } = await import('../agent-auth.js'); + await fetchSupportedScopes(); + expect(fetchMock.mock.calls[0][1].signal).toBeInstanceOf(AbortSignal); +}); diff --git a/src/api/agent-auth.ts b/src/api/agent-auth.ts index 623b037..a97c2ec 100644 --- a/src/api/agent-auth.ts +++ b/src/api/agent-auth.ts @@ -18,33 +18,38 @@ export interface AgentCredentialResponse { orgId?: string; } -async function postJson(path: string, body: unknown): Promise { - const url = `${getEffectiveApiUrl()}${path}`; - let res: Response; +// Auth requests must not hang an agent or CI job forever; bound every call. +const AUTH_FETCH_TIMEOUT_MS = 30_000; + +async function timedFetch(url: string, init: RequestInit): Promise { try { - res = await fetch(url, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }); + return await fetch(url, { ...init, signal: AbortSignal.timeout(AUTH_FETCH_TIMEOUT_MS) }); } catch (err) { - if (isNetworkFailure(err)) throw new NetworkError(); + // AbortSignal.timeout aborts with a TimeoutError; treat it, and any + // transport failure, as a NetworkError so the CLI exits cleanly (exit 5). + if (isNetworkFailure(err) || (err instanceof Error && err.name === 'TimeoutError')) { + throw new NetworkError(); + } throw err; } +} + +async function postJson(path: string, body: unknown): Promise { + const res = await timedFetch(`${getEffectiveApiUrl()}${path}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); if (!res.ok) throw await mapApiError(res); return res.json(); } /** Full scope vocabulary advertised by the backend (drift-free default). */ export async function fetchSupportedScopes(): Promise { - const url = `${getEffectiveApiUrl()}/.well-known/oauth-protected-resource`; - let res: Response; - try { - res = await fetch(url, { method: 'GET' }); - } catch (err) { - if (isNetworkFailure(err)) throw new NetworkError(); - throw err; - } + const res = await timedFetch( + `${getEffectiveApiUrl()}/.well-known/oauth-protected-resource`, + { method: 'GET' }, + ); if (!res.ok) throw await mapApiError(res); const body = (await res.json()) as { scopes_supported?: string[] }; return Array.isArray(body.scopes_supported) ? body.scopes_supported : []; diff --git a/src/auth/__tests__/agentmd-login.test.ts b/src/auth/__tests__/agentmd-login.test.ts index 7869234..2dbaa56 100644 --- a/src/auth/__tests__/agentmd-login.test.ts +++ b/src/auth/__tests__/agentmd-login.test.ts @@ -5,6 +5,7 @@ import { join } from 'node:path'; let DIR: string; const SAVED_DIR = process.env.HOOKMYAPP_CONFIG_DIR; +const SAVED_API_URL = process.env.HOOKMYAPP_API_URL; function okJson(b: unknown, s = 200): Response { return new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } }); } @@ -29,7 +30,8 @@ afterEach(() => { rmSync(DIR, { recursive: true, force: true }); if (SAVED_DIR) process.env.HOOKMYAPP_CONFIG_DIR = SAVED_DIR; else delete process.env.HOOKMYAPP_CONFIG_DIR; - delete process.env.HOOKMYAPP_API_URL; + if (SAVED_API_URL) process.env.HOOKMYAPP_API_URL = SAVED_API_URL; + else delete process.env.HOOKMYAPP_API_URL; vi.unstubAllGlobals(); }); @@ -45,16 +47,30 @@ test('interactive: claims with full scopes, prompts OTP, completes, saves ac_ cr const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); const mod = await import('../login.js'); - await mod.runAgentClaimLogin({ email: 'a@b.com' }); + try { + await mod.runAgentClaimLogin({ email: 'a@b.com' }); - expect(JSON.parse(fetchMock.mock.calls[1][1].body).scopes).toEqual(['workspace.read', 'message.send']); - const creds = readCreds(); - expect(creds.accessToken).toBe('ac_live_x'); - expect(creds.kind).toBe('agent'); - expect(creds.credentialPublicId).toBe('ac_pub1'); - logSpy.mockRestore(); - if (origIsTty) Object.defineProperty(process.stdin, 'isTTY', origIsTty); - else delete (process.stdin as { isTTY?: boolean }).isTTY; + expect(JSON.parse(fetchMock.mock.calls[1][1].body).scopes).toEqual(['workspace.read', 'message.send']); + const creds = readCreds(); + expect(creds.accessToken).toBe('ac_live_x'); + expect(creds.kind).toBe('agent'); + expect(creds.credentialPublicId).toBe('ac_pub1'); + } finally { + logSpy.mockRestore(); + if (origIsTty) Object.defineProperty(process.stdin, 'isTTY', origIsTty); + else delete (process.stdin as { isTTY?: boolean }).isTTY; + } +}); + +test('json step 2 without --otp is a ValidationError and never calls the network', async () => { + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + const mod = await import('../login.js'); + await expect( + mod.runAgentClaimLogin({ email: 'a@b.com', registrationId: '2222', json: true }), + ).rejects.toMatchObject({ exitCode: 2 }); + expect(fetchMock).not.toHaveBeenCalled(); + expect(inputMock).not.toHaveBeenCalled(); }); test('json split step 1: no --otp prints registrationId + expiresAt and does NOT prompt or save', async () => { @@ -92,3 +108,19 @@ test('--otp without --registration-id is a ValidationError (exit 2)', async () = const mod = await import('../login.js'); await expect(mod.runAgentClaimLogin({ email: 'a@b.com', otp: '123456' })).rejects.toMatchObject({ exitCode: 2 }); }); + +test('agent flags without --email are rejected before any browser flow', async () => { + const { Command } = await import('commander'); + const { loginCommand } = await import('../login.js'); + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + const program = new Command(); + program.exitOverride(); + program.option('--json'); + program.option('--human'); + loginCommand(program); + await expect( + program.parseAsync(['node', 'hookmyapp', 'login', '--registration-id', 'r1', '--otp', '123456', '--json']), + ).rejects.toMatchObject({ exitCode: 2 }); + expect(fetchMock).not.toHaveBeenCalled(); +}); diff --git a/src/auth/login.ts b/src/auth/login.ts index 3923573..3e65a0b 100644 --- a/src/auth/login.ts +++ b/src/auth/login.ts @@ -504,8 +504,16 @@ export async function runAgentClaimLogin(opts: { ); } + // Prompting is only possible on an interactive terminal and not in --json. + const interactive = Boolean(process.stdin.isTTY) && !opts.json; + // Split step 2 (or interactive completion): registrationId already known. if (opts.registrationId) { + if (!opts.otp && !interactive) { + throw new ValidationError( + '--otp is required with --registration-id in non-interactive or --json mode.', + ); + } const otp = opts.otp ?? (await promptOtp()); await persistAgentCredential( await completeClaim({ registrationId: opts.registrationId, otp }), @@ -529,7 +537,6 @@ export async function runAgentClaimLogin(opts: { // Non-interactive: cannot prompt. Emit the handle and stop; caller completes // with --registration-id + --otp. - const interactive = Boolean(process.stdin.isTTY) && !opts.json; if (!interactive) { process.stdout.write( JSON.stringify({ @@ -654,6 +661,17 @@ export function loginCommand(program: Command): void { | undefined; const json = program.opts().json === true; + // auth.md completion flags are meaningless without --email; reject them + // up front instead of silently falling through to the browser flow. + if ( + !opts.email && + (opts.otp || opts.registrationId || (opts.scope?.length ?? 0) > 0) + ) { + throw new ValidationError( + '--otp, --registration-id, and --scope require --email.', + ); + } + // auth.md browser-free branch. Mutually exclusive with --code/--wizard; // runs before them so a bad combination is rejected up front. if (opts.email) { diff --git a/src/commands/__tests__/credentials.test.ts b/src/commands/__tests__/credentials.test.ts index 7641fe7..51d258c 100644 --- a/src/commands/__tests__/credentials.test.ts +++ b/src/commands/__tests__/credentials.test.ts @@ -1,4 +1,10 @@ import { expect, test, beforeEach, afterEach, vi } from 'vitest'; +import { mkdtempSync, rmSync, writeFileSync, existsSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +let DIR: string; +const SAVED_DIR = process.env.HOOKMYAPP_CONFIG_DIR; const apiClientMock = vi.fn(); vi.mock('../../api/client.js', () => ({ apiClient: (...a: unknown[]) => apiClientMock(...a) })); @@ -20,10 +26,17 @@ async function run(argv: string[]): Promise { } beforeEach(() => { + DIR = mkdtempSync(join(tmpdir(), 'hma-creds-cmd-')); + process.env.HOOKMYAPP_CONFIG_DIR = DIR; apiClientMock.mockReset(); confirmMock.mockReset(); }); -afterEach(() => vi.restoreAllMocks()); +afterEach(() => { + rmSync(DIR, { recursive: true, force: true }); + if (SAVED_DIR) process.env.HOOKMYAPP_CONFIG_DIR = SAVED_DIR; + else delete process.env.HOOKMYAPP_CONFIG_DIR; + vi.restoreAllMocks(); +}); test('list --json prints the credentials array', async () => { apiClientMock.mockResolvedValue([{ publicId: 'ac_pub1', scopes: ['workspace.read'] }]); @@ -46,3 +59,23 @@ test('revoke aborts when the user declines the prompt', async () => { await run(['credentials', 'revoke', 'ac_pub1']); expect(apiClientMock).not.toHaveBeenCalled(); }); + +test('revoking the currently stored credential clears it from disk', async () => { + writeFileSync( + join(DIR, 'credentials.json'), + JSON.stringify({ accessToken: 'ac_x', refreshToken: '', expiresAt: 0, kind: 'agent', credentialPublicId: 'ac_pub1', scopes: [] }), + ); + apiClientMock.mockResolvedValue(undefined); + await run(['credentials', 'revoke', 'ac_pub1', '-y', '--json']); + expect(existsSync(join(DIR, 'credentials.json'))).toBe(false); +}); + +test('revoking a different credential leaves the stored one intact', async () => { + writeFileSync( + join(DIR, 'credentials.json'), + JSON.stringify({ accessToken: 'ac_x', refreshToken: '', expiresAt: 0, kind: 'agent', credentialPublicId: 'ac_pub1', scopes: [] }), + ); + apiClientMock.mockResolvedValue(undefined); + await run(['credentials', 'revoke', 'ac_other', '-y', '--json']); + expect(existsSync(join(DIR, 'credentials.json'))).toBe(true); +}); diff --git a/src/commands/credentials.ts b/src/commands/credentials.ts index 93c5abe..9b7e2ba 100644 --- a/src/commands/credentials.ts +++ b/src/commands/credentials.ts @@ -1,5 +1,7 @@ import { Command } from 'commander'; import { apiClient } from '../api/client.js'; +import { readCredentials, deleteCredentials } from '../auth/store.js'; +import { isAgentCredential } from '../storage/secrets.js'; import { addExamples } from '../output/help.js'; interface AgentCredentialRow { @@ -51,7 +53,15 @@ export function registerCredentialsCommand(program: Command): void { return; } } - await apiClient(`/agent/credentials/${publicId}`, { method: 'DELETE' }); + await apiClient(`/agent/credentials/${encodeURIComponent(publicId)}`, { + method: 'DELETE', + }); + // If this is the credential we're currently authenticated with, drop the + // now-dead token from disk so the next command doesn't send a 401. + const creds = await readCredentials(); + if (creds && isAgentCredential(creds) && creds.credentialPublicId === publicId) { + await deleteCredentials(); + } if (isJson) { console.log(JSON.stringify({ ok: true, revoked: publicId })); return;