Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions src/api/__tests__/env-key-bearer.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { apiClient } from '../client.js';
import { API_KEY_ENV_VAR } from '../../config/env-vars.js';
import * as secrets from '../../storage/secrets.js';
import { AuthError, ForbiddenError } from '../../output/error.js';
import { setWorkspaceContext } from '../client.js';

// The env key must reach the wire as the bearer token, exactly as a stored
// agent credential does — no refresh attempt, no rewriting (AIT-438).
describe('apiClient with HOOKMYAPP_API_KEY (AIT-438)', () => {
const original = process.env[API_KEY_ENV_VAR];

beforeEach(() => {
vi.restoreAllMocks();
vi.spyOn(secrets, 'readSecrets').mockResolvedValue(null);
});
afterEach(() => {
if (original === undefined) delete process.env[API_KEY_ENV_VAR];
else process.env[API_KEY_ENV_VAR] = original;
vi.unstubAllGlobals();
});

// A revoked key 401s. "Session expired. Run: login" is wrong twice over —
// there is no session, and login refuses while the variable is set.
it('names the variable when the key is rejected, not "session expired"', async () => {
process.env[API_KEY_ENV_VAR] = 'hmok_revoked';
vi.stubGlobal('fetch', vi.fn(async () => ({
ok: false,
status: 401,
headers: new Headers(),
json: async () => ({ message: 'Unauthorized' }),
})));

await expect(apiClient('/workspaces')).rejects.toThrow(AuthError);
await expect(apiClient('/workspaces')).rejects.toThrow(
/HOOKMYAPP_API_KEY was rejected/,
);
});

// A bare 403 maps to PermissionError, whose stock message says "run:
// hookmyapp login" — which login now refuses to do while the key is set.
it('gives permission guidance, not login advice, on a bare 403', async () => {
process.env[API_KEY_ENV_VAR] = 'hmok_lowperm';
setWorkspaceContext({ workspaceId: 'ws_abc12345' });
vi.stubGlobal('fetch', vi.fn(async () => ({
ok: false,
status: 403,
headers: new Headers(),
json: async () => ({ message: 'Forbidden' }),
})));

await expect(apiClient('/channels')).rejects.toThrow(ForbiddenError);
await expect(apiClient('/channels')).rejects.toThrow(
/HOOKMYAPP_API_KEY lacks permission for workspace ws_abc12345/,
);
await expect(apiClient('/channels')).rejects.not.toThrow(/hookmyapp login/);
setWorkspaceContext({ workspaceId: null });
});

it('sends the env key as the Authorization bearer', async () => {
process.env[API_KEY_ENV_VAR] = 'hmok_livekey123';
const fetchMock = vi.fn(async (_url: string, _init: RequestInit) => ({
ok: true,
status: 200,
headers: new Headers(),
json: async () => [{ id: 'ws_abc12345' }],
}));
vi.stubGlobal('fetch', fetchMock);

const res = await apiClient('/workspaces');

expect(res).toEqual([{ id: 'ws_abc12345' }]);
const headers = fetchMock.mock.calls[0]![1].headers as Record<string, string>;
expect(headers.Authorization).toBe('Bearer hmok_livekey123');
// One call only: an agent credential has no refresh token, so nothing
// should have tried to reach WorkOS first.
expect(fetchMock).toHaveBeenCalledTimes(1);
});
});
38 changes: 34 additions & 4 deletions src/api/client.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -20,6 +20,7 @@ import {
getEffectiveWorkosClientId,
} from '../config/env-profiles.js';
import { buildVersionHeaders } from './version-headers.js';
import { API_KEY_ENV_VAR } from '../config/env-vars.js';

// Module-level workspace context populated by the top-level CLI entry after
// parsing --workspace. Explicit options.workspaceId on a specific apiClient()
Expand DownExpand Up@@ -312,9 +313,14 @@ export function describeFetchError(err: unknown): string {

export async function apiClient(
path: string,
options?: RequestInit & { workspaceId?: string },
// `bearerToken` pins the request to one credential instead of the resolved
// one. Only logout needs it: it must authenticate as the STORED key to
// revoke it, which the env key would otherwise shadow (AIT-438).
options?: RequestInit & { workspaceId?: string; bearerToken?: string },
): Promise<any> {
const creds = await readCredentials();
const creds = options?.bearerToken
? ({ accessToken: options.bearerToken, refreshToken: '', expiresAt: 0, kind: 'agent' } as const)
: await readCredentials();
if (!creds) {
throw new AuthError('Not logged in. Run: hookmyapp login');
}
Expand All@@ -335,7 +341,7 @@ export async function apiClient(

const baseUrl = getEffectiveApiUrl();

const { workspaceId, ...fetchOptions } = options ?? {};
const { workspaceId, bearerToken: _bearerToken, ...fetchOptions } = options ?? {};

const headers: Record<string, string> = {
Authorization: `Bearer ${accessToken}`,
Expand DownExpand Up@@ -383,7 +389,31 @@ export async function apiClient(
}

if (!res.ok) {
throw await mapApiError(res);
const err = await mapApiError(res);
// A 401 on an env credential must not say "Session expired. Run: login":
// there is no session, and `login` refuses to run while the variable is
// set, so that guidance is a loop (AIT-438).
if ('source' in creds && creds.source === 'env') {
if (err instanceof AuthError) {
throw new AuthError(
`The API key in ${API_KEY_ENV_VAR} was rejected (invalid or revoked). Replace it or unset the variable.`,
);
}
// A bare 403 maps to PermissionError, whose message names the persisted
// workspace slug and says "run: hookmyapp login" — wrong on both counts
// here: login refuses while the variable is set, and the workspace in
// play may have come from the environment too.
if (err instanceof PermissionError) {
// resolvedWsId already folds in options.workspaceId, which beats the
// shared context for this specific call.
const ws = resolvedWsId ?? '(unresolved)';
throw new ForbiddenError(
`The API key in ${API_KEY_ENV_VAR} lacks permission for workspace ${ws}. Use a key with the required role, or unset the variable to use your stored login.`,
'AGENT_KEY_FORBIDDEN',
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}
throw err;
}

// 204 No Content (and other empty-body 2xx responses) have no JSON to parse.
Expand Down
112 changes: 112 additions & 0 deletions src/auth/__tests__/env-api-key.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { readCredentials, readEnvCredential } from '../store.js';
import { API_KEY_ENV_VAR } from '../../config/env-vars.js';
import * as secrets from '../../storage/secrets.js';
import { AuthError } from '../../output/error.js';

const STORED = {
accessToken: 'stored-token',
refreshToken: 'r',
expiresAt: 0,
} as const;

describe('HOOKMYAPP_API_KEY credential (AIT-438)', () => {
const original = process.env[API_KEY_ENV_VAR];

beforeEach(() => {
delete process.env[API_KEY_ENV_VAR];
vi.restoreAllMocks();
});
afterEach(() => {
if (original === undefined) delete process.env[API_KEY_ENV_VAR];
else process.env[API_KEY_ENV_VAR] = original;
});

it('is used when no credential is stored', async () => {
vi.spyOn(secrets, 'readSecrets').mockResolvedValue(null);
process.env[API_KEY_ENV_VAR] = 'hmok_abc123';
const creds = await readCredentials();
expect(creds?.accessToken).toBe('hmok_abc123');
// Shaped as an agent credential so refresh/rescope stay no-ops.
expect(creds?.kind).toBe('agent');
expect(creds?.source).toBe('env');
});

it('outranks a stored credential', async () => {
vi.spyOn(secrets, 'readSecrets').mockResolvedValue({ ...STORED });
process.env[API_KEY_ENV_VAR] = 'hmok_abc123';
expect((await readCredentials())?.accessToken).toBe('hmok_abc123');
});

it('leaves the stored credential in charge when unset', async () => {
vi.spyOn(secrets, 'readSecrets').mockResolvedValue({ ...STORED });
const creds = await readCredentials();
expect(creds?.accessToken).toBe('stored-token');
expect(creds?.source).toBeUndefined();
});

it('accepts legacy ac_ keys, which the backend still resolves', () => {
process.env[API_KEY_ENV_VAR] = 'ac_legacy';
expect(readEnvCredential()?.accessToken).toBe('ac_legacy');
});

it('ignores an empty or whitespace-only value', () => {
process.env[API_KEY_ENV_VAR] = ' ';
expect(readEnvCredential()).toBeNull();
});

// `set HOOKMYAPP_API_KEY=""` on cmd.exe stores the two quote characters.
// Every caller must agree that is "unset", or login refuses while no
// credential exists.
it.each(['""', "''", '" "'])('treats %s as unset everywhere', async (value) => {
process.env[API_KEY_ENV_VAR] = value;
expect(readEnvCredential()).toBeNull();
const { envApiKey } = await import('../../config/env-vars.js');
expect(envApiKey()).toBe('');
});

// cmd.exe keeps the quotes in `set HOOKMYAPP_API_KEY="hmok_abc"`, unlike
// PowerShell and POSIX shells — without stripping, Windows users get
// "not a valid API key" for a key that plainly starts with hmok_.
it('accepts a value quoted the way cmd.exe stores it', () => {
process.env[API_KEY_ENV_VAR] = '"hmok_abc123"';
expect(readEnvCredential()?.accessToken).toBe('hmok_abc123');
process.env[API_KEY_ENV_VAR] = "'hmok_abc123'";
expect(readEnvCredential()?.accessToken).toBe('hmok_abc123');
});

it('leaves an unbalanced quote alone, so it still fails as malformed', () => {
process.env[API_KEY_ENV_VAR] = '"hmok_abc123';
expect(() => readEnvCredential()).toThrow(AuthError);
});

it('gives each env key its own notification-cache fingerprint', async () => {
const { credentialFingerprint } = await import('../../notifications-nudge.js');
const a = credentialFingerprint({
accessToken: 'hmok_keyA', refreshToken: '', expiresAt: 0, kind: 'agent', source: 'env',
});
const b = credentialFingerprint({
accessToken: 'hmok_keyB', refreshToken: '', expiresAt: 0, kind: 'agent', source: 'env',
});
expect(a).not.toBe(b);
expect(a).not.toBe('unknown');
// Derived, never the secret itself.
expect(a).not.toContain('hmok_keyA');
});

it('rejects a malformed value, naming the variable', () => {
process.env[API_KEY_ENV_VAR] = 'not-a-key';
expect(() => readEnvCredential()).toThrow(AuthError);
expect(() => readEnvCredential()).toThrow(/HOOKMYAPP_API_KEY/);
});

it('never echoes the key in the malformed-value error', () => {
process.env[API_KEY_ENV_VAR] = 'not-a-key-supersecret';
try {
readEnvCredential();
throw new Error('expected a throw');
} catch (err) {
expect((err as Error).message).not.toContain('supersecret');
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});
});
89 changes: 89 additions & 0 deletions src/auth/__tests__/logout.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,19 +15,26 @@ vi.mock('../../commands/mcp.js', () => ({ removeClaudeMcp: removeClaudeMcpMock }

let DIR: string;
const SAVED = process.env.HOOKMYAPP_CONFIG_DIR;
// HOOKMYAPP_API_KEY is a supported auth mechanism now, so a CI runner may well
// have one exported. Every baseline expectation here assumes no env
// credential; clear it per test and let the env-specific cases set their own.
const SAVED_KEY = process.env.HOOKMYAPP_API_KEY;
let logSpy: ReturnType<typeof vi.spyOn>;

beforeEach(() => {
removeClaudeMcpMock.mockReset().mockReturnValue({ ok: true });
DIR = mkdtempSync(join(tmpdir(), 'hma-logout-'));
process.env.HOOKMYAPP_CONFIG_DIR = DIR;
delete process.env.HOOKMYAPP_API_KEY;
logSpy = vi.spyOn(console, 'log').mockReturnValue(undefined);
});

afterEach(() => {
rmSync(DIR, { recursive: true, force: true });
if (SAVED) process.env.HOOKMYAPP_CONFIG_DIR = SAVED;
else delete process.env.HOOKMYAPP_CONFIG_DIR;
if (SAVED_KEY === undefined) delete process.env.HOOKMYAPP_API_KEY;
else process.env.HOOKMYAPP_API_KEY = SAVED_KEY;
vi.restoreAllMocks();
});

Expand DownExpand Up@@ -69,12 +76,94 @@ describe('logout', () => {
expect(JSON.parse(written.trim())).toEqual({
status: 'logged_out',
revoked: false,
envKeyActive: false,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Isolate logout tests from the API-key environment

When the test runner has HOOKMYAPP_API_KEY set, as may happen in CI now that this is a supported authentication mechanism, the suite inherits it because beforeEach only isolates the config directory. logout then correctly returns logged_out_with_warning with envKeyActive: true, so this assertion and other baseline logout expectations fail; save, clear, and restore the variable for every test rather than only inside the environment-specific cases.

Useful? React with 👍 / 👎.

mcpCleanup: { ok: true },
});
// The human check line must NOT be printed in --json mode.
expect(logSpy.mock.calls.flat().join('')).not.toMatch(/Logged out/);
});

// AIT-438: an env key keeps authenticating after logout. Automation reads
// the payload, not the stderr warning, so the signal has to be in the JSON.
test('--json flags a still-active HOOKMYAPP_API_KEY', async () => {
process.env.HOOKMYAPP_API_KEY = 'hmok_stillhere';
const credsPath = join(DIR, 'credentials.json');
writeFileSync(credsPath, JSON.stringify({ accessToken: 'a', refreshToken: 'r', expiresAt: 1 }));
const stdoutSpy = vi.spyOn(process.stdout, 'write').mockReturnValue(true);

{
await runLogout(['--json']);
const written = stdoutSpy.mock.calls.map((c) => String(c[0])).join('');
expect(JSON.parse(written.trim())).toMatchObject({
status: 'logged_out_with_warning',
envKeyActive: true,
envKeyVar: 'HOOKMYAPP_API_KEY',
});
}
});

// AIT-438: the revoke goes through apiClient, which authenticates with the
// env key while it is set — a "self-revoke" would kill the credential every
// other process is sharing.
test('skips the server-side revoke while HOOKMYAPP_API_KEY is set', async () => {
process.env.HOOKMYAPP_API_KEY = 'hmok_stillhere';
writeFileSync(
join(DIR, 'credentials.json'),
JSON.stringify({
accessToken: 'hmok_stillhere',
refreshToken: '',
expiresAt: 0,
kind: 'agent',
credentialPublicId: 'ac_self0001',
}),
);
const stdoutSpy = vi.spyOn(process.stdout, 'write').mockReturnValue(true);

{
await runLogout(['--json']);
const payload = JSON.parse(
stdoutSpy.mock.calls.map((c) => String(c[0])).join('').trim(),
);
expect(payload.revoked).toBe(false);
expect(payload.envKeyActive).toBe(true);
expect(payload.envKeyIsStoredKey).toBe(true);
expect(existsSync(join(DIR, 'credentials.json'))).toBe(false);
}
});

// A different env key must not stop logout from revoking the stored one:
// the request is pinned to the stored token, so it is a real self-revoke.
test('still revokes a stored key when the env holds a DIFFERENT key', async () => {
process.env.HOOKMYAPP_API_KEY = 'hmok_otherkey';
writeFileSync(
join(DIR, 'credentials.json'),
JSON.stringify({
accessToken: 'hmok_storedkey',
refreshToken: '',
expiresAt: 0,
kind: 'agent',
credentialPublicId: 'ac_stored01',
}),
);
const stdoutSpy = vi.spyOn(process.stdout, 'write').mockReturnValue(true);
const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 204 }));
vi.stubGlobal('fetch', fetchMock);

{
await runLogout(['--json']);
const payload = JSON.parse(
stdoutSpy.mock.calls.map((c) => String(c[0])).join('').trim(),
);
expect(payload.envKeyIsStoredKey).toBe(false);
const call = fetchMock.mock.calls.find(([url]) =>
String(url).includes('/agent/credentials/ac_stored01'),
);
expect(call).toBeDefined();
// Pinned to the stored key, NOT the env key that would otherwise win.
expect(call![1].headers.Authorization).toBe('Bearer hmok_storedkey');
}
});

test('reports MCP cleanup failure after credentials are removed', async () => {
removeClaudeMcpMock.mockReturnValue({ ok: false, detail: 'Claude MCP cleanup timed out' });
const stdoutSpy = vi.spyOn(process.stdout, 'write').mockReturnValue(true);
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions src/api/__tests__/env-key-bearer.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { apiClient } from '../client.js';
import { API_KEY_ENV_VAR } from '../../config/env-vars.js';
import * as secrets from '../../storage/secrets.js';
import { AuthError, ForbiddenError } from '../../output/error.js';
import { setWorkspaceContext } from '../client.js';

// The env key must reach the wire as the bearer token, exactly as a stored
// agent credential does — no refresh attempt, no rewriting (AIT-438).
describe('apiClient with HOOKMYAPP_API_KEY (AIT-438)', () => {
const original = process.env[API_KEY_ENV_VAR];

beforeEach(() => {
vi.restoreAllMocks();
vi.spyOn(secrets, 'readSecrets').mockResolvedValue(null);
});
afterEach(() => {
if (original === undefined) delete process.env[API_KEY_ENV_VAR];
else process.env[API_KEY_ENV_VAR] = original;
vi.unstubAllGlobals();
});

// A revoked key 401s. "Session expired. Run: login" is wrong twice over —
// there is no session, and login refuses while the variable is set.
it('names the variable when the key is rejected, not "session expired"', async () => {
process.env[API_KEY_ENV_VAR] = 'hmok_revoked';
vi.stubGlobal('fetch', vi.fn(async () => ({
ok: false,
status: 401,
headers: new Headers(),
json: async () => ({ message: 'Unauthorized' }),
})));

await expect(apiClient('/workspaces')).rejects.toThrow(AuthError);
await expect(apiClient('/workspaces')).rejects.toThrow(
/HOOKMYAPP_API_KEY was rejected/,
);
});

// A bare 403 maps to PermissionError, whose stock message says "run:
// hookmyapp login" — which login now refuses to do while the key is set.
it('gives permission guidance, not login advice, on a bare 403', async () => {
process.env[API_KEY_ENV_VAR] = 'hmok_lowperm';
setWorkspaceContext({ workspaceId: 'ws_abc12345' });
vi.stubGlobal('fetch', vi.fn(async () => ({
ok: false,
status: 403,
headers: new Headers(),
json: async () => ({ message: 'Forbidden' }),
})));

await expect(apiClient('/channels')).rejects.toThrow(ForbiddenError);
await expect(apiClient('/channels')).rejects.toThrow(
/HOOKMYAPP_API_KEY lacks permission for workspace ws_abc12345/,
);
await expect(apiClient('/channels')).rejects.not.toThrow(/hookmyapp login/);
setWorkspaceContext({ workspaceId: null });
});

it('sends the env key as the Authorization bearer', async () => {
process.env[API_KEY_ENV_VAR] = 'hmok_livekey123';
const fetchMock = vi.fn(async (_url: string, _init: RequestInit) => ({
ok: true,
status: 200,
headers: new Headers(),
json: async () => [{ id: 'ws_abc12345' }],
}));
vi.stubGlobal('fetch', fetchMock);

const res = await apiClient('/workspaces');

expect(res).toEqual([{ id: 'ws_abc12345' }]);
const headers = fetchMock.mock.calls[0]![1].headers as Record<string, string>;
expect(headers.Authorization).toBe('Bearer hmok_livekey123');
// One call only: an agent credential has no refresh token, so nothing
// should have tried to reach WorkOS first.
expect(fetchMock).toHaveBeenCalledTimes(1);
});
});
38 changes: 34 additions & 4 deletions src/api/client.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -20,6 +20,7 @@ import {
getEffectiveWorkosClientId,
} from '../config/env-profiles.js';
import { buildVersionHeaders } from './version-headers.js';
import { API_KEY_ENV_VAR } from '../config/env-vars.js';

// Module-level workspace context populated by the top-level CLI entry after
// parsing --workspace. Explicit options.workspaceId on a specific apiClient()
Expand DownExpand Up@@ -312,9 +313,14 @@ export function describeFetchError(err: unknown): string {

export async function apiClient(
path: string,
options?: RequestInit & { workspaceId?: string },
// `bearerToken` pins the request to one credential instead of the resolved
// one. Only logout needs it: it must authenticate as the STORED key to
// revoke it, which the env key would otherwise shadow (AIT-438).
options?: RequestInit & { workspaceId?: string; bearerToken?: string },
): Promise<any> {
const creds = await readCredentials();
const creds = options?.bearerToken
? ({ accessToken: options.bearerToken, refreshToken: '', expiresAt: 0, kind: 'agent' } as const)
: await readCredentials();
if (!creds) {
throw new AuthError('Not logged in. Run: hookmyapp login');
}
Expand All@@ -335,7 +341,7 @@ export async function apiClient(

const baseUrl = getEffectiveApiUrl();

const { workspaceId, ...fetchOptions } = options ?? {};
const { workspaceId, bearerToken: _bearerToken, ...fetchOptions } = options ?? {};

const headers: Record<string, string> = {
Authorization: `Bearer ${accessToken}`,
Expand DownExpand Up@@ -383,7 +389,31 @@ export async function apiClient(
}

if (!res.ok) {
throw await mapApiError(res);
const err = await mapApiError(res);
// A 401 on an env credential must not say "Session expired. Run: login":
// there is no session, and `login` refuses to run while the variable is
// set, so that guidance is a loop (AIT-438).
if ('source' in creds && creds.source === 'env') {
if (err instanceof AuthError) {
throw new AuthError(
`The API key in ${API_KEY_ENV_VAR} was rejected (invalid or revoked). Replace it or unset the variable.`,
);
}
// A bare 403 maps to PermissionError, whose message names the persisted
// workspace slug and says "run: hookmyapp login" — wrong on both counts
// here: login refuses while the variable is set, and the workspace in
// play may have come from the environment too.
if (err instanceof PermissionError) {
// resolvedWsId already folds in options.workspaceId, which beats the
// shared context for this specific call.
const ws = resolvedWsId ?? '(unresolved)';
throw new ForbiddenError(
`The API key in ${API_KEY_ENV_VAR} lacks permission for workspace ${ws}. Use a key with the required role, or unset the variable to use your stored login.`,
'AGENT_KEY_FORBIDDEN',
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}
throw err;
}

// 204 No Content (and other empty-body 2xx responses) have no JSON to parse.
Expand Down
112 changes: 112 additions & 0 deletions src/auth/__tests__/env-api-key.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { readCredentials, readEnvCredential } from '../store.js';
import { API_KEY_ENV_VAR } from '../../config/env-vars.js';
import * as secrets from '../../storage/secrets.js';
import { AuthError } from '../../output/error.js';

const STORED = {
accessToken: 'stored-token',
refreshToken: 'r',
expiresAt: 0,
} as const;

describe('HOOKMYAPP_API_KEY credential (AIT-438)', () => {
const original = process.env[API_KEY_ENV_VAR];

beforeEach(() => {
delete process.env[API_KEY_ENV_VAR];
vi.restoreAllMocks();
});
afterEach(() => {
if (original === undefined) delete process.env[API_KEY_ENV_VAR];
else process.env[API_KEY_ENV_VAR] = original;
});

it('is used when no credential is stored', async () => {
vi.spyOn(secrets, 'readSecrets').mockResolvedValue(null);
process.env[API_KEY_ENV_VAR] = 'hmok_abc123';
const creds = await readCredentials();
expect(creds?.accessToken).toBe('hmok_abc123');
// Shaped as an agent credential so refresh/rescope stay no-ops.
expect(creds?.kind).toBe('agent');
expect(creds?.source).toBe('env');
});

it('outranks a stored credential', async () => {
vi.spyOn(secrets, 'readSecrets').mockResolvedValue({ ...STORED });
process.env[API_KEY_ENV_VAR] = 'hmok_abc123';
expect((await readCredentials())?.accessToken).toBe('hmok_abc123');
});

it('leaves the stored credential in charge when unset', async () => {
vi.spyOn(secrets, 'readSecrets').mockResolvedValue({ ...STORED });
const creds = await readCredentials();
expect(creds?.accessToken).toBe('stored-token');
expect(creds?.source).toBeUndefined();
});

it('accepts legacy ac_ keys, which the backend still resolves', () => {
process.env[API_KEY_ENV_VAR] = 'ac_legacy';
expect(readEnvCredential()?.accessToken).toBe('ac_legacy');
});

it('ignores an empty or whitespace-only value', () => {
process.env[API_KEY_ENV_VAR] = ' ';
expect(readEnvCredential()).toBeNull();
});

// `set HOOKMYAPP_API_KEY=""` on cmd.exe stores the two quote characters.
// Every caller must agree that is "unset", or login refuses while no
// credential exists.
it.each(['""', "''", '" "'])('treats %s as unset everywhere', async (value) => {
process.env[API_KEY_ENV_VAR] = value;
expect(readEnvCredential()).toBeNull();
const { envApiKey } = await import('../../config/env-vars.js');
expect(envApiKey()).toBe('');
});

// cmd.exe keeps the quotes in `set HOOKMYAPP_API_KEY="hmok_abc"`, unlike
// PowerShell and POSIX shells — without stripping, Windows users get
// "not a valid API key" for a key that plainly starts with hmok_.
it('accepts a value quoted the way cmd.exe stores it', () => {
process.env[API_KEY_ENV_VAR] = '"hmok_abc123"';
expect(readEnvCredential()?.accessToken).toBe('hmok_abc123');
process.env[API_KEY_ENV_VAR] = "'hmok_abc123'";
expect(readEnvCredential()?.accessToken).toBe('hmok_abc123');
});

it('leaves an unbalanced quote alone, so it still fails as malformed', () => {
process.env[API_KEY_ENV_VAR] = '"hmok_abc123';
expect(() => readEnvCredential()).toThrow(AuthError);
});

it('gives each env key its own notification-cache fingerprint', async () => {
const { credentialFingerprint } = await import('../../notifications-nudge.js');
const a = credentialFingerprint({
accessToken: 'hmok_keyA', refreshToken: '', expiresAt: 0, kind: 'agent', source: 'env',
});
const b = credentialFingerprint({
accessToken: 'hmok_keyB', refreshToken: '', expiresAt: 0, kind: 'agent', source: 'env',
});
expect(a).not.toBe(b);
expect(a).not.toBe('unknown');
// Derived, never the secret itself.
expect(a).not.toContain('hmok_keyA');
});

it('rejects a malformed value, naming the variable', () => {
process.env[API_KEY_ENV_VAR] = 'not-a-key';
expect(() => readEnvCredential()).toThrow(AuthError);
expect(() => readEnvCredential()).toThrow(/HOOKMYAPP_API_KEY/);
});

it('never echoes the key in the malformed-value error', () => {
process.env[API_KEY_ENV_VAR] = 'not-a-key-supersecret';
try {
readEnvCredential();
throw new Error('expected a throw');
} catch (err) {
expect((err as Error).message).not.toContain('supersecret');
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});
});
89 changes: 89 additions & 0 deletions src/auth/__tests__/logout.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,19 +15,26 @@ vi.mock('../../commands/mcp.js', () => ({ removeClaudeMcp: removeClaudeMcpMock }

let DIR: string;
const SAVED = process.env.HOOKMYAPP_CONFIG_DIR;
// HOOKMYAPP_API_KEY is a supported auth mechanism now, so a CI runner may well
// have one exported. Every baseline expectation here assumes no env
// credential; clear it per test and let the env-specific cases set their own.
const SAVED_KEY = process.env.HOOKMYAPP_API_KEY;
let logSpy: ReturnType<typeof vi.spyOn>;

beforeEach(() => {
removeClaudeMcpMock.mockReset().mockReturnValue({ ok: true });
DIR = mkdtempSync(join(tmpdir(), 'hma-logout-'));
process.env.HOOKMYAPP_CONFIG_DIR = DIR;
delete process.env.HOOKMYAPP_API_KEY;
logSpy = vi.spyOn(console, 'log').mockReturnValue(undefined);
});

afterEach(() => {
rmSync(DIR, { recursive: true, force: true });
if (SAVED) process.env.HOOKMYAPP_CONFIG_DIR = SAVED;
else delete process.env.HOOKMYAPP_CONFIG_DIR;
if (SAVED_KEY === undefined) delete process.env.HOOKMYAPP_API_KEY;
else process.env.HOOKMYAPP_API_KEY = SAVED_KEY;
vi.restoreAllMocks();
});

Expand DownExpand Up@@ -69,12 +76,94 @@ describe('logout', () => {
expect(JSON.parse(written.trim())).toEqual({
status: 'logged_out',
revoked: false,
envKeyActive: false,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Isolate logout tests from the API-key environment

When the test runner has HOOKMYAPP_API_KEY set, as may happen in CI now that this is a supported authentication mechanism, the suite inherits it because beforeEach only isolates the config directory. logout then correctly returns logged_out_with_warning with envKeyActive: true, so this assertion and other baseline logout expectations fail; save, clear, and restore the variable for every test rather than only inside the environment-specific cases.

Useful? React with 👍 / 👎.

mcpCleanup: { ok: true },
});
// The human check line must NOT be printed in --json mode.
expect(logSpy.mock.calls.flat().join('')).not.toMatch(/Logged out/);
});

// AIT-438: an env key keeps authenticating after logout. Automation reads
// the payload, not the stderr warning, so the signal has to be in the JSON.
test('--json flags a still-active HOOKMYAPP_API_KEY', async () => {
process.env.HOOKMYAPP_API_KEY = 'hmok_stillhere';
const credsPath = join(DIR, 'credentials.json');
writeFileSync(credsPath, JSON.stringify({ accessToken: 'a', refreshToken: 'r', expiresAt: 1 }));
const stdoutSpy = vi.spyOn(process.stdout, 'write').mockReturnValue(true);

{
await runLogout(['--json']);
const written = stdoutSpy.mock.calls.map((c) => String(c[0])).join('');
expect(JSON.parse(written.trim())).toMatchObject({
status: 'logged_out_with_warning',
envKeyActive: true,
envKeyVar: 'HOOKMYAPP_API_KEY',
});
}
});

// AIT-438: the revoke goes through apiClient, which authenticates with the
// env key while it is set — a "self-revoke" would kill the credential every
// other process is sharing.
test('skips the server-side revoke while HOOKMYAPP_API_KEY is set', async () => {
process.env.HOOKMYAPP_API_KEY = 'hmok_stillhere';
writeFileSync(
join(DIR, 'credentials.json'),
JSON.stringify({
accessToken: 'hmok_stillhere',
refreshToken: '',
expiresAt: 0,
kind: 'agent',
credentialPublicId: 'ac_self0001',
}),
);
const stdoutSpy = vi.spyOn(process.stdout, 'write').mockReturnValue(true);

{
await runLogout(['--json']);
const payload = JSON.parse(
stdoutSpy.mock.calls.map((c) => String(c[0])).join('').trim(),
);
expect(payload.revoked).toBe(false);
expect(payload.envKeyActive).toBe(true);
expect(payload.envKeyIsStoredKey).toBe(true);
expect(existsSync(join(DIR, 'credentials.json'))).toBe(false);
}
});

// A different env key must not stop logout from revoking the stored one:
// the request is pinned to the stored token, so it is a real self-revoke.
test('still revokes a stored key when the env holds a DIFFERENT key', async () => {
process.env.HOOKMYAPP_API_KEY = 'hmok_otherkey';
writeFileSync(
join(DIR, 'credentials.json'),
JSON.stringify({
accessToken: 'hmok_storedkey',
refreshToken: '',
expiresAt: 0,
kind: 'agent',
credentialPublicId: 'ac_stored01',
}),
);
const stdoutSpy = vi.spyOn(process.stdout, 'write').mockReturnValue(true);
const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 204 }));
vi.stubGlobal('fetch', fetchMock);

{
await runLogout(['--json']);
const payload = JSON.parse(
stdoutSpy.mock.calls.map((c) => String(c[0])).join('').trim(),
);
expect(payload.envKeyIsStoredKey).toBe(false);
const call = fetchMock.mock.calls.find(([url]) =>
String(url).includes('/agent/credentials/ac_stored01'),
);
expect(call).toBeDefined();
// Pinned to the stored key, NOT the env key that would otherwise win.
expect(call![1].headers.Authorization).toBe('Bearer hmok_storedkey');
}
});

test('reports MCP cleanup failure after credentials are removed', async () => {
removeClaudeMcpMock.mockReturnValue({ ok: false, detail: 'Claude MCP cleanup timed out' });
const stdoutSpy = vi.spyOn(process.stdout, 'write').mockReturnValue(true);
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions src/api/__tests__/env-key-bearer.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { apiClient } from '../client.js';
import { API_KEY_ENV_VAR } from '../../config/env-vars.js';
import * as secrets from '../../storage/secrets.js';
import { AuthError, ForbiddenError } from '../../output/error.js';
import { setWorkspaceContext } from '../client.js';

// The env key must reach the wire as the bearer token, exactly as a stored
// agent credential does — no refresh attempt, no rewriting (AIT-438).
describe('apiClient with HOOKMYAPP_API_KEY (AIT-438)', () => {
const original = process.env[API_KEY_ENV_VAR];

beforeEach(() => {
vi.restoreAllMocks();
vi.spyOn(secrets, 'readSecrets').mockResolvedValue(null);
});
afterEach(() => {
if (original === undefined) delete process.env[API_KEY_ENV_VAR];
else process.env[API_KEY_ENV_VAR] = original;
vi.unstubAllGlobals();
});

// A revoked key 401s. "Session expired. Run: login" is wrong twice over —
// there is no session, and login refuses while the variable is set.
it('names the variable when the key is rejected, not "session expired"', async () => {
process.env[API_KEY_ENV_VAR] = 'hmok_revoked';
vi.stubGlobal('fetch', vi.fn(async () => ({
ok: false,
status: 401,
headers: new Headers(),
json: async () => ({ message: 'Unauthorized' }),
})));

await expect(apiClient('/workspaces')).rejects.toThrow(AuthError);
await expect(apiClient('/workspaces')).rejects.toThrow(
/HOOKMYAPP_API_KEY was rejected/,
);
});

// A bare 403 maps to PermissionError, whose stock message says "run:
// hookmyapp login" — which login now refuses to do while the key is set.
it('gives permission guidance, not login advice, on a bare 403', async () => {
process.env[API_KEY_ENV_VAR] = 'hmok_lowperm';
setWorkspaceContext({ workspaceId: 'ws_abc12345' });
vi.stubGlobal('fetch', vi.fn(async () => ({
ok: false,
status: 403,
headers: new Headers(),
json: async () => ({ message: 'Forbidden' }),
})));

await expect(apiClient('/channels')).rejects.toThrow(ForbiddenError);
await expect(apiClient('/channels')).rejects.toThrow(
/HOOKMYAPP_API_KEY lacks permission for workspace ws_abc12345/,
);
await expect(apiClient('/channels')).rejects.not.toThrow(/hookmyapp login/);
setWorkspaceContext({ workspaceId: null });
});

it('sends the env key as the Authorization bearer', async () => {
process.env[API_KEY_ENV_VAR] = 'hmok_livekey123';
const fetchMock = vi.fn(async (_url: string, _init: RequestInit) => ({
ok: true,
status: 200,
headers: new Headers(),
json: async () => [{ id: 'ws_abc12345' }],
}));
vi.stubGlobal('fetch', fetchMock);

const res = await apiClient('/workspaces');

expect(res).toEqual([{ id: 'ws_abc12345' }]);
const headers = fetchMock.mock.calls[0]![1].headers as Record<string, string>;
expect(headers.Authorization).toBe('Bearer hmok_livekey123');
// One call only: an agent credential has no refresh token, so nothing
// should have tried to reach WorkOS first.
expect(fetchMock).toHaveBeenCalledTimes(1);
});
});
38 changes: 34 additions & 4 deletions src/api/client.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -20,6 +20,7 @@ import {
getEffectiveWorkosClientId,
} from '../config/env-profiles.js';
import { buildVersionHeaders } from './version-headers.js';
import { API_KEY_ENV_VAR } from '../config/env-vars.js';

// Module-level workspace context populated by the top-level CLI entry after
// parsing --workspace. Explicit options.workspaceId on a specific apiClient()
Expand DownExpand Up@@ -312,9 +313,14 @@ export function describeFetchError(err: unknown): string {

export async function apiClient(
path: string,
options?: RequestInit & { workspaceId?: string },
// `bearerToken` pins the request to one credential instead of the resolved
// one. Only logout needs it: it must authenticate as the STORED key to
// revoke it, which the env key would otherwise shadow (AIT-438).
options?: RequestInit & { workspaceId?: string; bearerToken?: string },
): Promise<any> {
const creds = await readCredentials();
const creds = options?.bearerToken
? ({ accessToken: options.bearerToken, refreshToken: '', expiresAt: 0, kind: 'agent' } as const)
: await readCredentials();
if (!creds) {
throw new AuthError('Not logged in. Run: hookmyapp login');
}
Expand All@@ -335,7 +341,7 @@ export async function apiClient(

const baseUrl = getEffectiveApiUrl();

const { workspaceId, ...fetchOptions } = options ?? {};
const { workspaceId, bearerToken: _bearerToken, ...fetchOptions } = options ?? {};

const headers: Record<string, string> = {
Authorization: `Bearer ${accessToken}`,
Expand DownExpand Up@@ -383,7 +389,31 @@ export async function apiClient(
}

if (!res.ok) {
throw await mapApiError(res);
const err = await mapApiError(res);
// A 401 on an env credential must not say "Session expired. Run: login":
// there is no session, and `login` refuses to run while the variable is
// set, so that guidance is a loop (AIT-438).
if ('source' in creds && creds.source === 'env') {
if (err instanceof AuthError) {
throw new AuthError(
`The API key in ${API_KEY_ENV_VAR} was rejected (invalid or revoked). Replace it or unset the variable.`,
);
}
// A bare 403 maps to PermissionError, whose message names the persisted
// workspace slug and says "run: hookmyapp login" — wrong on both counts
// here: login refuses while the variable is set, and the workspace in
// play may have come from the environment too.
if (err instanceof PermissionError) {
// resolvedWsId already folds in options.workspaceId, which beats the
// shared context for this specific call.
const ws = resolvedWsId ?? '(unresolved)';
throw new ForbiddenError(
`The API key in ${API_KEY_ENV_VAR} lacks permission for workspace ${ws}. Use a key with the required role, or unset the variable to use your stored login.`,
'AGENT_KEY_FORBIDDEN',
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}
throw err;
}

// 204 No Content (and other empty-body 2xx responses) have no JSON to parse.
Expand Down
112 changes: 112 additions & 0 deletions src/auth/__tests__/env-api-key.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { readCredentials, readEnvCredential } from '../store.js';
import { API_KEY_ENV_VAR } from '../../config/env-vars.js';
import * as secrets from '../../storage/secrets.js';
import { AuthError } from '../../output/error.js';

const STORED = {
accessToken: 'stored-token',
refreshToken: 'r',
expiresAt: 0,
} as const;

describe('HOOKMYAPP_API_KEY credential (AIT-438)', () => {
const original = process.env[API_KEY_ENV_VAR];

beforeEach(() => {
delete process.env[API_KEY_ENV_VAR];
vi.restoreAllMocks();
});
afterEach(() => {
if (original === undefined) delete process.env[API_KEY_ENV_VAR];
else process.env[API_KEY_ENV_VAR] = original;
});

it('is used when no credential is stored', async () => {
vi.spyOn(secrets, 'readSecrets').mockResolvedValue(null);
process.env[API_KEY_ENV_VAR] = 'hmok_abc123';
const creds = await readCredentials();
expect(creds?.accessToken).toBe('hmok_abc123');
// Shaped as an agent credential so refresh/rescope stay no-ops.
expect(creds?.kind).toBe('agent');
expect(creds?.source).toBe('env');
});

it('outranks a stored credential', async () => {
vi.spyOn(secrets, 'readSecrets').mockResolvedValue({ ...STORED });
process.env[API_KEY_ENV_VAR] = 'hmok_abc123';
expect((await readCredentials())?.accessToken).toBe('hmok_abc123');
});

it('leaves the stored credential in charge when unset', async () => {
vi.spyOn(secrets, 'readSecrets').mockResolvedValue({ ...STORED });
const creds = await readCredentials();
expect(creds?.accessToken).toBe('stored-token');
expect(creds?.source).toBeUndefined();
});

it('accepts legacy ac_ keys, which the backend still resolves', () => {
process.env[API_KEY_ENV_VAR] = 'ac_legacy';
expect(readEnvCredential()?.accessToken).toBe('ac_legacy');
});

it('ignores an empty or whitespace-only value', () => {
process.env[API_KEY_ENV_VAR] = ' ';
expect(readEnvCredential()).toBeNull();
});

// `set HOOKMYAPP_API_KEY=""` on cmd.exe stores the two quote characters.
// Every caller must agree that is "unset", or login refuses while no
// credential exists.
it.each(['""', "''", '" "'])('treats %s as unset everywhere', async (value) => {
process.env[API_KEY_ENV_VAR] = value;
expect(readEnvCredential()).toBeNull();
const { envApiKey } = await import('../../config/env-vars.js');
expect(envApiKey()).toBe('');
});

// cmd.exe keeps the quotes in `set HOOKMYAPP_API_KEY="hmok_abc"`, unlike
// PowerShell and POSIX shells — without stripping, Windows users get
// "not a valid API key" for a key that plainly starts with hmok_.
it('accepts a value quoted the way cmd.exe stores it', () => {
process.env[API_KEY_ENV_VAR] = '"hmok_abc123"';
expect(readEnvCredential()?.accessToken).toBe('hmok_abc123');
process.env[API_KEY_ENV_VAR] = "'hmok_abc123'";
expect(readEnvCredential()?.accessToken).toBe('hmok_abc123');
});

it('leaves an unbalanced quote alone, so it still fails as malformed', () => {
process.env[API_KEY_ENV_VAR] = '"hmok_abc123';
expect(() => readEnvCredential()).toThrow(AuthError);
});

it('gives each env key its own notification-cache fingerprint', async () => {
const { credentialFingerprint } = await import('../../notifications-nudge.js');
const a = credentialFingerprint({
accessToken: 'hmok_keyA', refreshToken: '', expiresAt: 0, kind: 'agent', source: 'env',
});
const b = credentialFingerprint({
accessToken: 'hmok_keyB', refreshToken: '', expiresAt: 0, kind: 'agent', source: 'env',
});
expect(a).not.toBe(b);
expect(a).not.toBe('unknown');
// Derived, never the secret itself.
expect(a).not.toContain('hmok_keyA');
});

it('rejects a malformed value, naming the variable', () => {
process.env[API_KEY_ENV_VAR] = 'not-a-key';
expect(() => readEnvCredential()).toThrow(AuthError);
expect(() => readEnvCredential()).toThrow(/HOOKMYAPP_API_KEY/);
});

it('never echoes the key in the malformed-value error', () => {
process.env[API_KEY_ENV_VAR] = 'not-a-key-supersecret';
try {
readEnvCredential();
throw new Error('expected a throw');
} catch (err) {
expect((err as Error).message).not.toContain('supersecret');
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});
});
89 changes: 89 additions & 0 deletions src/auth/__tests__/logout.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,19 +15,26 @@ vi.mock('../../commands/mcp.js', () => ({ removeClaudeMcp: removeClaudeMcpMock }

let DIR: string;
const SAVED = process.env.HOOKMYAPP_CONFIG_DIR;
// HOOKMYAPP_API_KEY is a supported auth mechanism now, so a CI runner may well
// have one exported. Every baseline expectation here assumes no env
// credential; clear it per test and let the env-specific cases set their own.
const SAVED_KEY = process.env.HOOKMYAPP_API_KEY;
let logSpy: ReturnType<typeof vi.spyOn>;

beforeEach(() => {
removeClaudeMcpMock.mockReset().mockReturnValue({ ok: true });
DIR = mkdtempSync(join(tmpdir(), 'hma-logout-'));
process.env.HOOKMYAPP_CONFIG_DIR = DIR;
delete process.env.HOOKMYAPP_API_KEY;
logSpy = vi.spyOn(console, 'log').mockReturnValue(undefined);
});

afterEach(() => {
rmSync(DIR, { recursive: true, force: true });
if (SAVED) process.env.HOOKMYAPP_CONFIG_DIR = SAVED;
else delete process.env.HOOKMYAPP_CONFIG_DIR;
if (SAVED_KEY === undefined) delete process.env.HOOKMYAPP_API_KEY;
else process.env.HOOKMYAPP_API_KEY = SAVED_KEY;
vi.restoreAllMocks();
});

Expand DownExpand Up@@ -69,12 +76,94 @@ describe('logout', () => {
expect(JSON.parse(written.trim())).toEqual({
status: 'logged_out',
revoked: false,
envKeyActive: false,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Isolate logout tests from the API-key environment

When the test runner has HOOKMYAPP_API_KEY set, as may happen in CI now that this is a supported authentication mechanism, the suite inherits it because beforeEach only isolates the config directory. logout then correctly returns logged_out_with_warning with envKeyActive: true, so this assertion and other baseline logout expectations fail; save, clear, and restore the variable for every test rather than only inside the environment-specific cases.

Useful? React with 👍 / 👎.

mcpCleanup: { ok: true },
});
// The human check line must NOT be printed in --json mode.
expect(logSpy.mock.calls.flat().join('')).not.toMatch(/Logged out/);
});

// AIT-438: an env key keeps authenticating after logout. Automation reads
// the payload, not the stderr warning, so the signal has to be in the JSON.
test('--json flags a still-active HOOKMYAPP_API_KEY', async () => {
process.env.HOOKMYAPP_API_KEY = 'hmok_stillhere';
const credsPath = join(DIR, 'credentials.json');
writeFileSync(credsPath, JSON.stringify({ accessToken: 'a', refreshToken: 'r', expiresAt: 1 }));
const stdoutSpy = vi.spyOn(process.stdout, 'write').mockReturnValue(true);

{
await runLogout(['--json']);
const written = stdoutSpy.mock.calls.map((c) => String(c[0])).join('');
expect(JSON.parse(written.trim())).toMatchObject({
status: 'logged_out_with_warning',
envKeyActive: true,
envKeyVar: 'HOOKMYAPP_API_KEY',
});
}
});

// AIT-438: the revoke goes through apiClient, which authenticates with the
// env key while it is set — a "self-revoke" would kill the credential every
// other process is sharing.
test('skips the server-side revoke while HOOKMYAPP_API_KEY is set', async () => {
process.env.HOOKMYAPP_API_KEY = 'hmok_stillhere';
writeFileSync(
join(DIR, 'credentials.json'),
JSON.stringify({
accessToken: 'hmok_stillhere',
refreshToken: '',
expiresAt: 0,
kind: 'agent',
credentialPublicId: 'ac_self0001',
}),
);
const stdoutSpy = vi.spyOn(process.stdout, 'write').mockReturnValue(true);

{
await runLogout(['--json']);
const payload = JSON.parse(
stdoutSpy.mock.calls.map((c) => String(c[0])).join('').trim(),
);
expect(payload.revoked).toBe(false);
expect(payload.envKeyActive).toBe(true);
expect(payload.envKeyIsStoredKey).toBe(true);
expect(existsSync(join(DIR, 'credentials.json'))).toBe(false);
}
});

// A different env key must not stop logout from revoking the stored one:
// the request is pinned to the stored token, so it is a real self-revoke.
test('still revokes a stored key when the env holds a DIFFERENT key', async () => {
process.env.HOOKMYAPP_API_KEY = 'hmok_otherkey';
writeFileSync(
join(DIR, 'credentials.json'),
JSON.stringify({
accessToken: 'hmok_storedkey',
refreshToken: '',
expiresAt: 0,
kind: 'agent',
credentialPublicId: 'ac_stored01',
}),
);
const stdoutSpy = vi.spyOn(process.stdout, 'write').mockReturnValue(true);
const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 204 }));
vi.stubGlobal('fetch', fetchMock);

{
await runLogout(['--json']);
const payload = JSON.parse(
stdoutSpy.mock.calls.map((c) => String(c[0])).join('').trim(),
);
expect(payload.envKeyIsStoredKey).toBe(false);
const call = fetchMock.mock.calls.find(([url]) =>
String(url).includes('/agent/credentials/ac_stored01'),
);
expect(call).toBeDefined();
// Pinned to the stored key, NOT the env key that would otherwise win.
expect(call![1].headers.Authorization).toBe('Bearer hmok_storedkey');
}
});

test('reports MCP cleanup failure after credentials are removed', async () => {
removeClaudeMcpMock.mockReturnValue({ ok: false, detail: 'Claude MCP cleanup timed out' });
const stdoutSpy = vi.spyOn(process.stdout, 'write').mockReturnValue(true);
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions src/api/__tests__/env-key-bearer.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { apiClient } from '../client.js';
import { API_KEY_ENV_VAR } from '../../config/env-vars.js';
import * as secrets from '../../storage/secrets.js';
import { AuthError, ForbiddenError } from '../../output/error.js';
import { setWorkspaceContext } from '../client.js';

// The env key must reach the wire as the bearer token, exactly as a stored
// agent credential does — no refresh attempt, no rewriting (AIT-438).
describe('apiClient with HOOKMYAPP_API_KEY (AIT-438)', () => {
const original = process.env[API_KEY_ENV_VAR];

beforeEach(() => {
vi.restoreAllMocks();
vi.spyOn(secrets, 'readSecrets').mockResolvedValue(null);
});
afterEach(() => {
if (original === undefined) delete process.env[API_KEY_ENV_VAR];
else process.env[API_KEY_ENV_VAR] = original;
vi.unstubAllGlobals();
});

// A revoked key 401s. "Session expired. Run: login" is wrong twice over —
// there is no session, and login refuses while the variable is set.
it('names the variable when the key is rejected, not "session expired"', async () => {
process.env[API_KEY_ENV_VAR] = 'hmok_revoked';
vi.stubGlobal('fetch', vi.fn(async () => ({
ok: false,
status: 401,
headers: new Headers(),
json: async () => ({ message: 'Unauthorized' }),
})));

await expect(apiClient('/workspaces')).rejects.toThrow(AuthError);
await expect(apiClient('/workspaces')).rejects.toThrow(
/HOOKMYAPP_API_KEY was rejected/,
);
});

// A bare 403 maps to PermissionError, whose stock message says "run:
// hookmyapp login" — which login now refuses to do while the key is set.
it('gives permission guidance, not login advice, on a bare 403', async () => {
process.env[API_KEY_ENV_VAR] = 'hmok_lowperm';
setWorkspaceContext({ workspaceId: 'ws_abc12345' });
vi.stubGlobal('fetch', vi.fn(async () => ({
ok: false,
status: 403,
headers: new Headers(),
json: async () => ({ message: 'Forbidden' }),
})));

await expect(apiClient('/channels')).rejects.toThrow(ForbiddenError);
await expect(apiClient('/channels')).rejects.toThrow(
/HOOKMYAPP_API_KEY lacks permission for workspace ws_abc12345/,
);
await expect(apiClient('/channels')).rejects.not.toThrow(/hookmyapp login/);
setWorkspaceContext({ workspaceId: null });
});

it('sends the env key as the Authorization bearer', async () => {
process.env[API_KEY_ENV_VAR] = 'hmok_livekey123';
const fetchMock = vi.fn(async (_url: string, _init: RequestInit) => ({
ok: true,
status: 200,
headers: new Headers(),
json: async () => [{ id: 'ws_abc12345' }],
}));
vi.stubGlobal('fetch', fetchMock);

const res = await apiClient('/workspaces');

expect(res).toEqual([{ id: 'ws_abc12345' }]);
const headers = fetchMock.mock.calls[0]![1].headers as Record<string, string>;
expect(headers.Authorization).toBe('Bearer hmok_livekey123');
// One call only: an agent credential has no refresh token, so nothing
// should have tried to reach WorkOS first.
expect(fetchMock).toHaveBeenCalledTimes(1);
});
});
38 changes: 34 additions & 4 deletions src/api/client.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -20,6 +20,7 @@ import {
getEffectiveWorkosClientId,
} from '../config/env-profiles.js';
import { buildVersionHeaders } from './version-headers.js';
import { API_KEY_ENV_VAR } from '../config/env-vars.js';

// Module-level workspace context populated by the top-level CLI entry after
// parsing --workspace. Explicit options.workspaceId on a specific apiClient()
Expand DownExpand Up@@ -312,9 +313,14 @@ export function describeFetchError(err: unknown): string {

export async function apiClient(
path: string,
options?: RequestInit & { workspaceId?: string },
// `bearerToken` pins the request to one credential instead of the resolved
// one. Only logout needs it: it must authenticate as the STORED key to
// revoke it, which the env key would otherwise shadow (AIT-438).
options?: RequestInit & { workspaceId?: string; bearerToken?: string },
): Promise<any> {
const creds = await readCredentials();
const creds = options?.bearerToken
? ({ accessToken: options.bearerToken, refreshToken: '', expiresAt: 0, kind: 'agent' } as const)
: await readCredentials();
if (!creds) {
throw new AuthError('Not logged in. Run: hookmyapp login');
}
Expand All@@ -335,7 +341,7 @@ export async function apiClient(

const baseUrl = getEffectiveApiUrl();

const { workspaceId, ...fetchOptions } = options ?? {};
const { workspaceId, bearerToken: _bearerToken, ...fetchOptions } = options ?? {};

const headers: Record<string, string> = {
Authorization: `Bearer ${accessToken}`,
Expand DownExpand Up@@ -383,7 +389,31 @@ export async function apiClient(
}

if (!res.ok) {
throw await mapApiError(res);
const err = await mapApiError(res);
// A 401 on an env credential must not say "Session expired. Run: login":
// there is no session, and `login` refuses to run while the variable is
// set, so that guidance is a loop (AIT-438).
if ('source' in creds && creds.source === 'env') {
if (err instanceof AuthError) {
throw new AuthError(
`The API key in ${API_KEY_ENV_VAR} was rejected (invalid or revoked). Replace it or unset the variable.`,
);
}
// A bare 403 maps to PermissionError, whose message names the persisted
// workspace slug and says "run: hookmyapp login" — wrong on both counts
// here: login refuses while the variable is set, and the workspace in
// play may have come from the environment too.
if (err instanceof PermissionError) {
// resolvedWsId already folds in options.workspaceId, which beats the
// shared context for this specific call.
const ws = resolvedWsId ?? '(unresolved)';
throw new ForbiddenError(
`The API key in ${API_KEY_ENV_VAR} lacks permission for workspace ${ws}. Use a key with the required role, or unset the variable to use your stored login.`,
'AGENT_KEY_FORBIDDEN',
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}
throw err;
}

// 204 No Content (and other empty-body 2xx responses) have no JSON to parse.
Expand Down
112 changes: 112 additions & 0 deletions src/auth/__tests__/env-api-key.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { readCredentials, readEnvCredential } from '../store.js';
import { API_KEY_ENV_VAR } from '../../config/env-vars.js';
import * as secrets from '../../storage/secrets.js';
import { AuthError } from '../../output/error.js';

const STORED = {
accessToken: 'stored-token',
refreshToken: 'r',
expiresAt: 0,
} as const;

describe('HOOKMYAPP_API_KEY credential (AIT-438)', () => {
const original = process.env[API_KEY_ENV_VAR];

beforeEach(() => {
delete process.env[API_KEY_ENV_VAR];
vi.restoreAllMocks();
});
afterEach(() => {
if (original === undefined) delete process.env[API_KEY_ENV_VAR];
else process.env[API_KEY_ENV_VAR] = original;
});

it('is used when no credential is stored', async () => {
vi.spyOn(secrets, 'readSecrets').mockResolvedValue(null);
process.env[API_KEY_ENV_VAR] = 'hmok_abc123';
const creds = await readCredentials();
expect(creds?.accessToken).toBe('hmok_abc123');
// Shaped as an agent credential so refresh/rescope stay no-ops.
expect(creds?.kind).toBe('agent');
expect(creds?.source).toBe('env');
});

it('outranks a stored credential', async () => {
vi.spyOn(secrets, 'readSecrets').mockResolvedValue({ ...STORED });
process.env[API_KEY_ENV_VAR] = 'hmok_abc123';
expect((await readCredentials())?.accessToken).toBe('hmok_abc123');
});

it('leaves the stored credential in charge when unset', async () => {
vi.spyOn(secrets, 'readSecrets').mockResolvedValue({ ...STORED });
const creds = await readCredentials();
expect(creds?.accessToken).toBe('stored-token');
expect(creds?.source).toBeUndefined();
});

it('accepts legacy ac_ keys, which the backend still resolves', () => {
process.env[API_KEY_ENV_VAR] = 'ac_legacy';
expect(readEnvCredential()?.accessToken).toBe('ac_legacy');
});

it('ignores an empty or whitespace-only value', () => {
process.env[API_KEY_ENV_VAR] = ' ';
expect(readEnvCredential()).toBeNull();
});

// `set HOOKMYAPP_API_KEY=""` on cmd.exe stores the two quote characters.
// Every caller must agree that is "unset", or login refuses while no
// credential exists.
it.each(['""', "''", '" "'])('treats %s as unset everywhere', async (value) => {
process.env[API_KEY_ENV_VAR] = value;
expect(readEnvCredential()).toBeNull();
const { envApiKey } = await import('../../config/env-vars.js');
expect(envApiKey()).toBe('');
});

// cmd.exe keeps the quotes in `set HOOKMYAPP_API_KEY="hmok_abc"`, unlike
// PowerShell and POSIX shells — without stripping, Windows users get
// "not a valid API key" for a key that plainly starts with hmok_.
it('accepts a value quoted the way cmd.exe stores it', () => {
process.env[API_KEY_ENV_VAR] = '"hmok_abc123"';
expect(readEnvCredential()?.accessToken).toBe('hmok_abc123');
process.env[API_KEY_ENV_VAR] = "'hmok_abc123'";
expect(readEnvCredential()?.accessToken).toBe('hmok_abc123');
});

it('leaves an unbalanced quote alone, so it still fails as malformed', () => {
process.env[API_KEY_ENV_VAR] = '"hmok_abc123';
expect(() => readEnvCredential()).toThrow(AuthError);
});

it('gives each env key its own notification-cache fingerprint', async () => {
const { credentialFingerprint } = await import('../../notifications-nudge.js');
const a = credentialFingerprint({
accessToken: 'hmok_keyA', refreshToken: '', expiresAt: 0, kind: 'agent', source: 'env',
});
const b = credentialFingerprint({
accessToken: 'hmok_keyB', refreshToken: '', expiresAt: 0, kind: 'agent', source: 'env',
});
expect(a).not.toBe(b);
expect(a).not.toBe('unknown');
// Derived, never the secret itself.
expect(a).not.toContain('hmok_keyA');
});

it('rejects a malformed value, naming the variable', () => {
process.env[API_KEY_ENV_VAR] = 'not-a-key';
expect(() => readEnvCredential()).toThrow(AuthError);
expect(() => readEnvCredential()).toThrow(/HOOKMYAPP_API_KEY/);
});

it('never echoes the key in the malformed-value error', () => {
process.env[API_KEY_ENV_VAR] = 'not-a-key-supersecret';
try {
readEnvCredential();
throw new Error('expected a throw');
} catch (err) {
expect((err as Error).message).not.toContain('supersecret');
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});
});
89 changes: 89 additions & 0 deletions src/auth/__tests__/logout.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,19 +15,26 @@ vi.mock('../../commands/mcp.js', () => ({ removeClaudeMcp: removeClaudeMcpMock }

let DIR: string;
const SAVED = process.env.HOOKMYAPP_CONFIG_DIR;
// HOOKMYAPP_API_KEY is a supported auth mechanism now, so a CI runner may well
// have one exported. Every baseline expectation here assumes no env
// credential; clear it per test and let the env-specific cases set their own.
const SAVED_KEY = process.env.HOOKMYAPP_API_KEY;
let logSpy: ReturnType<typeof vi.spyOn>;

beforeEach(() => {
removeClaudeMcpMock.mockReset().mockReturnValue({ ok: true });
DIR = mkdtempSync(join(tmpdir(), 'hma-logout-'));
process.env.HOOKMYAPP_CONFIG_DIR = DIR;
delete process.env.HOOKMYAPP_API_KEY;
logSpy = vi.spyOn(console, 'log').mockReturnValue(undefined);
});

afterEach(() => {
rmSync(DIR, { recursive: true, force: true });
if (SAVED) process.env.HOOKMYAPP_CONFIG_DIR = SAVED;
else delete process.env.HOOKMYAPP_CONFIG_DIR;
if (SAVED_KEY === undefined) delete process.env.HOOKMYAPP_API_KEY;
else process.env.HOOKMYAPP_API_KEY = SAVED_KEY;
vi.restoreAllMocks();
});

Expand DownExpand Up@@ -69,12 +76,94 @@ describe('logout', () => {
expect(JSON.parse(written.trim())).toEqual({
status: 'logged_out',
revoked: false,
envKeyActive: false,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Isolate logout tests from the API-key environment

When the test runner has HOOKMYAPP_API_KEY set, as may happen in CI now that this is a supported authentication mechanism, the suite inherits it because beforeEach only isolates the config directory. logout then correctly returns logged_out_with_warning with envKeyActive: true, so this assertion and other baseline logout expectations fail; save, clear, and restore the variable for every test rather than only inside the environment-specific cases.

Useful? React with 👍 / 👎.

mcpCleanup: { ok: true },
});
// The human check line must NOT be printed in --json mode.
expect(logSpy.mock.calls.flat().join('')).not.toMatch(/Logged out/);
});

// AIT-438: an env key keeps authenticating after logout. Automation reads
// the payload, not the stderr warning, so the signal has to be in the JSON.
test('--json flags a still-active HOOKMYAPP_API_KEY', async () => {
process.env.HOOKMYAPP_API_KEY = 'hmok_stillhere';
const credsPath = join(DIR, 'credentials.json');
writeFileSync(credsPath, JSON.stringify({ accessToken: 'a', refreshToken: 'r', expiresAt: 1 }));
const stdoutSpy = vi.spyOn(process.stdout, 'write').mockReturnValue(true);

{
await runLogout(['--json']);
const written = stdoutSpy.mock.calls.map((c) => String(c[0])).join('');
expect(JSON.parse(written.trim())).toMatchObject({
status: 'logged_out_with_warning',
envKeyActive: true,
envKeyVar: 'HOOKMYAPP_API_KEY',
});
}
});

// AIT-438: the revoke goes through apiClient, which authenticates with the
// env key while it is set — a "self-revoke" would kill the credential every
// other process is sharing.
test('skips the server-side revoke while HOOKMYAPP_API_KEY is set', async () => {
process.env.HOOKMYAPP_API_KEY = 'hmok_stillhere';
writeFileSync(
join(DIR, 'credentials.json'),
JSON.stringify({
accessToken: 'hmok_stillhere',
refreshToken: '',
expiresAt: 0,
kind: 'agent',
credentialPublicId: 'ac_self0001',
}),
);
const stdoutSpy = vi.spyOn(process.stdout, 'write').mockReturnValue(true);

{
await runLogout(['--json']);
const payload = JSON.parse(
stdoutSpy.mock.calls.map((c) => String(c[0])).join('').trim(),
);
expect(payload.revoked).toBe(false);
expect(payload.envKeyActive).toBe(true);
expect(payload.envKeyIsStoredKey).toBe(true);
expect(existsSync(join(DIR, 'credentials.json'))).toBe(false);
}
});

// A different env key must not stop logout from revoking the stored one:
// the request is pinned to the stored token, so it is a real self-revoke.
test('still revokes a stored key when the env holds a DIFFERENT key', async () => {
process.env.HOOKMYAPP_API_KEY = 'hmok_otherkey';
writeFileSync(
join(DIR, 'credentials.json'),
JSON.stringify({
accessToken: 'hmok_storedkey',
refreshToken: '',
expiresAt: 0,
kind: 'agent',
credentialPublicId: 'ac_stored01',
}),
);
const stdoutSpy = vi.spyOn(process.stdout, 'write').mockReturnValue(true);
const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 204 }));
vi.stubGlobal('fetch', fetchMock);

{
await runLogout(['--json']);
const payload = JSON.parse(
stdoutSpy.mock.calls.map((c) => String(c[0])).join('').trim(),
);
expect(payload.envKeyIsStoredKey).toBe(false);
const call = fetchMock.mock.calls.find(([url]) =>
String(url).includes('/agent/credentials/ac_stored01'),
);
expect(call).toBeDefined();
// Pinned to the stored key, NOT the env key that would otherwise win.
expect(call![1].headers.Authorization).toBe('Bearer hmok_storedkey');
}
});

test('reports MCP cleanup failure after credentials are removed', async () => {
removeClaudeMcpMock.mockReturnValue({ ok: false, detail: 'Claude MCP cleanup timed out' });
const stdoutSpy = vi.spyOn(process.stdout, 'write').mockReturnValue(true);
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions src/api/__tests__/env-key-bearer.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { apiClient } from '../client.js';
import { API_KEY_ENV_VAR } from '../../config/env-vars.js';
import * as secrets from '../../storage/secrets.js';
import { AuthError, ForbiddenError } from '../../output/error.js';
import { setWorkspaceContext } from '../client.js';

// The env key must reach the wire as the bearer token, exactly as a stored
// agent credential does — no refresh attempt, no rewriting (AIT-438).
describe('apiClient with HOOKMYAPP_API_KEY (AIT-438)', () => {
const original = process.env[API_KEY_ENV_VAR];

beforeEach(() => {
vi.restoreAllMocks();
vi.spyOn(secrets, 'readSecrets').mockResolvedValue(null);
});
afterEach(() => {
if (original === undefined) delete process.env[API_KEY_ENV_VAR];
else process.env[API_KEY_ENV_VAR] = original;
vi.unstubAllGlobals();
});

// A revoked key 401s. "Session expired. Run: login" is wrong twice over —
// there is no session, and login refuses while the variable is set.
it('names the variable when the key is rejected, not "session expired"', async () => {
process.env[API_KEY_ENV_VAR] = 'hmok_revoked';
vi.stubGlobal('fetch', vi.fn(async () => ({
ok: false,
status: 401,
headers: new Headers(),
json: async () => ({ message: 'Unauthorized' }),
})));

await expect(apiClient('/workspaces')).rejects.toThrow(AuthError);
await expect(apiClient('/workspaces')).rejects.toThrow(
/HOOKMYAPP_API_KEY was rejected/,
);
});

// A bare 403 maps to PermissionError, whose stock message says "run:
// hookmyapp login" — which login now refuses to do while the key is set.
it('gives permission guidance, not login advice, on a bare 403', async () => {
process.env[API_KEY_ENV_VAR] = 'hmok_lowperm';
setWorkspaceContext({ workspaceId: 'ws_abc12345' });
vi.stubGlobal('fetch', vi.fn(async () => ({
ok: false,
status: 403,
headers: new Headers(),
json: async () => ({ message: 'Forbidden' }),
})));

await expect(apiClient('/channels')).rejects.toThrow(ForbiddenError);
await expect(apiClient('/channels')).rejects.toThrow(
/HOOKMYAPP_API_KEY lacks permission for workspace ws_abc12345/,
);
await expect(apiClient('/channels')).rejects.not.toThrow(/hookmyapp login/);
setWorkspaceContext({ workspaceId: null });
});

it('sends the env key as the Authorization bearer', async () => {
process.env[API_KEY_ENV_VAR] = 'hmok_livekey123';
const fetchMock = vi.fn(async (_url: string, _init: RequestInit) => ({
ok: true,
status: 200,
headers: new Headers(),
json: async () => [{ id: 'ws_abc12345' }],
}));
vi.stubGlobal('fetch', fetchMock);

const res = await apiClient('/workspaces');

expect(res).toEqual([{ id: 'ws_abc12345' }]);
const headers = fetchMock.mock.calls[0]![1].headers as Record<string, string>;
expect(headers.Authorization).toBe('Bearer hmok_livekey123');
// One call only: an agent credential has no refresh token, so nothing
// should have tried to reach WorkOS first.
expect(fetchMock).toHaveBeenCalledTimes(1);
});
});
38 changes: 34 additions & 4 deletions src/api/client.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -20,6 +20,7 @@ import {
getEffectiveWorkosClientId,
} from '../config/env-profiles.js';
import { buildVersionHeaders } from './version-headers.js';
import { API_KEY_ENV_VAR } from '../config/env-vars.js';

// Module-level workspace context populated by the top-level CLI entry after
// parsing --workspace. Explicit options.workspaceId on a specific apiClient()
Expand DownExpand Up@@ -312,9 +313,14 @@ export function describeFetchError(err: unknown): string {

export async function apiClient(
path: string,
options?: RequestInit & { workspaceId?: string },
// `bearerToken` pins the request to one credential instead of the resolved
// one. Only logout needs it: it must authenticate as the STORED key to
// revoke it, which the env key would otherwise shadow (AIT-438).
options?: RequestInit & { workspaceId?: string; bearerToken?: string },
): Promise<any> {
const creds = await readCredentials();
const creds = options?.bearerToken
? ({ accessToken: options.bearerToken, refreshToken: '', expiresAt: 0, kind: 'agent' } as const)
: await readCredentials();
if (!creds) {
throw new AuthError('Not logged in. Run: hookmyapp login');
}
Expand All@@ -335,7 +341,7 @@ export async function apiClient(

const baseUrl = getEffectiveApiUrl();

const { workspaceId, ...fetchOptions } = options ?? {};
const { workspaceId, bearerToken: _bearerToken, ...fetchOptions } = options ?? {};

const headers: Record<string, string> = {
Authorization: `Bearer ${accessToken}`,
Expand DownExpand Up@@ -383,7 +389,31 @@ export async function apiClient(
}

if (!res.ok) {
throw await mapApiError(res);
const err = await mapApiError(res);
// A 401 on an env credential must not say "Session expired. Run: login":
// there is no session, and `login` refuses to run while the variable is
// set, so that guidance is a loop (AIT-438).
if ('source' in creds && creds.source === 'env') {
if (err instanceof AuthError) {
throw new AuthError(
`The API key in ${API_KEY_ENV_VAR} was rejected (invalid or revoked). Replace it or unset the variable.`,
);
}
// A bare 403 maps to PermissionError, whose message names the persisted
// workspace slug and says "run: hookmyapp login" — wrong on both counts
// here: login refuses while the variable is set, and the workspace in
// play may have come from the environment too.
if (err instanceof PermissionError) {
// resolvedWsId already folds in options.workspaceId, which beats the
// shared context for this specific call.
const ws = resolvedWsId ?? '(unresolved)';
throw new ForbiddenError(
`The API key in ${API_KEY_ENV_VAR} lacks permission for workspace ${ws}. Use a key with the required role, or unset the variable to use your stored login.`,
'AGENT_KEY_FORBIDDEN',
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}
throw err;
}

// 204 No Content (and other empty-body 2xx responses) have no JSON to parse.
Expand Down
112 changes: 112 additions & 0 deletions src/auth/__tests__/env-api-key.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { readCredentials, readEnvCredential } from '../store.js';
import { API_KEY_ENV_VAR } from '../../config/env-vars.js';
import * as secrets from '../../storage/secrets.js';
import { AuthError } from '../../output/error.js';

const STORED = {
accessToken: 'stored-token',
refreshToken: 'r',
expiresAt: 0,
} as const;

describe('HOOKMYAPP_API_KEY credential (AIT-438)', () => {
const original = process.env[API_KEY_ENV_VAR];

beforeEach(() => {
delete process.env[API_KEY_ENV_VAR];
vi.restoreAllMocks();
});
afterEach(() => {
if (original === undefined) delete process.env[API_KEY_ENV_VAR];
else process.env[API_KEY_ENV_VAR] = original;
});

it('is used when no credential is stored', async () => {
vi.spyOn(secrets, 'readSecrets').mockResolvedValue(null);
process.env[API_KEY_ENV_VAR] = 'hmok_abc123';
const creds = await readCredentials();
expect(creds?.accessToken).toBe('hmok_abc123');
// Shaped as an agent credential so refresh/rescope stay no-ops.
expect(creds?.kind).toBe('agent');
expect(creds?.source).toBe('env');
});

it('outranks a stored credential', async () => {
vi.spyOn(secrets, 'readSecrets').mockResolvedValue({ ...STORED });
process.env[API_KEY_ENV_VAR] = 'hmok_abc123';
expect((await readCredentials())?.accessToken).toBe('hmok_abc123');
});

it('leaves the stored credential in charge when unset', async () => {
vi.spyOn(secrets, 'readSecrets').mockResolvedValue({ ...STORED });
const creds = await readCredentials();
expect(creds?.accessToken).toBe('stored-token');
expect(creds?.source).toBeUndefined();
});

it('accepts legacy ac_ keys, which the backend still resolves', () => {
process.env[API_KEY_ENV_VAR] = 'ac_legacy';
expect(readEnvCredential()?.accessToken).toBe('ac_legacy');
});

it('ignores an empty or whitespace-only value', () => {
process.env[API_KEY_ENV_VAR] = ' ';
expect(readEnvCredential()).toBeNull();
});

// `set HOOKMYAPP_API_KEY=""` on cmd.exe stores the two quote characters.
// Every caller must agree that is "unset", or login refuses while no
// credential exists.
it.each(['""', "''", '" "'])('treats %s as unset everywhere', async (value) => {
process.env[API_KEY_ENV_VAR] = value;
expect(readEnvCredential()).toBeNull();
const { envApiKey } = await import('../../config/env-vars.js');
expect(envApiKey()).toBe('');
});

// cmd.exe keeps the quotes in `set HOOKMYAPP_API_KEY="hmok_abc"`, unlike
// PowerShell and POSIX shells — without stripping, Windows users get
// "not a valid API key" for a key that plainly starts with hmok_.
it('accepts a value quoted the way cmd.exe stores it', () => {
process.env[API_KEY_ENV_VAR] = '"hmok_abc123"';
expect(readEnvCredential()?.accessToken).toBe('hmok_abc123');
process.env[API_KEY_ENV_VAR] = "'hmok_abc123'";
expect(readEnvCredential()?.accessToken).toBe('hmok_abc123');
});

it('leaves an unbalanced quote alone, so it still fails as malformed', () => {
process.env[API_KEY_ENV_VAR] = '"hmok_abc123';
expect(() => readEnvCredential()).toThrow(AuthError);
});

it('gives each env key its own notification-cache fingerprint', async () => {
const { credentialFingerprint } = await import('../../notifications-nudge.js');
const a = credentialFingerprint({
accessToken: 'hmok_keyA', refreshToken: '', expiresAt: 0, kind: 'agent', source: 'env',
});
const b = credentialFingerprint({
accessToken: 'hmok_keyB', refreshToken: '', expiresAt: 0, kind: 'agent', source: 'env',
});
expect(a).not.toBe(b);
expect(a).not.toBe('unknown');
// Derived, never the secret itself.
expect(a).not.toContain('hmok_keyA');
});

it('rejects a malformed value, naming the variable', () => {
process.env[API_KEY_ENV_VAR] = 'not-a-key';
expect(() => readEnvCredential()).toThrow(AuthError);
expect(() => readEnvCredential()).toThrow(/HOOKMYAPP_API_KEY/);
});

it('never echoes the key in the malformed-value error', () => {
process.env[API_KEY_ENV_VAR] = 'not-a-key-supersecret';
try {
readEnvCredential();
throw new Error('expected a throw');
} catch (err) {
expect((err as Error).message).not.toContain('supersecret');
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});
});
89 changes: 89 additions & 0 deletions src/auth/__tests__/logout.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,19 +15,26 @@ vi.mock('../../commands/mcp.js', () => ({ removeClaudeMcp: removeClaudeMcpMock }

let DIR: string;
const SAVED = process.env.HOOKMYAPP_CONFIG_DIR;
// HOOKMYAPP_API_KEY is a supported auth mechanism now, so a CI runner may well
// have one exported. Every baseline expectation here assumes no env
// credential; clear it per test and let the env-specific cases set their own.
const SAVED_KEY = process.env.HOOKMYAPP_API_KEY;
let logSpy: ReturnType<typeof vi.spyOn>;

beforeEach(() => {
removeClaudeMcpMock.mockReset().mockReturnValue({ ok: true });
DIR = mkdtempSync(join(tmpdir(), 'hma-logout-'));
process.env.HOOKMYAPP_CONFIG_DIR = DIR;
delete process.env.HOOKMYAPP_API_KEY;
logSpy = vi.spyOn(console, 'log').mockReturnValue(undefined);
});

afterEach(() => {
rmSync(DIR, { recursive: true, force: true });
if (SAVED) process.env.HOOKMYAPP_CONFIG_DIR = SAVED;
else delete process.env.HOOKMYAPP_CONFIG_DIR;
if (SAVED_KEY === undefined) delete process.env.HOOKMYAPP_API_KEY;
else process.env.HOOKMYAPP_API_KEY = SAVED_KEY;
vi.restoreAllMocks();
});

Expand DownExpand Up@@ -69,12 +76,94 @@ describe('logout', () => {
expect(JSON.parse(written.trim())).toEqual({
status: 'logged_out',
revoked: false,
envKeyActive: false,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Isolate logout tests from the API-key environment

When the test runner has HOOKMYAPP_API_KEY set, as may happen in CI now that this is a supported authentication mechanism, the suite inherits it because beforeEach only isolates the config directory. logout then correctly returns logged_out_with_warning with envKeyActive: true, so this assertion and other baseline logout expectations fail; save, clear, and restore the variable for every test rather than only inside the environment-specific cases.

Useful? React with 👍 / 👎.

mcpCleanup: { ok: true },
});
// The human check line must NOT be printed in --json mode.
expect(logSpy.mock.calls.flat().join('')).not.toMatch(/Logged out/);
});

// AIT-438: an env key keeps authenticating after logout. Automation reads
// the payload, not the stderr warning, so the signal has to be in the JSON.
test('--json flags a still-active HOOKMYAPP_API_KEY', async () => {
process.env.HOOKMYAPP_API_KEY = 'hmok_stillhere';
const credsPath = join(DIR, 'credentials.json');
writeFileSync(credsPath, JSON.stringify({ accessToken: 'a', refreshToken: 'r', expiresAt: 1 }));
const stdoutSpy = vi.spyOn(process.stdout, 'write').mockReturnValue(true);

{
await runLogout(['--json']);
const written = stdoutSpy.mock.calls.map((c) => String(c[0])).join('');
expect(JSON.parse(written.trim())).toMatchObject({
status: 'logged_out_with_warning',
envKeyActive: true,
envKeyVar: 'HOOKMYAPP_API_KEY',
});
}
});

// AIT-438: the revoke goes through apiClient, which authenticates with the
// env key while it is set — a "self-revoke" would kill the credential every
// other process is sharing.
test('skips the server-side revoke while HOOKMYAPP_API_KEY is set', async () => {
process.env.HOOKMYAPP_API_KEY = 'hmok_stillhere';
writeFileSync(
join(DIR, 'credentials.json'),
JSON.stringify({
accessToken: 'hmok_stillhere',
refreshToken: '',
expiresAt: 0,
kind: 'agent',
credentialPublicId: 'ac_self0001',
}),
);
const stdoutSpy = vi.spyOn(process.stdout, 'write').mockReturnValue(true);

{
await runLogout(['--json']);
const payload = JSON.parse(
stdoutSpy.mock.calls.map((c) => String(c[0])).join('').trim(),
);
expect(payload.revoked).toBe(false);
expect(payload.envKeyActive).toBe(true);
expect(payload.envKeyIsStoredKey).toBe(true);
expect(existsSync(join(DIR, 'credentials.json'))).toBe(false);
}
});

// A different env key must not stop logout from revoking the stored one:
// the request is pinned to the stored token, so it is a real self-revoke.
test('still revokes a stored key when the env holds a DIFFERENT key', async () => {
process.env.HOOKMYAPP_API_KEY = 'hmok_otherkey';
writeFileSync(
join(DIR, 'credentials.json'),
JSON.stringify({
accessToken: 'hmok_storedkey',
refreshToken: '',
expiresAt: 0,
kind: 'agent',
credentialPublicId: 'ac_stored01',
}),
);
const stdoutSpy = vi.spyOn(process.stdout, 'write').mockReturnValue(true);
const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 204 }));
vi.stubGlobal('fetch', fetchMock);

{
await runLogout(['--json']);
const payload = JSON.parse(
stdoutSpy.mock.calls.map((c) => String(c[0])).join('').trim(),
);
expect(payload.envKeyIsStoredKey).toBe(false);
const call = fetchMock.mock.calls.find(([url]) =>
String(url).includes('/agent/credentials/ac_stored01'),
);
expect(call).toBeDefined();
// Pinned to the stored key, NOT the env key that would otherwise win.
expect(call![1].headers.Authorization).toBe('Bearer hmok_storedkey');
}
});

test('reports MCP cleanup failure after credentials are removed', async () => {
removeClaudeMcpMock.mockReturnValue({ ok: false, detail: 'Claude MCP cleanup timed out' });
const stdoutSpy = vi.spyOn(process.stdout, 'write').mockReturnValue(true);
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions src/api/__tests__/env-key-bearer.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { apiClient } from '../client.js';
import { API_KEY_ENV_VAR } from '../../config/env-vars.js';
import * as secrets from '../../storage/secrets.js';
import { AuthError, ForbiddenError } from '../../output/error.js';
import { setWorkspaceContext } from '../client.js';

// The env key must reach the wire as the bearer token, exactly as a stored
// agent credential does — no refresh attempt, no rewriting (AIT-438).
describe('apiClient with HOOKMYAPP_API_KEY (AIT-438)', () => {
const original = process.env[API_KEY_ENV_VAR];

beforeEach(() => {
vi.restoreAllMocks();
vi.spyOn(secrets, 'readSecrets').mockResolvedValue(null);
});
afterEach(() => {
if (original === undefined) delete process.env[API_KEY_ENV_VAR];
else process.env[API_KEY_ENV_VAR] = original;
vi.unstubAllGlobals();
});

// A revoked key 401s. "Session expired. Run: login" is wrong twice over —
// there is no session, and login refuses while the variable is set.
it('names the variable when the key is rejected, not "session expired"', async () => {
process.env[API_KEY_ENV_VAR] = 'hmok_revoked';
vi.stubGlobal('fetch', vi.fn(async () => ({
ok: false,
status: 401,
headers: new Headers(),
json: async () => ({ message: 'Unauthorized' }),
})));

await expect(apiClient('/workspaces')).rejects.toThrow(AuthError);
await expect(apiClient('/workspaces')).rejects.toThrow(
/HOOKMYAPP_API_KEY was rejected/,
);
});

// A bare 403 maps to PermissionError, whose stock message says "run:
// hookmyapp login" — which login now refuses to do while the key is set.
it('gives permission guidance, not login advice, on a bare 403', async () => {
process.env[API_KEY_ENV_VAR] = 'hmok_lowperm';
setWorkspaceContext({ workspaceId: 'ws_abc12345' });
vi.stubGlobal('fetch', vi.fn(async () => ({
ok: false,
status: 403,
headers: new Headers(),
json: async () => ({ message: 'Forbidden' }),
})));

await expect(apiClient('/channels')).rejects.toThrow(ForbiddenError);
await expect(apiClient('/channels')).rejects.toThrow(
/HOOKMYAPP_API_KEY lacks permission for workspace ws_abc12345/,
);
await expect(apiClient('/channels')).rejects.not.toThrow(/hookmyapp login/);
setWorkspaceContext({ workspaceId: null });
});

it('sends the env key as the Authorization bearer', async () => {
process.env[API_KEY_ENV_VAR] = 'hmok_livekey123';
const fetchMock = vi.fn(async (_url: string, _init: RequestInit) => ({
ok: true,
status: 200,
headers: new Headers(),
json: async () => [{ id: 'ws_abc12345' }],
}));
vi.stubGlobal('fetch', fetchMock);

const res = await apiClient('/workspaces');

expect(res).toEqual([{ id: 'ws_abc12345' }]);
const headers = fetchMock.mock.calls[0]![1].headers as Record<string, string>;
expect(headers.Authorization).toBe('Bearer hmok_livekey123');
// One call only: an agent credential has no refresh token, so nothing
// should have tried to reach WorkOS first.
expect(fetchMock).toHaveBeenCalledTimes(1);
});
});
38 changes: 34 additions & 4 deletions src/api/client.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -20,6 +20,7 @@ import {
getEffectiveWorkosClientId,
} from '../config/env-profiles.js';
import { buildVersionHeaders } from './version-headers.js';
import { API_KEY_ENV_VAR } from '../config/env-vars.js';

// Module-level workspace context populated by the top-level CLI entry after
// parsing --workspace. Explicit options.workspaceId on a specific apiClient()
Expand DownExpand Up@@ -312,9 +313,14 @@ export function describeFetchError(err: unknown): string {

export async function apiClient(
path: string,
options?: RequestInit & { workspaceId?: string },
// `bearerToken` pins the request to one credential instead of the resolved
// one. Only logout needs it: it must authenticate as the STORED key to
// revoke it, which the env key would otherwise shadow (AIT-438).
options?: RequestInit & { workspaceId?: string; bearerToken?: string },
): Promise<any> {
const creds = await readCredentials();
const creds = options?.bearerToken
? ({ accessToken: options.bearerToken, refreshToken: '', expiresAt: 0, kind: 'agent' } as const)
: await readCredentials();
if (!creds) {
throw new AuthError('Not logged in. Run: hookmyapp login');
}
Expand All@@ -335,7 +341,7 @@ export async function apiClient(

const baseUrl = getEffectiveApiUrl();

const { workspaceId, ...fetchOptions } = options ?? {};
const { workspaceId, bearerToken: _bearerToken, ...fetchOptions } = options ?? {};

const headers: Record<string, string> = {
Authorization: `Bearer ${accessToken}`,
Expand DownExpand Up@@ -383,7 +389,31 @@ export async function apiClient(
}

if (!res.ok) {
throw await mapApiError(res);
const err = await mapApiError(res);
// A 401 on an env credential must not say "Session expired. Run: login":
// there is no session, and `login` refuses to run while the variable is
// set, so that guidance is a loop (AIT-438).
if ('source' in creds && creds.source === 'env') {
if (err instanceof AuthError) {
throw new AuthError(
`The API key in ${API_KEY_ENV_VAR} was rejected (invalid or revoked). Replace it or unset the variable.`,
);
}
// A bare 403 maps to PermissionError, whose message names the persisted
// workspace slug and says "run: hookmyapp login" — wrong on both counts
// here: login refuses while the variable is set, and the workspace in
// play may have come from the environment too.
if (err instanceof PermissionError) {
// resolvedWsId already folds in options.workspaceId, which beats the
// shared context for this specific call.
const ws = resolvedWsId ?? '(unresolved)';
throw new ForbiddenError(
`The API key in ${API_KEY_ENV_VAR} lacks permission for workspace ${ws}. Use a key with the required role, or unset the variable to use your stored login.`,
'AGENT_KEY_FORBIDDEN',
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}
throw err;
}

// 204 No Content (and other empty-body 2xx responses) have no JSON to parse.
Expand Down
112 changes: 112 additions & 0 deletions src/auth/__tests__/env-api-key.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { readCredentials, readEnvCredential } from '../store.js';
import { API_KEY_ENV_VAR } from '../../config/env-vars.js';
import * as secrets from '../../storage/secrets.js';
import { AuthError } from '../../output/error.js';

const STORED = {
accessToken: 'stored-token',
refreshToken: 'r',
expiresAt: 0,
} as const;

describe('HOOKMYAPP_API_KEY credential (AIT-438)', () => {
const original = process.env[API_KEY_ENV_VAR];

beforeEach(() => {
delete process.env[API_KEY_ENV_VAR];
vi.restoreAllMocks();
});
afterEach(() => {
if (original === undefined) delete process.env[API_KEY_ENV_VAR];
else process.env[API_KEY_ENV_VAR] = original;
});

it('is used when no credential is stored', async () => {
vi.spyOn(secrets, 'readSecrets').mockResolvedValue(null);
process.env[API_KEY_ENV_VAR] = 'hmok_abc123';
const creds = await readCredentials();
expect(creds?.accessToken).toBe('hmok_abc123');
// Shaped as an agent credential so refresh/rescope stay no-ops.
expect(creds?.kind).toBe('agent');
expect(creds?.source).toBe('env');
});

it('outranks a stored credential', async () => {
vi.spyOn(secrets, 'readSecrets').mockResolvedValue({ ...STORED });
process.env[API_KEY_ENV_VAR] = 'hmok_abc123';
expect((await readCredentials())?.accessToken).toBe('hmok_abc123');
});

it('leaves the stored credential in charge when unset', async () => {
vi.spyOn(secrets, 'readSecrets').mockResolvedValue({ ...STORED });
const creds = await readCredentials();
expect(creds?.accessToken).toBe('stored-token');
expect(creds?.source).toBeUndefined();
});

it('accepts legacy ac_ keys, which the backend still resolves', () => {
process.env[API_KEY_ENV_VAR] = 'ac_legacy';
expect(readEnvCredential()?.accessToken).toBe('ac_legacy');
});

it('ignores an empty or whitespace-only value', () => {
process.env[API_KEY_ENV_VAR] = ' ';
expect(readEnvCredential()).toBeNull();
});

// `set HOOKMYAPP_API_KEY=""` on cmd.exe stores the two quote characters.
// Every caller must agree that is "unset", or login refuses while no
// credential exists.
it.each(['""', "''", '" "'])('treats %s as unset everywhere', async (value) => {
process.env[API_KEY_ENV_VAR] = value;
expect(readEnvCredential()).toBeNull();
const { envApiKey } = await import('../../config/env-vars.js');
expect(envApiKey()).toBe('');
});

// cmd.exe keeps the quotes in `set HOOKMYAPP_API_KEY="hmok_abc"`, unlike
// PowerShell and POSIX shells — without stripping, Windows users get
// "not a valid API key" for a key that plainly starts with hmok_.
it('accepts a value quoted the way cmd.exe stores it', () => {
process.env[API_KEY_ENV_VAR] = '"hmok_abc123"';
expect(readEnvCredential()?.accessToken).toBe('hmok_abc123');
process.env[API_KEY_ENV_VAR] = "'hmok_abc123'";
expect(readEnvCredential()?.accessToken).toBe('hmok_abc123');
});

it('leaves an unbalanced quote alone, so it still fails as malformed', () => {
process.env[API_KEY_ENV_VAR] = '"hmok_abc123';
expect(() => readEnvCredential()).toThrow(AuthError);
});

it('gives each env key its own notification-cache fingerprint', async () => {
const { credentialFingerprint } = await import('../../notifications-nudge.js');
const a = credentialFingerprint({
accessToken: 'hmok_keyA', refreshToken: '', expiresAt: 0, kind: 'agent', source: 'env',
});
const b = credentialFingerprint({
accessToken: 'hmok_keyB', refreshToken: '', expiresAt: 0, kind: 'agent', source: 'env',
});
expect(a).not.toBe(b);
expect(a).not.toBe('unknown');
// Derived, never the secret itself.
expect(a).not.toContain('hmok_keyA');
});

it('rejects a malformed value, naming the variable', () => {
process.env[API_KEY_ENV_VAR] = 'not-a-key';
expect(() => readEnvCredential()).toThrow(AuthError);
expect(() => readEnvCredential()).toThrow(/HOOKMYAPP_API_KEY/);
});

it('never echoes the key in the malformed-value error', () => {
process.env[API_KEY_ENV_VAR] = 'not-a-key-supersecret';
try {
readEnvCredential();
throw new Error('expected a throw');
} catch (err) {
expect((err as Error).message).not.toContain('supersecret');
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});
});
89 changes: 89 additions & 0 deletions src/auth/__tests__/logout.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,19 +15,26 @@ vi.mock('../../commands/mcp.js', () => ({ removeClaudeMcp: removeClaudeMcpMock }

let DIR: string;
const SAVED = process.env.HOOKMYAPP_CONFIG_DIR;
// HOOKMYAPP_API_KEY is a supported auth mechanism now, so a CI runner may well
// have one exported. Every baseline expectation here assumes no env
// credential; clear it per test and let the env-specific cases set their own.
const SAVED_KEY = process.env.HOOKMYAPP_API_KEY;
let logSpy: ReturnType<typeof vi.spyOn>;

beforeEach(() => {
removeClaudeMcpMock.mockReset().mockReturnValue({ ok: true });
DIR = mkdtempSync(join(tmpdir(), 'hma-logout-'));
process.env.HOOKMYAPP_CONFIG_DIR = DIR;
delete process.env.HOOKMYAPP_API_KEY;
logSpy = vi.spyOn(console, 'log').mockReturnValue(undefined);
});

afterEach(() => {
rmSync(DIR, { recursive: true, force: true });
if (SAVED) process.env.HOOKMYAPP_CONFIG_DIR = SAVED;
else delete process.env.HOOKMYAPP_CONFIG_DIR;
if (SAVED_KEY === undefined) delete process.env.HOOKMYAPP_API_KEY;
else process.env.HOOKMYAPP_API_KEY = SAVED_KEY;
vi.restoreAllMocks();
});

Expand DownExpand Up@@ -69,12 +76,94 @@ describe('logout', () => {
expect(JSON.parse(written.trim())).toEqual({
status: 'logged_out',
revoked: false,
envKeyActive: false,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Isolate logout tests from the API-key environment

When the test runner has HOOKMYAPP_API_KEY set, as may happen in CI now that this is a supported authentication mechanism, the suite inherits it because beforeEach only isolates the config directory. logout then correctly returns logged_out_with_warning with envKeyActive: true, so this assertion and other baseline logout expectations fail; save, clear, and restore the variable for every test rather than only inside the environment-specific cases.

Useful? React with 👍 / 👎.

mcpCleanup: { ok: true },
});
// The human check line must NOT be printed in --json mode.
expect(logSpy.mock.calls.flat().join('')).not.toMatch(/Logged out/);
});

// AIT-438: an env key keeps authenticating after logout. Automation reads
// the payload, not the stderr warning, so the signal has to be in the JSON.
test('--json flags a still-active HOOKMYAPP_API_KEY', async () => {
process.env.HOOKMYAPP_API_KEY = 'hmok_stillhere';
const credsPath = join(DIR, 'credentials.json');
writeFileSync(credsPath, JSON.stringify({ accessToken: 'a', refreshToken: 'r', expiresAt: 1 }));
const stdoutSpy = vi.spyOn(process.stdout, 'write').mockReturnValue(true);

{
await runLogout(['--json']);
const written = stdoutSpy.mock.calls.map((c) => String(c[0])).join('');
expect(JSON.parse(written.trim())).toMatchObject({
status: 'logged_out_with_warning',
envKeyActive: true,
envKeyVar: 'HOOKMYAPP_API_KEY',
});
}
});

// AIT-438: the revoke goes through apiClient, which authenticates with the
// env key while it is set — a "self-revoke" would kill the credential every
// other process is sharing.
test('skips the server-side revoke while HOOKMYAPP_API_KEY is set', async () => {
process.env.HOOKMYAPP_API_KEY = 'hmok_stillhere';
writeFileSync(
join(DIR, 'credentials.json'),
JSON.stringify({
accessToken: 'hmok_stillhere',
refreshToken: '',
expiresAt: 0,
kind: 'agent',
credentialPublicId: 'ac_self0001',
}),
);
const stdoutSpy = vi.spyOn(process.stdout, 'write').mockReturnValue(true);

{
await runLogout(['--json']);
const payload = JSON.parse(
stdoutSpy.mock.calls.map((c) => String(c[0])).join('').trim(),
);
expect(payload.revoked).toBe(false);
expect(payload.envKeyActive).toBe(true);
expect(payload.envKeyIsStoredKey).toBe(true);
expect(existsSync(join(DIR, 'credentials.json'))).toBe(false);
}
});

// A different env key must not stop logout from revoking the stored one:
// the request is pinned to the stored token, so it is a real self-revoke.
test('still revokes a stored key when the env holds a DIFFERENT key', async () => {
process.env.HOOKMYAPP_API_KEY = 'hmok_otherkey';
writeFileSync(
join(DIR, 'credentials.json'),
JSON.stringify({
accessToken: 'hmok_storedkey',
refreshToken: '',
expiresAt: 0,
kind: 'agent',
credentialPublicId: 'ac_stored01',
}),
);
const stdoutSpy = vi.spyOn(process.stdout, 'write').mockReturnValue(true);
const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 204 }));
vi.stubGlobal('fetch', fetchMock);

{
await runLogout(['--json']);
const payload = JSON.parse(
stdoutSpy.mock.calls.map((c) => String(c[0])).join('').trim(),
);
expect(payload.envKeyIsStoredKey).toBe(false);
const call = fetchMock.mock.calls.find(([url]) =>
String(url).includes('/agent/credentials/ac_stored01'),
);
expect(call).toBeDefined();
// Pinned to the stored key, NOT the env key that would otherwise win.
expect(call![1].headers.Authorization).toBe('Bearer hmok_storedkey');
}
});

test('reports MCP cleanup failure after credentials are removed', async () => {
removeClaudeMcpMock.mockReturnValue({ ok: false, detail: 'Claude MCP cleanup timed out' });
const stdoutSpy = vi.spyOn(process.stdout, 'write').mockReturnValue(true);
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions src/api/__tests__/env-key-bearer.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { apiClient } from '../client.js';
import { API_KEY_ENV_VAR } from '../../config/env-vars.js';
import * as secrets from '../../storage/secrets.js';
import { AuthError, ForbiddenError } from '../../output/error.js';
import { setWorkspaceContext } from '../client.js';

// The env key must reach the wire as the bearer token, exactly as a stored
// agent credential does — no refresh attempt, no rewriting (AIT-438).
describe('apiClient with HOOKMYAPP_API_KEY (AIT-438)', () => {
const original = process.env[API_KEY_ENV_VAR];

beforeEach(() => {
vi.restoreAllMocks();
vi.spyOn(secrets, 'readSecrets').mockResolvedValue(null);
});
afterEach(() => {
if (original === undefined) delete process.env[API_KEY_ENV_VAR];
else process.env[API_KEY_ENV_VAR] = original;
vi.unstubAllGlobals();
});

// A revoked key 401s. "Session expired. Run: login" is wrong twice over —
// there is no session, and login refuses while the variable is set.
it('names the variable when the key is rejected, not "session expired"', async () => {
process.env[API_KEY_ENV_VAR] = 'hmok_revoked';
vi.stubGlobal('fetch', vi.fn(async () => ({
ok: false,
status: 401,
headers: new Headers(),
json: async () => ({ message: 'Unauthorized' }),
})));

await expect(apiClient('/workspaces')).rejects.toThrow(AuthError);
await expect(apiClient('/workspaces')).rejects.toThrow(
/HOOKMYAPP_API_KEY was rejected/,
);
});

// A bare 403 maps to PermissionError, whose stock message says "run:
// hookmyapp login" — which login now refuses to do while the key is set.
it('gives permission guidance, not login advice, on a bare 403', async () => {
process.env[API_KEY_ENV_VAR] = 'hmok_lowperm';
setWorkspaceContext({ workspaceId: 'ws_abc12345' });
vi.stubGlobal('fetch', vi.fn(async () => ({
ok: false,
status: 403,
headers: new Headers(),
json: async () => ({ message: 'Forbidden' }),
})));

await expect(apiClient('/channels')).rejects.toThrow(ForbiddenError);
await expect(apiClient('/channels')).rejects.toThrow(
/HOOKMYAPP_API_KEY lacks permission for workspace ws_abc12345/,
);
await expect(apiClient('/channels')).rejects.not.toThrow(/hookmyapp login/);
setWorkspaceContext({ workspaceId: null });
});

it('sends the env key as the Authorization bearer', async () => {
process.env[API_KEY_ENV_VAR] = 'hmok_livekey123';
const fetchMock = vi.fn(async (_url: string, _init: RequestInit) => ({
ok: true,
status: 200,
headers: new Headers(),
json: async () => [{ id: 'ws_abc12345' }],
}));
vi.stubGlobal('fetch', fetchMock);

const res = await apiClient('/workspaces');

expect(res).toEqual([{ id: 'ws_abc12345' }]);
const headers = fetchMock.mock.calls[0]![1].headers as Record<string, string>;
expect(headers.Authorization).toBe('Bearer hmok_livekey123');
// One call only: an agent credential has no refresh token, so nothing
// should have tried to reach WorkOS first.
expect(fetchMock).toHaveBeenCalledTimes(1);
});
});
38 changes: 34 additions & 4 deletions src/api/client.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -20,6 +20,7 @@ import {
getEffectiveWorkosClientId,
} from '../config/env-profiles.js';
import { buildVersionHeaders } from './version-headers.js';
import { API_KEY_ENV_VAR } from '../config/env-vars.js';

// Module-level workspace context populated by the top-level CLI entry after
// parsing --workspace. Explicit options.workspaceId on a specific apiClient()
Expand DownExpand Up@@ -312,9 +313,14 @@ export function describeFetchError(err: unknown): string {

export async function apiClient(
path: string,
options?: RequestInit & { workspaceId?: string },
// `bearerToken` pins the request to one credential instead of the resolved
// one. Only logout needs it: it must authenticate as the STORED key to
// revoke it, which the env key would otherwise shadow (AIT-438).
options?: RequestInit & { workspaceId?: string; bearerToken?: string },
): Promise<any> {
const creds = await readCredentials();
const creds = options?.bearerToken
? ({ accessToken: options.bearerToken, refreshToken: '', expiresAt: 0, kind: 'agent' } as const)
: await readCredentials();
if (!creds) {
throw new AuthError('Not logged in. Run: hookmyapp login');
}
Expand All@@ -335,7 +341,7 @@ export async function apiClient(

const baseUrl = getEffectiveApiUrl();

const { workspaceId, ...fetchOptions } = options ?? {};
const { workspaceId, bearerToken: _bearerToken, ...fetchOptions } = options ?? {};

const headers: Record<string, string> = {
Authorization: `Bearer ${accessToken}`,
Expand DownExpand Up@@ -383,7 +389,31 @@ export async function apiClient(
}

if (!res.ok) {
throw await mapApiError(res);
const err = await mapApiError(res);
// A 401 on an env credential must not say "Session expired. Run: login":
// there is no session, and `login` refuses to run while the variable is
// set, so that guidance is a loop (AIT-438).
if ('source' in creds && creds.source === 'env') {
if (err instanceof AuthError) {
throw new AuthError(
`The API key in ${API_KEY_ENV_VAR} was rejected (invalid or revoked). Replace it or unset the variable.`,
);
}
// A bare 403 maps to PermissionError, whose message names the persisted
// workspace slug and says "run: hookmyapp login" — wrong on both counts
// here: login refuses while the variable is set, and the workspace in
// play may have come from the environment too.
if (err instanceof PermissionError) {
// resolvedWsId already folds in options.workspaceId, which beats the
// shared context for this specific call.
const ws = resolvedWsId ?? '(unresolved)';
throw new ForbiddenError(
`The API key in ${API_KEY_ENV_VAR} lacks permission for workspace ${ws}. Use a key with the required role, or unset the variable to use your stored login.`,
'AGENT_KEY_FORBIDDEN',
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}
throw err;
}

// 204 No Content (and other empty-body 2xx responses) have no JSON to parse.
Expand Down
112 changes: 112 additions & 0 deletions src/auth/__tests__/env-api-key.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { readCredentials, readEnvCredential } from '../store.js';
import { API_KEY_ENV_VAR } from '../../config/env-vars.js';
import * as secrets from '../../storage/secrets.js';
import { AuthError } from '../../output/error.js';

const STORED = {
accessToken: 'stored-token',
refreshToken: 'r',
expiresAt: 0,
} as const;

describe('HOOKMYAPP_API_KEY credential (AIT-438)', () => {
const original = process.env[API_KEY_ENV_VAR];

beforeEach(() => {
delete process.env[API_KEY_ENV_VAR];
vi.restoreAllMocks();
});
afterEach(() => {
if (original === undefined) delete process.env[API_KEY_ENV_VAR];
else process.env[API_KEY_ENV_VAR] = original;
});

it('is used when no credential is stored', async () => {
vi.spyOn(secrets, 'readSecrets').mockResolvedValue(null);
process.env[API_KEY_ENV_VAR] = 'hmok_abc123';
const creds = await readCredentials();
expect(creds?.accessToken).toBe('hmok_abc123');
// Shaped as an agent credential so refresh/rescope stay no-ops.
expect(creds?.kind).toBe('agent');
expect(creds?.source).toBe('env');
});

it('outranks a stored credential', async () => {
vi.spyOn(secrets, 'readSecrets').mockResolvedValue({ ...STORED });
process.env[API_KEY_ENV_VAR] = 'hmok_abc123';
expect((await readCredentials())?.accessToken).toBe('hmok_abc123');
});

it('leaves the stored credential in charge when unset', async () => {
vi.spyOn(secrets, 'readSecrets').mockResolvedValue({ ...STORED });
const creds = await readCredentials();
expect(creds?.accessToken).toBe('stored-token');
expect(creds?.source).toBeUndefined();
});

it('accepts legacy ac_ keys, which the backend still resolves', () => {
process.env[API_KEY_ENV_VAR] = 'ac_legacy';
expect(readEnvCredential()?.accessToken).toBe('ac_legacy');
});

it('ignores an empty or whitespace-only value', () => {
process.env[API_KEY_ENV_VAR] = ' ';
expect(readEnvCredential()).toBeNull();
});

// `set HOOKMYAPP_API_KEY=""` on cmd.exe stores the two quote characters.
// Every caller must agree that is "unset", or login refuses while no
// credential exists.
it.each(['""', "''", '" "'])('treats %s as unset everywhere', async (value) => {
process.env[API_KEY_ENV_VAR] = value;
expect(readEnvCredential()).toBeNull();
const { envApiKey } = await import('../../config/env-vars.js');
expect(envApiKey()).toBe('');
});

// cmd.exe keeps the quotes in `set HOOKMYAPP_API_KEY="hmok_abc"`, unlike
// PowerShell and POSIX shells — without stripping, Windows users get
// "not a valid API key" for a key that plainly starts with hmok_.
it('accepts a value quoted the way cmd.exe stores it', () => {
process.env[API_KEY_ENV_VAR] = '"hmok_abc123"';
expect(readEnvCredential()?.accessToken).toBe('hmok_abc123');
process.env[API_KEY_ENV_VAR] = "'hmok_abc123'";
expect(readEnvCredential()?.accessToken).toBe('hmok_abc123');
});

it('leaves an unbalanced quote alone, so it still fails as malformed', () => {
process.env[API_KEY_ENV_VAR] = '"hmok_abc123';
expect(() => readEnvCredential()).toThrow(AuthError);
});

it('gives each env key its own notification-cache fingerprint', async () => {
const { credentialFingerprint } = await import('../../notifications-nudge.js');
const a = credentialFingerprint({
accessToken: 'hmok_keyA', refreshToken: '', expiresAt: 0, kind: 'agent', source: 'env',
});
const b = credentialFingerprint({
accessToken: 'hmok_keyB', refreshToken: '', expiresAt: 0, kind: 'agent', source: 'env',
});
expect(a).not.toBe(b);
expect(a).not.toBe('unknown');
// Derived, never the secret itself.
expect(a).not.toContain('hmok_keyA');
});

it('rejects a malformed value, naming the variable', () => {
process.env[API_KEY_ENV_VAR] = 'not-a-key';
expect(() => readEnvCredential()).toThrow(AuthError);
expect(() => readEnvCredential()).toThrow(/HOOKMYAPP_API_KEY/);
});

it('never echoes the key in the malformed-value error', () => {
process.env[API_KEY_ENV_VAR] = 'not-a-key-supersecret';
try {
readEnvCredential();
throw new Error('expected a throw');
} catch (err) {
expect((err as Error).message).not.toContain('supersecret');
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});
});
89 changes: 89 additions & 0 deletions src/auth/__tests__/logout.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,19 +15,26 @@ vi.mock('../../commands/mcp.js', () => ({ removeClaudeMcp: removeClaudeMcpMock }

let DIR: string;
const SAVED = process.env.HOOKMYAPP_CONFIG_DIR;
// HOOKMYAPP_API_KEY is a supported auth mechanism now, so a CI runner may well
// have one exported. Every baseline expectation here assumes no env
// credential; clear it per test and let the env-specific cases set their own.
const SAVED_KEY = process.env.HOOKMYAPP_API_KEY;
let logSpy: ReturnType<typeof vi.spyOn>;

beforeEach(() => {
removeClaudeMcpMock.mockReset().mockReturnValue({ ok: true });
DIR = mkdtempSync(join(tmpdir(), 'hma-logout-'));
process.env.HOOKMYAPP_CONFIG_DIR = DIR;
delete process.env.HOOKMYAPP_API_KEY;
logSpy = vi.spyOn(console, 'log').mockReturnValue(undefined);
});

afterEach(() => {
rmSync(DIR, { recursive: true, force: true });
if (SAVED) process.env.HOOKMYAPP_CONFIG_DIR = SAVED;
else delete process.env.HOOKMYAPP_CONFIG_DIR;
if (SAVED_KEY === undefined) delete process.env.HOOKMYAPP_API_KEY;
else process.env.HOOKMYAPP_API_KEY = SAVED_KEY;
vi.restoreAllMocks();
});

Expand DownExpand Up@@ -69,12 +76,94 @@ describe('logout', () => {
expect(JSON.parse(written.trim())).toEqual({
status: 'logged_out',
revoked: false,
envKeyActive: false,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Isolate logout tests from the API-key environment

When the test runner has HOOKMYAPP_API_KEY set, as may happen in CI now that this is a supported authentication mechanism, the suite inherits it because beforeEach only isolates the config directory. logout then correctly returns logged_out_with_warning with envKeyActive: true, so this assertion and other baseline logout expectations fail; save, clear, and restore the variable for every test rather than only inside the environment-specific cases.

Useful? React with 👍 / 👎.

mcpCleanup: { ok: true },
});
// The human check line must NOT be printed in --json mode.
expect(logSpy.mock.calls.flat().join('')).not.toMatch(/Logged out/);
});

// AIT-438: an env key keeps authenticating after logout. Automation reads
// the payload, not the stderr warning, so the signal has to be in the JSON.
test('--json flags a still-active HOOKMYAPP_API_KEY', async () => {
process.env.HOOKMYAPP_API_KEY = 'hmok_stillhere';
const credsPath = join(DIR, 'credentials.json');
writeFileSync(credsPath, JSON.stringify({ accessToken: 'a', refreshToken: 'r', expiresAt: 1 }));
const stdoutSpy = vi.spyOn(process.stdout, 'write').mockReturnValue(true);

{
await runLogout(['--json']);
const written = stdoutSpy.mock.calls.map((c) => String(c[0])).join('');
expect(JSON.parse(written.trim())).toMatchObject({
status: 'logged_out_with_warning',
envKeyActive: true,
envKeyVar: 'HOOKMYAPP_API_KEY',
});
}
});

// AIT-438: the revoke goes through apiClient, which authenticates with the
// env key while it is set — a "self-revoke" would kill the credential every
// other process is sharing.
test('skips the server-side revoke while HOOKMYAPP_API_KEY is set', async () => {
process.env.HOOKMYAPP_API_KEY = 'hmok_stillhere';
writeFileSync(
join(DIR, 'credentials.json'),
JSON.stringify({
accessToken: 'hmok_stillhere',
refreshToken: '',
expiresAt: 0,
kind: 'agent',
credentialPublicId: 'ac_self0001',
}),
);
const stdoutSpy = vi.spyOn(process.stdout, 'write').mockReturnValue(true);

{
await runLogout(['--json']);
const payload = JSON.parse(
stdoutSpy.mock.calls.map((c) => String(c[0])).join('').trim(),
);
expect(payload.revoked).toBe(false);
expect(payload.envKeyActive).toBe(true);
expect(payload.envKeyIsStoredKey).toBe(true);
expect(existsSync(join(DIR, 'credentials.json'))).toBe(false);
}
});

// A different env key must not stop logout from revoking the stored one:
// the request is pinned to the stored token, so it is a real self-revoke.
test('still revokes a stored key when the env holds a DIFFERENT key', async () => {
process.env.HOOKMYAPP_API_KEY = 'hmok_otherkey';
writeFileSync(
join(DIR, 'credentials.json'),
JSON.stringify({
accessToken: 'hmok_storedkey',
refreshToken: '',
expiresAt: 0,
kind: 'agent',
credentialPublicId: 'ac_stored01',
}),
);
const stdoutSpy = vi.spyOn(process.stdout, 'write').mockReturnValue(true);
const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 204 }));
vi.stubGlobal('fetch', fetchMock);

{
await runLogout(['--json']);
const payload = JSON.parse(
stdoutSpy.mock.calls.map((c) => String(c[0])).join('').trim(),
);
expect(payload.envKeyIsStoredKey).toBe(false);
const call = fetchMock.mock.calls.find(([url]) =>
String(url).includes('/agent/credentials/ac_stored01'),
);
expect(call).toBeDefined();
// Pinned to the stored key, NOT the env key that would otherwise win.
expect(call![1].headers.Authorization).toBe('Bearer hmok_storedkey');
}
});

test('reports MCP cleanup failure after credentials are removed', async () => {
removeClaudeMcpMock.mockReturnValue({ ok: false, detail: 'Claude MCP cleanup timed out' });
const stdoutSpy = vi.spyOn(process.stdout, 'write').mockReturnValue(true);
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions src/api/__tests__/env-key-bearer.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { apiClient } from '../client.js';
import { API_KEY_ENV_VAR } from '../../config/env-vars.js';
import * as secrets from '../../storage/secrets.js';
import { AuthError, ForbiddenError } from '../../output/error.js';
import { setWorkspaceContext } from '../client.js';

// The env key must reach the wire as the bearer token, exactly as a stored
// agent credential does — no refresh attempt, no rewriting (AIT-438).
describe('apiClient with HOOKMYAPP_API_KEY (AIT-438)', () => {
const original = process.env[API_KEY_ENV_VAR];

beforeEach(() => {
vi.restoreAllMocks();
vi.spyOn(secrets, 'readSecrets').mockResolvedValue(null);
});
afterEach(() => {
if (original === undefined) delete process.env[API_KEY_ENV_VAR];
else process.env[API_KEY_ENV_VAR] = original;
vi.unstubAllGlobals();
});

// A revoked key 401s. "Session expired. Run: login" is wrong twice over —
// there is no session, and login refuses while the variable is set.
it('names the variable when the key is rejected, not "session expired"', async () => {
process.env[API_KEY_ENV_VAR] = 'hmok_revoked';
vi.stubGlobal('fetch', vi.fn(async () => ({
ok: false,
status: 401,
headers: new Headers(),
json: async () => ({ message: 'Unauthorized' }),
})));

await expect(apiClient('/workspaces')).rejects.toThrow(AuthError);
await expect(apiClient('/workspaces')).rejects.toThrow(
/HOOKMYAPP_API_KEY was rejected/,
);
});

// A bare 403 maps to PermissionError, whose stock message says "run:
// hookmyapp login" — which login now refuses to do while the key is set.
it('gives permission guidance, not login advice, on a bare 403', async () => {
process.env[API_KEY_ENV_VAR] = 'hmok_lowperm';
setWorkspaceContext({ workspaceId: 'ws_abc12345' });
vi.stubGlobal('fetch', vi.fn(async () => ({
ok: false,
status: 403,
headers: new Headers(),
json: async () => ({ message: 'Forbidden' }),
})));

await expect(apiClient('/channels')).rejects.toThrow(ForbiddenError);
await expect(apiClient('/channels')).rejects.toThrow(
/HOOKMYAPP_API_KEY lacks permission for workspace ws_abc12345/,
);
await expect(apiClient('/channels')).rejects.not.toThrow(/hookmyapp login/);
setWorkspaceContext({ workspaceId: null });
});

it('sends the env key as the Authorization bearer', async () => {
process.env[API_KEY_ENV_VAR] = 'hmok_livekey123';
const fetchMock = vi.fn(async (_url: string, _init: RequestInit) => ({
ok: true,
status: 200,
headers: new Headers(),
json: async () => [{ id: 'ws_abc12345' }],
}));
vi.stubGlobal('fetch', fetchMock);

const res = await apiClient('/workspaces');

expect(res).toEqual([{ id: 'ws_abc12345' }]);
const headers = fetchMock.mock.calls[0]![1].headers as Record<string, string>;
expect(headers.Authorization).toBe('Bearer hmok_livekey123');
// One call only: an agent credential has no refresh token, so nothing
// should have tried to reach WorkOS first.
expect(fetchMock).toHaveBeenCalledTimes(1);
});
});
38 changes: 34 additions & 4 deletions src/api/client.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -20,6 +20,7 @@ import {
getEffectiveWorkosClientId,
} from '../config/env-profiles.js';
import { buildVersionHeaders } from './version-headers.js';
import { API_KEY_ENV_VAR } from '../config/env-vars.js';

// Module-level workspace context populated by the top-level CLI entry after
// parsing --workspace. Explicit options.workspaceId on a specific apiClient()
Expand DownExpand Up@@ -312,9 +313,14 @@ export function describeFetchError(err: unknown): string {

export async function apiClient(
path: string,
options?: RequestInit & { workspaceId?: string },
// `bearerToken` pins the request to one credential instead of the resolved
// one. Only logout needs it: it must authenticate as the STORED key to
// revoke it, which the env key would otherwise shadow (AIT-438).
options?: RequestInit & { workspaceId?: string; bearerToken?: string },
): Promise<any> {
const creds = await readCredentials();
const creds = options?.bearerToken
? ({ accessToken: options.bearerToken, refreshToken: '', expiresAt: 0, kind: 'agent' } as const)
: await readCredentials();
if (!creds) {
throw new AuthError('Not logged in. Run: hookmyapp login');
}
Expand All@@ -335,7 +341,7 @@ export async function apiClient(

const baseUrl = getEffectiveApiUrl();

const { workspaceId, ...fetchOptions } = options ?? {};
const { workspaceId, bearerToken: _bearerToken, ...fetchOptions } = options ?? {};

const headers: Record<string, string> = {
Authorization: `Bearer ${accessToken}`,
Expand DownExpand Up@@ -383,7 +389,31 @@ export async function apiClient(
}

if (!res.ok) {
throw await mapApiError(res);
const err = await mapApiError(res);
// A 401 on an env credential must not say "Session expired. Run: login":
// there is no session, and `login` refuses to run while the variable is
// set, so that guidance is a loop (AIT-438).
if ('source' in creds && creds.source === 'env') {
if (err instanceof AuthError) {
throw new AuthError(
`The API key in ${API_KEY_ENV_VAR} was rejected (invalid or revoked). Replace it or unset the variable.`,
);
}
// A bare 403 maps to PermissionError, whose message names the persisted
// workspace slug and says "run: hookmyapp login" — wrong on both counts
// here: login refuses while the variable is set, and the workspace in
// play may have come from the environment too.
if (err instanceof PermissionError) {
// resolvedWsId already folds in options.workspaceId, which beats the
// shared context for this specific call.
const ws = resolvedWsId ?? '(unresolved)';
throw new ForbiddenError(
`The API key in ${API_KEY_ENV_VAR} lacks permission for workspace ${ws}. Use a key with the required role, or unset the variable to use your stored login.`,
'AGENT_KEY_FORBIDDEN',
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}
throw err;
}

// 204 No Content (and other empty-body 2xx responses) have no JSON to parse.
Expand Down
112 changes: 112 additions & 0 deletions src/auth/__tests__/env-api-key.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { readCredentials, readEnvCredential } from '../store.js';
import { API_KEY_ENV_VAR } from '../../config/env-vars.js';
import * as secrets from '../../storage/secrets.js';
import { AuthError } from '../../output/error.js';

const STORED = {
accessToken: 'stored-token',
refreshToken: 'r',
expiresAt: 0,
} as const;

describe('HOOKMYAPP_API_KEY credential (AIT-438)', () => {
const original = process.env[API_KEY_ENV_VAR];

beforeEach(() => {
delete process.env[API_KEY_ENV_VAR];
vi.restoreAllMocks();
});
afterEach(() => {
if (original === undefined) delete process.env[API_KEY_ENV_VAR];
else process.env[API_KEY_ENV_VAR] = original;
});

it('is used when no credential is stored', async () => {
vi.spyOn(secrets, 'readSecrets').mockResolvedValue(null);
process.env[API_KEY_ENV_VAR] = 'hmok_abc123';
const creds = await readCredentials();
expect(creds?.accessToken).toBe('hmok_abc123');
// Shaped as an agent credential so refresh/rescope stay no-ops.
expect(creds?.kind).toBe('agent');
expect(creds?.source).toBe('env');
});

it('outranks a stored credential', async () => {
vi.spyOn(secrets, 'readSecrets').mockResolvedValue({ ...STORED });
process.env[API_KEY_ENV_VAR] = 'hmok_abc123';
expect((await readCredentials())?.accessToken).toBe('hmok_abc123');
});

it('leaves the stored credential in charge when unset', async () => {
vi.spyOn(secrets, 'readSecrets').mockResolvedValue({ ...STORED });
const creds = await readCredentials();
expect(creds?.accessToken).toBe('stored-token');
expect(creds?.source).toBeUndefined();
});

it('accepts legacy ac_ keys, which the backend still resolves', () => {
process.env[API_KEY_ENV_VAR] = 'ac_legacy';
expect(readEnvCredential()?.accessToken).toBe('ac_legacy');
});

it('ignores an empty or whitespace-only value', () => {
process.env[API_KEY_ENV_VAR] = ' ';
expect(readEnvCredential()).toBeNull();
});

// `set HOOKMYAPP_API_KEY=""` on cmd.exe stores the two quote characters.
// Every caller must agree that is "unset", or login refuses while no
// credential exists.
it.each(['""', "''", '" "'])('treats %s as unset everywhere', async (value) => {
process.env[API_KEY_ENV_VAR] = value;
expect(readEnvCredential()).toBeNull();
const { envApiKey } = await import('../../config/env-vars.js');
expect(envApiKey()).toBe('');
});

// cmd.exe keeps the quotes in `set HOOKMYAPP_API_KEY="hmok_abc"`, unlike
// PowerShell and POSIX shells — without stripping, Windows users get
// "not a valid API key" for a key that plainly starts with hmok_.
it('accepts a value quoted the way cmd.exe stores it', () => {
process.env[API_KEY_ENV_VAR] = '"hmok_abc123"';
expect(readEnvCredential()?.accessToken).toBe('hmok_abc123');
process.env[API_KEY_ENV_VAR] = "'hmok_abc123'";
expect(readEnvCredential()?.accessToken).toBe('hmok_abc123');
});

it('leaves an unbalanced quote alone, so it still fails as malformed', () => {
process.env[API_KEY_ENV_VAR] = '"hmok_abc123';
expect(() => readEnvCredential()).toThrow(AuthError);
});

it('gives each env key its own notification-cache fingerprint', async () => {
const { credentialFingerprint } = await import('../../notifications-nudge.js');
const a = credentialFingerprint({
accessToken: 'hmok_keyA', refreshToken: '', expiresAt: 0, kind: 'agent', source: 'env',
});
const b = credentialFingerprint({
accessToken: 'hmok_keyB', refreshToken: '', expiresAt: 0, kind: 'agent', source: 'env',
});
expect(a).not.toBe(b);
expect(a).not.toBe('unknown');
// Derived, never the secret itself.
expect(a).not.toContain('hmok_keyA');
});

it('rejects a malformed value, naming the variable', () => {
process.env[API_KEY_ENV_VAR] = 'not-a-key';
expect(() => readEnvCredential()).toThrow(AuthError);
expect(() => readEnvCredential()).toThrow(/HOOKMYAPP_API_KEY/);
});

it('never echoes the key in the malformed-value error', () => {
process.env[API_KEY_ENV_VAR] = 'not-a-key-supersecret';
try {
readEnvCredential();
throw new Error('expected a throw');
} catch (err) {
expect((err as Error).message).not.toContain('supersecret');
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});
});
89 changes: 89 additions & 0 deletions src/auth/__tests__/logout.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,19 +15,26 @@ vi.mock('../../commands/mcp.js', () => ({ removeClaudeMcp: removeClaudeMcpMock }

let DIR: string;
const SAVED = process.env.HOOKMYAPP_CONFIG_DIR;
// HOOKMYAPP_API_KEY is a supported auth mechanism now, so a CI runner may well
// have one exported. Every baseline expectation here assumes no env
// credential; clear it per test and let the env-specific cases set their own.
const SAVED_KEY = process.env.HOOKMYAPP_API_KEY;
let logSpy: ReturnType<typeof vi.spyOn>;

beforeEach(() => {
removeClaudeMcpMock.mockReset().mockReturnValue({ ok: true });
DIR = mkdtempSync(join(tmpdir(), 'hma-logout-'));
process.env.HOOKMYAPP_CONFIG_DIR = DIR;
delete process.env.HOOKMYAPP_API_KEY;
logSpy = vi.spyOn(console, 'log').mockReturnValue(undefined);
});

afterEach(() => {
rmSync(DIR, { recursive: true, force: true });
if (SAVED) process.env.HOOKMYAPP_CONFIG_DIR = SAVED;
else delete process.env.HOOKMYAPP_CONFIG_DIR;
if (SAVED_KEY === undefined) delete process.env.HOOKMYAPP_API_KEY;
else process.env.HOOKMYAPP_API_KEY = SAVED_KEY;
vi.restoreAllMocks();
});

Expand DownExpand Up@@ -69,12 +76,94 @@ describe('logout', () => {
expect(JSON.parse(written.trim())).toEqual({
status: 'logged_out',
revoked: false,
envKeyActive: false,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Isolate logout tests from the API-key environment

When the test runner has HOOKMYAPP_API_KEY set, as may happen in CI now that this is a supported authentication mechanism, the suite inherits it because beforeEach only isolates the config directory. logout then correctly returns logged_out_with_warning with envKeyActive: true, so this assertion and other baseline logout expectations fail; save, clear, and restore the variable for every test rather than only inside the environment-specific cases.

Useful? React with 👍 / 👎.

mcpCleanup: { ok: true },
});
// The human check line must NOT be printed in --json mode.
expect(logSpy.mock.calls.flat().join('')).not.toMatch(/Logged out/);
});

// AIT-438: an env key keeps authenticating after logout. Automation reads
// the payload, not the stderr warning, so the signal has to be in the JSON.
test('--json flags a still-active HOOKMYAPP_API_KEY', async () => {
process.env.HOOKMYAPP_API_KEY = 'hmok_stillhere';
const credsPath = join(DIR, 'credentials.json');
writeFileSync(credsPath, JSON.stringify({ accessToken: 'a', refreshToken: 'r', expiresAt: 1 }));
const stdoutSpy = vi.spyOn(process.stdout, 'write').mockReturnValue(true);

{
await runLogout(['--json']);
const written = stdoutSpy.mock.calls.map((c) => String(c[0])).join('');
expect(JSON.parse(written.trim())).toMatchObject({
status: 'logged_out_with_warning',
envKeyActive: true,
envKeyVar: 'HOOKMYAPP_API_KEY',
});
}
});

// AIT-438: the revoke goes through apiClient, which authenticates with the
// env key while it is set — a "self-revoke" would kill the credential every
// other process is sharing.
test('skips the server-side revoke while HOOKMYAPP_API_KEY is set', async () => {
process.env.HOOKMYAPP_API_KEY = 'hmok_stillhere';
writeFileSync(
join(DIR, 'credentials.json'),
JSON.stringify({
accessToken: 'hmok_stillhere',
refreshToken: '',
expiresAt: 0,
kind: 'agent',
credentialPublicId: 'ac_self0001',
}),
);
const stdoutSpy = vi.spyOn(process.stdout, 'write').mockReturnValue(true);

{
await runLogout(['--json']);
const payload = JSON.parse(
stdoutSpy.mock.calls.map((c) => String(c[0])).join('').trim(),
);
expect(payload.revoked).toBe(false);
expect(payload.envKeyActive).toBe(true);
expect(payload.envKeyIsStoredKey).toBe(true);
expect(existsSync(join(DIR, 'credentials.json'))).toBe(false);
}
});

// A different env key must not stop logout from revoking the stored one:
// the request is pinned to the stored token, so it is a real self-revoke.
test('still revokes a stored key when the env holds a DIFFERENT key', async () => {
process.env.HOOKMYAPP_API_KEY = 'hmok_otherkey';
writeFileSync(
join(DIR, 'credentials.json'),
JSON.stringify({
accessToken: 'hmok_storedkey',
refreshToken: '',
expiresAt: 0,
kind: 'agent',
credentialPublicId: 'ac_stored01',
}),
);
const stdoutSpy = vi.spyOn(process.stdout, 'write').mockReturnValue(true);
const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 204 }));
vi.stubGlobal('fetch', fetchMock);

{
await runLogout(['--json']);
const payload = JSON.parse(
stdoutSpy.mock.calls.map((c) => String(c[0])).join('').trim(),
);
expect(payload.envKeyIsStoredKey).toBe(false);
const call = fetchMock.mock.calls.find(([url]) =>
String(url).includes('/agent/credentials/ac_stored01'),
);
expect(call).toBeDefined();
// Pinned to the stored key, NOT the env key that would otherwise win.
expect(call![1].headers.Authorization).toBe('Bearer hmok_storedkey');
}
});

test('reports MCP cleanup failure after credentials are removed', async () => {
removeClaudeMcpMock.mockReturnValue({ ok: false, detail: 'Claude MCP cleanup timed out' });
const stdoutSpy = vi.spyOn(process.stdout, 'write').mockReturnValue(true);
Expand Down
Loading
Loading