From 908e317b8b5dd642b3e79ae31e76f779b870de27 Mon Sep 17 00:00:00 2001 From: Benjamin Liu Date: Wed, 3 Jun 2026 16:06:35 +0900 Subject: [PATCH 1/2] feat(browser): expose cookies command for HttpOnly-aware reads Wire the existing IPage.getCookies() path to a user-facing CLI verb so agents can read HttpOnly cookies that document.cookie cannot reach. Refs #1472 --- src/cli.test.ts | 54 +++++++++++++++++++++++++++++++++++++++++++++++++ src/cli.ts | 19 +++++++++++++++++ 2 files changed, 73 insertions(+) diff --git a/src/cli.test.ts b/src/cli.test.ts index 1e3fd0b1a..850b213fd 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -2228,6 +2228,60 @@ describe('browser console command', () => { }); }); +describe('browser cookies command', () => { + const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + beforeEach(() => { + process.exitCode = undefined; + consoleLogSpy.mockClear(); + mockBrowserConnect.mockClear(); + mockBrowserClose.mockReset().mockResolvedValue(undefined); + browserState.page = { + session: 'test', + setActivePage: vi.fn(), + getActivePage: vi.fn().mockReturnValue('tab-1'), + tabs: vi.fn().mockResolvedValue([{ page: 'tab-1', active: true }]), + getCookies: vi.fn().mockResolvedValue([ + { name: 'session', value: 'abc', domain: 'example.com', path: '/', secure: true, httpOnly: true }, + { name: 'theme', value: 'dark', domain: 'example.com', path: '/', secure: false, httpOnly: false }, + ]), + } as unknown as IPage; + }); + + function lastJsonLog(): any { + const calls = consoleLogSpy.mock.calls; + if (calls.length === 0) throw new Error('Expected at least one console.log call'); + const last = calls[calls.length - 1][0]; + if (typeof last !== 'string') throw new Error(`Expected string arg to console.log, got ${typeof last}`); + return JSON.parse(last); + } + + it('returns cookies including HttpOnly entries for a scoped domain read', async () => { + const program = createProgram('', ''); + + await program.parseAsync(['node', 'opencli', 'browser', '--session', 'test', 'cookies', '--domain', 'example.com']); + + expect(browserState.page!.getCookies).toHaveBeenCalledWith({ domain: 'example.com', url: undefined }); + const out = lastJsonLog(); + expect(out.count).toBe(2); + expect(out.cookies).toEqual(expect.arrayContaining([ + expect.objectContaining({ name: 'session', httpOnly: true }), + expect.objectContaining({ name: 'theme', httpOnly: false }), + ])); + }); + + it('errors with missing_scope when neither --domain nor --url is provided', async () => { + const program = createProgram('', ''); + + await program.parseAsync(['node', 'opencli', 'browser', '--session', 'test', 'cookies']); + + expect(browserState.page!.getCookies).not.toHaveBeenCalled(); + expect(process.exitCode).toBeDefined(); + const out = lastJsonLog(); + expect(out.error.code).toBe('missing_scope'); + }); +}); + describe('browser get html command', () => { const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); diff --git a/src/cli.ts b/src/cli.ts index 2ddb8fd7b..6ec0fe431 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1271,6 +1271,25 @@ Examples: }, null, 2)); })); + addBrowserTabOption(browser.command('cookies')) + .option('--domain ', 'Cookie domain scope (e.g. example.com)') + .option('--url ', 'URL scope (use domain or url, not both)') + .description('Read scoped cookies (includes HttpOnly)') + .action(browserAction(async (page, opts) => { + if (!opts.domain && !opts.url) { + console.log(JSON.stringify({ error: { code: 'missing_scope', message: 'Provide --domain or --url to scope the cookie read' } }, null, 2)); + process.exitCode = EXIT_CODES.USAGE_ERROR; + return; + } + const cookies = await page.getCookies({ domain: opts.domain, url: opts.url }); + console.log(JSON.stringify({ + session: getPageSession(page), + captured_at: new Date().toISOString(), + count: cookies.length, + cookies, + }, null, 2)); + })); + // ── Analyze (site recon, agent-native) ── // // Mechanizes the `site-recon.md` decision tree into one CLI call. The agent From e7ea268c398ec28f678e13aca75fb39039080f24 Mon Sep 17 00:00:00 2001 From: Benjamin Liu Date: Wed, 3 Jun 2026 16:19:09 +0900 Subject: [PATCH 2/2] test(browser): cover cookies --url path and empty-scope envelope Only forward the truthy scope option so getCookies receives the same shape internal callers already use. --- src/cli.test.ts | 27 +++++++++++++++++++++++++-- src/cli.ts | 7 +++++-- 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/src/cli.test.ts b/src/cli.test.ts index 850b213fd..3692464a1 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -2261,15 +2261,38 @@ describe('browser cookies command', () => { await program.parseAsync(['node', 'opencli', 'browser', '--session', 'test', 'cookies', '--domain', 'example.com']); - expect(browserState.page!.getCookies).toHaveBeenCalledWith({ domain: 'example.com', url: undefined }); + expect(browserState.page!.getCookies).toHaveBeenCalledWith({ domain: 'example.com' }); const out = lastJsonLog(); - expect(out.count).toBe(2); + expect(out).toMatchObject({ + session: 'test', + count: 2, + captured_at: expect.any(String), + }); expect(out.cookies).toEqual(expect.arrayContaining([ expect.objectContaining({ name: 'session', httpOnly: true }), expect.objectContaining({ name: 'theme', httpOnly: false }), ])); }); + it('passes --url through to getCookies without sending an unset --domain', async () => { + const program = createProgram('', ''); + + await program.parseAsync(['node', 'opencli', 'browser', '--session', 'test', 'cookies', '--url', 'https://example.com/login']); + + expect(browserState.page!.getCookies).toHaveBeenCalledWith({ url: 'https://example.com/login' }); + }); + + it('emits an empty cookies envelope with count 0 when the scope matches nothing', async () => { + (browserState.page!.getCookies as ReturnType).mockResolvedValueOnce([]); + const program = createProgram('', ''); + + await program.parseAsync(['node', 'opencli', 'browser', '--session', 'test', 'cookies', '--domain', 'nothing.example']); + + const out = lastJsonLog(); + expect(out.count).toBe(0); + expect(out.cookies).toEqual([]); + }); + it('errors with missing_scope when neither --domain nor --url is provided', async () => { const program = createProgram('', ''); diff --git a/src/cli.ts b/src/cli.ts index 6ec0fe431..15c252647 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1273,7 +1273,7 @@ Examples: addBrowserTabOption(browser.command('cookies')) .option('--domain ', 'Cookie domain scope (e.g. example.com)') - .option('--url ', 'URL scope (use domain or url, not both)') + .option('--url ', 'URL scope (may be combined with --domain to narrow)') .description('Read scoped cookies (includes HttpOnly)') .action(browserAction(async (page, opts) => { if (!opts.domain && !opts.url) { @@ -1281,7 +1281,10 @@ Examples: process.exitCode = EXIT_CODES.USAGE_ERROR; return; } - const cookies = await page.getCookies({ domain: opts.domain, url: opts.url }); + const cookies = await page.getCookies({ + ...(opts.domain ? { domain: opts.domain } : {}), + ...(opts.url ? { url: opts.url } : {}), + }); console.log(JSON.stringify({ session: getPageSession(page), captured_at: new Date().toISOString(),