From d89f41e3276e6c4889bedef614c7a8e6fe6c0da8 Mon Sep 17 00:00:00 2001 From: beubax Date: Wed, 12 Aug 2026 01:03:27 +0530 Subject: [PATCH 01/27] feat: make session a root cli selector --- src/cli-argv-preprocess.test.ts | 260 ++---------------------- src/cli-argv-preprocess.ts | 90 +++----- src/cli.test.ts | 77 ++++--- src/cli.ts | 50 +---- src/commanderAdapter.ts | 1 + src/execution.ts | 1 + src/hosted/browser-args.test.ts | 53 +++-- src/hosted/browser-args.ts | 47 ++--- src/hosted/client.test.ts | 22 ++ src/hosted/client.ts | 3 + src/hosted/root-command-surface.test.ts | 12 ++ src/hosted/runner.test.ts | 55 +++-- src/hosted/runner.ts | 43 ++-- src/main.ts | 10 +- src/root-command-surface.ts | 12 +- 15 files changed, 260 insertions(+), 476 deletions(-) diff --git a/src/cli-argv-preprocess.test.ts b/src/cli-argv-preprocess.test.ts index aa1dbb32..84b971df 100644 --- a/src/cli-argv-preprocess.test.ts +++ b/src/cli-argv-preprocess.test.ts @@ -1,256 +1,28 @@ import { describe, expect, it } from 'vitest'; -import { getBrowserSubcommandNames, rewriteBrowserArgv } from './cli-argv-preprocess.js'; +import { rejectPositionalBrowserSessionArgv } from './cli-argv-preprocess.js'; -describe('rewriteBrowserArgv', () => { - it('rewrites `browser ` into `browser --session `', () => { - expect(rewriteBrowserArgv(['browser', 'work', 'state'])).toEqual([ - 'browser', - '--session', - 'work', - 'state', - ]); - }); - - it('rewrites with subcommand arguments preserved', () => { - expect(rewriteBrowserArgv(['browser', 'mercury', 'open', 'https://x.com'])).toEqual([ - 'browser', - '--session', - 'mercury', - 'open', - 'https://x.com', - ]); - }); - - it('rewrites `browser bind`', () => { - expect(rewriteBrowserArgv(['browser', 'mercury', 'bind'])).toEqual([ - 'browser', - '--session', - 'mercury', - 'bind', - ]); - }); - - it('rewrites `browser run` with source options preserved', () => { - expect(rewriteBrowserArgv(['browser', 'mercury', 'run', '--file', 'task.js'])).toEqual([ - 'browser', - '--session', - 'mercury', - 'run', - '--file', - 'task.js', - ]); - }); - - it('leaves argv alone when session omitted and a subcommand follows', () => { - // Commander surfaces the required-flag error itself. - expect(rewriteBrowserArgv(['browser', 'state'])).toEqual(['browser', 'state']); - expect(rewriteBrowserArgv(['browser', 'bind'])).toEqual(['browser', 'bind']); - }); - - it('leaves argv alone when the token after `browser` is a flag', () => { - expect(rewriteBrowserArgv(['browser', '--help'])).toEqual(['browser', '--help']); - expect(rewriteBrowserArgv(['browser', '-h'])).toEqual(['browser', '-h']); +describe('rejectPositionalBrowserSessionArgv', () => { + it('rejects retired positional browser sessions with the canonical replacement', () => { + expect(() => rejectPositionalBrowserSessionArgv(['browser', 'session_a', 'run', '--stdin'])) + .toThrowError(/webcmd --session session_a browser run --stdin/); }); - it('refuses the retired `webcmd browser --session foo ...` user form', () => { - // The flag form is no longer a public entrance. Tests calling - // program.parseAsync directly bypass the preprocessor, so internal - // callers still work; but the user-facing pipeline throws. - expect(() => rewriteBrowserArgv(['browser', '--session', 'foo', 'state'])) - .toThrowError(/no longer a public option/i); - expect(() => rewriteBrowserArgv(['browser', '--session=foo', 'state'])) - .toThrowError(/no longer a public option/i); - }); - - it('leaves argv alone when `browser` is not present', () => { - expect(rewriteBrowserArgv(['twitter', 'tweets', '@elonmusk'])).toEqual([ - 'twitter', - 'tweets', - '@elonmusk', - ]); - expect(rewriteBrowserArgv(['doctor'])).toEqual(['doctor']); - }); - - it('returns argv unchanged when `browser` is the last token', () => { - expect(rewriteBrowserArgv(['browser'])).toEqual(['browser']); - }); - - it('only rewrites when `browser` is the root command, not deeper in argv', () => { - // `webcmd adapter init browser/x` — the literal `browser` is a path argument, - // not the root command. Must not be touched. - expect(rewriteBrowserArgv(['adapter', 'init', 'browser', 'x'])).toEqual([ - 'adapter', - 'init', - 'browser', - 'x', - ]); - // Same for URLs or arbitrary arg values that happen to contain `browser`. - expect(rewriteBrowserArgv(['twitter', 'tweets', 'https://browser.example.com'])).toEqual([ - 'twitter', - 'tweets', - 'https://browser.example.com', - ]); - // First-match heuristic must NOT rewrite when an earlier non-flag token - // already established a different root command. - expect(rewriteBrowserArgv(['list', 'browser', 'state'])).toEqual([ - 'list', - 'browser', - 'state', - ]); - }); - - it('skips leading root flags before identifying the root command', () => { - // `--profile` takes a value — the value is not the command. - expect(rewriteBrowserArgv(['--profile', 'work', 'browser', 'mercury', 'state'])).toEqual([ - '--profile', - 'work', - 'browser', - '--session', - 'mercury', - 'state', - ]); - // Long form with `=` separator consumes one slot only. - expect(rewriteBrowserArgv(['--profile=work', 'browser', 'mercury', 'state'])).toEqual([ - '--profile=work', - 'browser', - '--session', - 'mercury', - 'state', - ]); - // Boolean flags don't consume values. - expect(rewriteBrowserArgv(['-v', 'browser', 'mercury', 'state'])).toEqual([ - '-v', - 'browser', - '--session', - 'mercury', - 'state', - ]); - }); - - it('hoists a trailing browser --window option to the namespace slot', () => { - expect(rewriteBrowserArgv(['browser', 'work', 'open', 'https://x.com', '--window', 'background'])).toEqual([ - 'browser', - '--session', - 'work', - '--window', - 'background', - 'open', - 'https://x.com', - ]); - expect(rewriteBrowserArgv(['browser', 'work', 'state', '--window', 'foreground'])).toEqual([ - 'browser', - '--session', - 'work', - '--window', - 'foreground', - 'state', - ]); - }); - - it('hoists trailing browser --window after leading root options', () => { - expect(rewriteBrowserArgv(['--profile', 'sandbox', 'browser', 'work', 'state', '--window', 'background'])).toEqual([ - '--profile', - 'sandbox', - 'browser', - '--session', - 'work', - '--window', - 'background', - 'state', - ]); - }); - - it('hoists a trailing browser --window= option', () => { - expect(rewriteBrowserArgv(['browser', 'work', 'open', 'https://x.com', '--window=background'])).toEqual([ - 'browser', - '--session', - 'work', - '--window=background', - 'open', - 'https://x.com', - ]); - }); - - it('hoists browser --window after nested browser leaf commands', () => { - expect(rewriteBrowserArgv(['browser', 'work', 'get', 'url', '--window', 'background'])).toEqual([ - 'browser', - '--session', - 'work', - '--window', - 'background', - 'get', - 'url', - ]); - expect(rewriteBrowserArgv(['browser', 'work', 'tab', 'close', 'abc123', '--window', 'background'])).toEqual([ - 'browser', - '--session', - 'work', - '--window', - 'background', - 'tab', - 'close', - 'abc123', - ]); - }); - - it('leaves an already parent-slot browser --window option untouched', () => { - expect(rewriteBrowserArgv(['browser', 'work', '--window', 'background', 'open', 'https://x.com'])).toEqual([ - 'browser', - '--session', - 'work', - '--window', - 'background', - 'open', - 'https://x.com', - ]); - }); - - it('does not hoist browser --window after a literal -- separator', () => { - expect(rewriteBrowserArgv(['browser', 'work', 'eval', 'console.log(1)', '--', '--window', 'background'])).toEqual([ - 'browser', - '--session', - 'work', - 'eval', - 'console.log(1)', - '--', - '--window', - 'background', - ]); - }); - - it('does not hoist a bare trailing browser --window without a value', () => { - expect(rewriteBrowserArgv(['browser', 'work', 'open', 'https://x.com', '--window'])).toEqual([ - 'browser', - '--session', - 'work', - 'open', - 'https://x.com', - '--window', - ]); + it('keeps the canonical root selector unchanged', () => { + expect(rejectPositionalBrowserSessionArgv(['--session', 'session_a', 'browser', 'run', '--stdin'])) + .toEqual(['--session', 'session_a', 'browser', 'run', '--stdin']); }); +}); - it('leaves argv alone when the root command is not `browser`, even if `browser` appears later', () => { - // The first browser keyword does NOT win — it must be at the root. - expect(rewriteBrowserArgv(['twitter', 'browser', 'work', 'state'])).toEqual([ - 'twitter', - 'browser', - 'work', - 'state', +describe('rejectPositionalBrowserSessionArgv details', () => { + it('keeps browser subcommands and hoists trailing --window', () => { + expect(rejectPositionalBrowserSessionArgv(['--session', 'session_a', 'browser', 'state', '--window', 'background'])).toEqual([ + '--session', 'session_a', 'browser', '--window', 'background', 'state', ]); }); - it('reserved subcommand list covers every known browser subcommand registered in cli.ts', () => { - const names = getBrowserSubcommandNames(); - const required = [ - 'analyze', 'back', 'bind', 'check', 'click', 'close', 'console', 'dblclick', - 'dialog', 'drag', 'eval', 'extract', 'fill', 'find', 'focus', 'frames', - 'get', 'hover', 'init', 'keys', 'network', 'open', 'screenshot', 'scroll', - 'run', 'select', 'state', 'tab', 'type', 'unbind', 'uncheck', 'upload', 'verify', - 'wait', - ]; - for (const name of required) { - expect(names.has(name)).toBe(true); - } + it('leaves non-browser commands unchanged', () => { + expect(rejectPositionalBrowserSessionArgv(['twitter', 'browser', 'session_a', 'state'])) + .toEqual(['twitter', 'browser', 'session_a', 'state']); }); }); diff --git a/src/cli-argv-preprocess.ts b/src/cli-argv-preprocess.ts index 8201be87..b9bd22a5 100644 --- a/src/cli-argv-preprocess.ts +++ b/src/cli-argv-preprocess.ts @@ -1,10 +1,6 @@ /** - * argv preprocessing: rewrite `webcmd browser ...` - * into `webcmd browser --session ...` so commander - * (which can't combine a parent positional with subcommand dispatch) can parse it. - * - * The user-facing form is positional; the internal form uses --session. Help text - * for the `browser` command is overridden to advertise the positional form. + * Reject the retired positional browser-session grammar before Commander parses + * the canonical root `--session` selector. */ /** @@ -30,6 +26,7 @@ const BROWSER_SUBCOMMAND_NAMES: ReadonlySet = new Set([ 'fill', 'find', 'focus', + 'fork', 'frames', 'get', 'help', @@ -42,8 +39,10 @@ const BROWSER_SUBCOMMAND_NAMES: ReadonlySet = new Set([ 'screenshot', 'scroll', 'select', + 'snapshot', 'state', 'tab', + 'tabs', 'type', 'unbind', 'uncheck', @@ -60,7 +59,7 @@ const BROWSER_SUBCOMMAND_NAMES: ReadonlySet = new Set([ * * Keep in sync with `program.option(...)` calls in cli.ts. */ -const ROOT_VALUE_FLAGS: ReadonlySet = new Set(['--profile']); +const ROOT_VALUE_FLAGS: ReadonlySet = new Set(['--profile', '--session', '--workspace']); /** * Returns the set of reserved subcommand names (exposed for tests so they stay @@ -70,60 +69,35 @@ export function getBrowserSubcommandNames(): ReadonlySet { return BROWSER_SUBCOMMAND_NAMES; } -/** - * Rewrite `argv` to convert the positional `` after `browser` - * into the internal `--session ` flag form. - * - * Only acts when `browser` is the root command (i.e. the first non-flag token - * after any leading root options), so it can't mis-interpret occurrences of - * the literal word `browser` deeper in the argv (e.g. `webcmd adapter init - * browser/x`, or a URL value containing `browser`). - * - * Leaves argv unchanged when: - * - root command is not `browser` - * - the token after `browser` is a flag (e.g. `--help`) - * - the token after `browser` is a known subcommand name (session was - * omitted; commander will surface its own required-flag error) - */ -export function rewriteBrowserArgv(argv: readonly string[]): string[] { +/** Rejects retired `browser ...` while preserving canonical argv. */ +export function rejectPositionalBrowserSessionArgv(argv: readonly string[]): string[] { const result = [...argv]; - // Walk past leading root flags + their values to find the root command token. - let i = 0; - while (i < result.length) { - const tok = result[i]; - if (!tok.startsWith('-')) break; - // `--flag=value` consumes one slot regardless of whether the flag expects a value. - if (tok.includes('=')) { - i += 1; - continue; - } - if (ROOT_VALUE_FLAGS.has(tok) && i + 1 < result.length) { - i += 2; - } else { - i += 1; - } + const commandIndex = findRootCommandIndex(result); + if (result[commandIndex] !== 'browser') return result; + const candidate = result[commandIndex + 1]; + if (!candidate || candidate.startsWith('-') || BROWSER_SUBCOMMAND_NAMES.has(candidate)) { + hoistBrowserWindowOption(result, commandIndex + 1); + return result; } - if (result[i] !== 'browser') return result; - const sessionIdx = i + 1; - const next = result[sessionIdx]; - if (next === undefined) return result; - // The retired `--session` flag must not be a working public entrance. - if (next === '--session' || next === '--session=' || next.startsWith('--session=')) { - throw new BrowserSessionArgvError( - 'The `--session` flag is no longer a public option. Use the positional form: webcmd browser ', - ); + const replacement = [ + ...result.slice(0, commandIndex), + '--session', candidate, + 'browser', + ...result.slice(commandIndex + 2), + ]; + throw new BrowserSessionArgvError( + `Browser sessions are root selectors. Use: webcmd ${replacement.join(' ')}`, + ); +} + +function findRootCommandIndex(argv: readonly string[]): number { + let index = 0; + while (index < argv.length) { + const token = argv[index]!; + if (!token.startsWith('-')) return index; + index += token.includes('=') || !ROOT_VALUE_FLAGS.has(token) ? 1 : 2; } - if (next.startsWith('-')) return result; - if (BROWSER_SUBCOMMAND_NAMES.has(next)) return result; - // Splice in --session in place of the positional. - result.splice(sessionIdx, 1, '--session', next); - // `--window` is a browser namespace option, so commander accepts it before the - // leaf command. Users naturally put it at the end: - // `browser work open https://x.com --window background`. Hoist that public - // form into the namespace-option slot instead of mirroring the option onto - // every browser leaf command. - hoistBrowserWindowOption(result, sessionIdx + 2); - return result; + return index; } /** diff --git a/src/cli.test.ts b/src/cli.test.ts index 8d327a80..01edc156 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -1060,22 +1060,19 @@ name: 'search', const browser = program.commands.find(cmd => cmd.name() === 'browser'); expect(browser).toBeTruthy(); - process.argv = ['node', 'webcmd', 'browser', '--session', 'test', '--help', '-f', 'yaml']; + process.argv = ['node', 'webcmd', '--session', 'session_test', 'browser', '--help', '-f', 'yaml']; const data = yaml.load(browser!.helpInformation()) as any; expect(data.namespace).toBe('browser'); expect(data.command).toBe('webcmd browser'); - expect(data.description).toBe('Run Playwright programs against named browser sessions'); + expect(data.description).toBe('Run Playwright programs against an explicit browser Session'); expect(data.command_count).toBe(8); expect(data.commands.map((cmd: any) => cmd.name)).toEqual(['bind', 'close', 'fork', 'init', 'run', 'snapshot', 'tabs', 'verify']); - // `--session` is now a hidden internal option; user-facing surface is the - // positional declared via `.usage()`. Structured help drops - // hidden options, so namespace_options shouldn't expose it. expect(data.namespace_options).not.toEqual(expect.arrayContaining([ expect.objectContaining({ name: 'session' }), ])); expect(data.namespace_options).toEqual([]); - expect(data.usage).toBe('webcmd browser [options]'); + expect(data.usage).toBe('webcmd browser [args] [options]'); expect(data.global_options).toEqual(expect.arrayContaining([ expect.objectContaining({ name: 'version', @@ -1086,15 +1083,17 @@ name: 'search', flags: '--profile ', takes_value: 'required', }), + expect.objectContaining({ + name: 'session', + flags: '--session ', + takes_value: 'required', + }), ])); const bind = data.commands.find((cmd: any) => cmd.name === 'bind'); - // Structured help command/usage paths include the positional so - // agents construct the correct full invocation. `name` is the leaf - // identifier (placeholder positionals are stripped). expect(bind).toMatchObject({ - command: 'webcmd browser bind', - usage: 'webcmd browser bind [options]', + command: 'webcmd browser bind', + usage: 'webcmd browser bind [options]', positionals: [], }); expect(bind.command_options.map((option: any) => option.name)).toEqual(['page']); @@ -1414,7 +1413,7 @@ describe('browser verify', () => { fs.writeFileSync(path.join(adapterDir, 'top.js'), 'export default {};\n', 'utf-8'); const program = createProgram('', ''); - await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'verify', 'hn/top', '--no-fixture', '--trace', 'retain-on-failure']); + await program.parseAsync(['node', 'webcmd', '--session', 'session_test', 'browser', 'verify', 'hn/top', '--no-fixture', '--trace', 'retain-on-failure']); expect(mockExecFileSync).toHaveBeenCalledTimes(1); const [, execArgs] = mockExecFileSync.mock.calls[0] as [string, string[]]; @@ -1441,7 +1440,7 @@ describe('browser verify', () => { fs.writeFileSync(path.join(adapterDir, 'top.js'), 'export default {};\n', 'utf-8'); const program = createProgram('', ''); - await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'verify', 'hn/top', '--no-fixture', '--seed-args', 'webcmd-verify']); + await program.parseAsync(['node', 'webcmd', '--session', 'session_test', 'browser', 'verify', 'hn/top', '--no-fixture', '--seed-args', 'webcmd-verify']); expect(mockExecFileSync).toHaveBeenCalledTimes(1); const [, execArgs] = mockExecFileSync.mock.calls[0] as [string, string[]]; @@ -1469,7 +1468,7 @@ describe('browser verify', () => { fs.writeFileSync(path.join(adapterDir, 'top.js'), 'export default {};\n', 'utf-8'); const program = createProgram('', ''); - await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'verify', 'hn/top', '--write-fixture', '--seed-args', 'webcmd-verify']); + await program.parseAsync(['node', 'webcmd', '--session', 'session_test', 'browser', 'verify', 'hn/top', '--write-fixture', '--seed-args', 'webcmd-verify']); const fixtureFile = path.join(fakeHome, '.webcmd', 'sites', 'hn', 'verify', 'top.json'); const fixture = JSON.parse(fs.readFileSync(fixtureFile, 'utf-8')); @@ -1500,7 +1499,7 @@ describe('browser verify', () => { fs.writeFileSync(path.join(adapterDir, 'top.js'), 'export default {};\n', 'utf-8'); const program = createProgram('', ''); - await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'verify', 'hn/top', '--no-fixture']); + await program.parseAsync(['node', 'webcmd', '--session', 'session_test', 'browser', 'verify', 'hn/top', '--no-fixture']); expect(process.exitCode).toBe(1); const output = consoleLogSpy.mock.calls.map((args) => args.join(' ')).join('\n'); @@ -1594,14 +1593,28 @@ describe('browser raw session commands', () => { mockSendCommand.mockReset().mockResolvedValue({ ok: true }); }); + it.each([ + { argv: ['browser', 'tabs'], code: 'SESSION_REQUIRED' }, + { argv: ['--session', 'work', 'browser', 'tabs'], code: 'INVALID_SESSION_SELECTOR' }, + ])('rejects an unusable raw selector with exit 2 before daemon dispatch: $code', async ({ argv, code }) => { + const program = createProgram('', ''); + + await program.parseAsync(['node', 'webcmd', ...argv]); + + expect(process.exitCode).toBe(2); + expect(mockListExistingBrowserTabs).not.toHaveBeenCalled(); + expect(mockSendCommand).not.toHaveBeenCalled(); + expect(stderrSpy.mock.calls.flat().join('')).toContain(code); + }); + it('lists tabs without allocating a local browser runtime', async () => { const program = createProgram('', ''); - await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'tabs']); + await program.parseAsync(['node', 'webcmd', '--session', 'session_test', 'browser', 'tabs']); expect(mockBrowserConnect).not.toHaveBeenCalled(); expect(mockSendCommand).not.toHaveBeenCalled(); - expect(mockListExistingBrowserTabs).toHaveBeenCalledWith('test', {}); + expect(mockListExistingBrowserTabs).toHaveBeenCalledWith('session_test', {}); expect(consoleLogSpy).toHaveBeenLastCalledWith('[]'); }); @@ -1609,33 +1622,33 @@ describe('browser raw session commands', () => { mockListExistingBrowserTabs.mockResolvedValue([{ page: 'page-123' }]); const program = createProgram('', ''); - await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'tabs']); + await program.parseAsync(['node', 'webcmd', '--session', 'session_test', 'browser', 'tabs']); expect(mockBrowserConnect).not.toHaveBeenCalled(); - expect(mockListExistingBrowserTabs).toHaveBeenCalledWith('test', {}); + expect(mockListExistingBrowserTabs).toHaveBeenCalledWith('session_test', {}); }); it('binds only an explicit stable page id', async () => { const program = createProgram('', ''); - await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'bind', '--page', 'page-123']); + await program.parseAsync(['node', 'webcmd', '--session', 'session_test', 'browser', 'bind', '--page', 'page-123']); expect(mockSendCommand).toHaveBeenCalledWith('bind', { - session: 'test', surface: 'browser', page: 'page-123', + session: 'session_test', surface: 'browser', page: 'page-123', }); - await expect(program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'bind', '--index', '0'])) + await expect(program.parseAsync(['node', 'webcmd', '--session', 'session_test', 'browser', 'bind', '--index', '0'])) .rejects.toThrow(/process\.exit unexpectedly called/); - await expect(program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'bind', '--page', ' '])) + await expect(program.parseAsync(['node', 'webcmd', '--session', 'session_test', 'browser', 'bind', '--page', ' '])) .rejects.toThrow(/process\.exit unexpectedly called/); }); it('sends snapshot inspection options to the browser runtime', async () => { const program = createProgram('', ''); - await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'snapshot', '--snapshot-mode', 'read', '--ref', 'e12', '--max-output', '1000']); + await program.parseAsync(['node', 'webcmd', '--session', 'session_test', 'browser', 'snapshot', '--snapshot-mode', 'read', '--ref', 'e12', '--max-output', '1000']); expect(mockSendCommand).toHaveBeenCalledWith('snapshot', { - session: 'test', surface: 'browser', snapshotMode: 'read', ref: 'e12', maxOutputChars: 1000, + session: 'session_test', surface: 'browser', snapshotMode: 'read', ref: 'e12', maxOutputChars: 1000, }); }); @@ -1644,18 +1657,18 @@ describe('browser raw session commands', () => { fs.writeFileSync(sourcePath, 'return 42;', 'utf8'); try { const program = createProgram('', ''); - await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'run', '--file', sourcePath]); + await program.parseAsync(['node', 'webcmd', '--session', 'session_test', 'browser', 'run', '--file', sourcePath]); expect(mockSendCommand).toHaveBeenCalledWith('run', { - session: 'test', surface: 'browser', source: 'return 42;', snapshotMode: 'act', + session: 'session_test', surface: 'browser', source: 'return 42;', snapshotMode: 'act', }); - await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'run', '--stdin', '--file', sourcePath]); + await program.parseAsync(['node', 'webcmd', '--session', 'session_test', 'browser', 'run', '--stdin', '--file', sourcePath]); expect(mockSendCommand).toHaveBeenCalledTimes(1); expect(process.exitCode).toBeDefined(); process.exitCode = undefined; fs.writeFileSync(sourcePath, '', 'utf8'); - await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'run', '--file', sourcePath]); + await program.parseAsync(['node', 'webcmd', '--session', 'session_test', 'browser', 'run', '--file', sourcePath]); expect(mockSendCommand).toHaveBeenCalledTimes(1); expect(process.exitCode).toBeDefined(); } finally { @@ -1665,8 +1678,8 @@ describe('browser raw session commands', () => { it('closes the named session through the daemon', async () => { const program = createProgram('', ''); - await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'close']); - expect(mockSendCommand).toHaveBeenCalledWith('close-window', { session: 'test', surface: 'browser' }); + await program.parseAsync(['node', 'webcmd', '--session', 'session_test', 'browser', 'close']); + expect(mockSendCommand).toHaveBeenCalledWith('close-window', { session: 'session_test', surface: 'browser' }); }); }); @@ -1699,7 +1712,7 @@ function installSelectorFirstTestHarness(label: string, pageOverrides: () => Par setActivePage: vi.fn(), getActivePage: vi.fn().mockReturnValue('tab-1'), tabs: vi.fn().mockResolvedValue([{ page: 'tab-1', active: true }]), - session: 'test', + session: 'session_test', ...pageOverrides(), } as unknown as IPage; }); diff --git a/src/cli.ts b/src/cli.ts index 31042c1f..bc3988f3 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -47,6 +47,7 @@ import { CLI_COMMAND, PACKAGE_NAME } from './brand.js'; import type { BrowserDownloadWaitResult, IPage, ScreenshotOptions } from './types.js'; import type { BrowserWindowMode } from './runtime.js'; import { configureRootCommandSurface } from './root-command-surface.js'; +import { validateRawBrowserSession } from './hosted/browser-args.js'; import { missingPluginGuidance, PLUGINS_DIR } from './discovery.js'; import { loadBrowserRunSource } from './browser/run/input.js'; import { BrowserRunError } from './browser/run/types.js'; @@ -539,12 +540,7 @@ function getCommandOption(command: Command | undefined, option: string): unknown } function getBrowserSession(command?: Command): string { - // The CLI surface is `webcmd browser `. main.ts rewrites - // argv to insert `--session ` before commander parses it; this helper - // reads back the rewritten flag. - const raw = getCommandOption(command, 'session'); - if (typeof raw === 'string' && raw.trim()) return raw.trim(); - throw new Error(' is a required positional argument: webcmd browser '); + return validateRawBrowserSession(getCommandOption(command, 'session'), getCommandOption(command, 'profile') as string | undefined); } function getBrowserProfileSelection(command?: Command): ProfileSelection | undefined { @@ -793,20 +789,7 @@ export function createProgram(BUILTIN_CLIS: string, USER_CLIS: string, pluginsDi const browser = program .command('browser') - // --session is an internal hidden option used by the daemon protocol and direct - // program.parseAsync callers (tests). User-facing surface is the - // positional; main.ts argv preprocessor rewrites positional -> --session. - .addOption(new Option('--session ', 'Internal — set automatically from the positional').hideHelp()) - .description('Run Playwright programs against named browser sessions') - .usage(' [options]') - .addHelpText('after', ` - is a required positional: pass the name of the browser session every subcommand should operate on. Reuse the same name across calls to keep the tab/state alive; pick a different name to isolate parallel browser work. - -Examples: - $ webcmd browser work tabs - $ webcmd browser work bind --page page-123 - $ printf 'await page.goto("https://example.com")' | webcmd browser work run --stdin -`); + .description('Run Playwright programs against an explicit browser Session'); const originalBrowserDescription = browser.description(); // ── Init (adapter scaffolding) ── @@ -1056,8 +1039,9 @@ cli({ }, }, null, 2)); } - log.error(error instanceof Error ? error.message : String(error)); - process.exitCode = EXIT_CODES.GENERIC_ERROR; + log.error(error instanceof CliError ? `${error.code}: ${error.message}` : error instanceof Error ? error.message : String(error)); + if (error instanceof CliError && error.hint) log.error(error.hint); + process.exitCode = error instanceof CliError ? error.exitCode : EXIT_CODES.GENERIC_ERROR; } }; } @@ -1866,28 +1850,6 @@ cli({ program.configureHelp({ visibleCommands: (command) => command.commands.filter(child => command !== program || !adapterNameSet.has(child.name())), }); - // When an ancestor command declares a leading positional via `.usage(...)` - // (e.g. `browser` -> ` [options]`), inject the positional - // between that ancestor's name and the next path segment so the help Usage - // line is accurate: `Usage: webcmd browser run [options]` - // instead of `webcmd browser run [options]`. Commander does NOT - // inherit configureHelp into subcommands, so we walk the descendant tree and - // apply the override on each. - const ancestorAwareCommandUsage = (cmd: Command): string => { - const ancestors: string[] = []; - let ancestor: Command | null = cmd.parent; - while (ancestor) { - const positional = leadingPositionalFromUsage(ancestor); - ancestors.unshift(positional ? `${ancestor.name()} ${positional}` : ancestor.name()); - ancestor = ancestor.parent; - } - return [...ancestors, cmd.name(), cmd.usage()].filter(Boolean).join(' ').trim(); - }; - function applyAncestorAwareUsage(cmd: Command): void { - cmd.configureHelp({ commandUsage: ancestorAwareCommandUsage }); - for (const sub of cmd.commands) applyAncestorAwareUsage(sub); - } - applyAncestorAwareUsage(browser); installRootPresentationHelp( program, () => rootHelpData(program, adapterGroups), diff --git a/src/commanderAdapter.ts b/src/commanderAdapter.ts index 0664c4f5..6ae9a433 100644 --- a/src/commanderAdapter.ts +++ b/src/commanderAdapter.ts @@ -104,6 +104,7 @@ export function registerCommandToProgram( const result = await executeCommand(cmd, kwargs, verbose, { prepared: true, ...(typeof globals.profile === 'string' && globals.profile.trim() ? { profile: globals.profile.trim() } : {}), + ...(typeof globals.session === 'string' && globals.session.trim() ? { session: globals.session.trim() } : {}), ...(typeof optionsRecord.trace === 'string' && optionsRecord.trace !== 'off' ? { trace: optionsRecord.trace } : {}), ...(cmd.browser && typeof optionsRecord.window === 'string' ? { windowMode: optionsRecord.window } : {}), ...(cmd.browser && typeof optionsRecord.siteSession === 'string' ? { siteSession: optionsRecord.siteSession } : {}), diff --git a/src/execution.ts b/src/execution.ts index bc11dc9d..dfe920fa 100644 --- a/src/execution.ts +++ b/src/execution.ts @@ -161,6 +161,7 @@ export async function executeCommand( opts: { prepared?: boolean; profile?: string; + session?: string; trace?: string; keepTab?: string; windowMode?: string; diff --git a/src/hosted/browser-args.test.ts b/src/hosted/browser-args.test.ts index ee6729ac..645de74b 100644 --- a/src/hosted/browser-args.test.ts +++ b/src/hosted/browser-args.test.ts @@ -1,53 +1,74 @@ import { describe, expect, it } from 'vitest'; import { CommanderStructuralError } from '../command-surface.js'; import { browserCommandCatalog } from '../browser/command-catalog.js'; -import { rewriteBrowserArgv } from '../cli-argv-preprocess.js'; -import { parseHostedBrowserStructure } from './browser-args.js'; +import { parseHostedBrowserStructure, validateRawBrowserSession } from './browser-args.js'; function parse(argv: string[]) { - return parseHostedBrowserStructure(rewriteBrowserArgv(argv)); + return parseHostedBrowserStructure(argv); } describe('hosted browser argument surface', () => { + it('requires an opaque root session selector for raw browser dispatch', () => { + expect(() => validateRawBrowserSession(undefined)).toThrowError( + expect.objectContaining({ code: 'SESSION_REQUIRED', exitCode: 2 }), + ); + expect(() => validateRawBrowserSession('work')).toThrowError( + expect.objectContaining({ code: 'INVALID_SESSION_SELECTOR', exitCode: 2 }), + ); + }); + + it('includes the selected profile in raw-session recovery commands', () => { + expect(() => validateRawBrowserSession(undefined, 'work')).toThrowError( + expect.objectContaining({ + hint: expect.stringContaining('webcmd --profile work session create'), + }), + ); + expect(() => validateRawBrowserSession(undefined, 'work')).toThrowError( + expect.objectContaining({ + hint: expect.stringContaining('webcmd --profile work session list'), + }), + ); + }); + it('uses the same command catalog as local mode', () => { expect(browserCommandCatalog.map(command => command.command)).toEqual(['tabs', 'bind', 'fork', 'run', 'snapshot', 'close']); }); it('parses a hosted adapter fork command', () => { - expect(parse(['browser', 'work', 'fork', 'linkedin/search'])).toMatchObject({ + expect(parse(['--session', 'session_work', 'browser', 'fork', 'linkedin/search'])).toMatchObject({ commandName: 'fork', - session: 'work', + session: 'session_work', positionals: ['linkedin/search'], }); }); it('requires a stable page id for bind', () => { - expect(parse(['browser', 'work', 'bind', '--page', 'page-123'])).toMatchObject({ + expect(parse(['--session', 'session_work', 'browser', 'bind', '--page', 'page-123'])).toMatchObject({ commandName: 'bind', - session: 'work', + session: 'session_work', options: { page: 'page-123' }, }); - expect(() => parse(['browser', 'work', 'bind'])).toThrow(CommanderStructuralError); - expect(() => parse(['browser', 'work', 'bind', '--index', '0'])).toThrow(CommanderStructuralError); - expect(() => parse(['browser', 'work', 'bind', '--page', ' '])).toThrow(CommanderStructuralError); + expect(() => parse(['--session', 'session_work', 'browser', 'bind'])).toThrow(CommanderStructuralError); + expect(() => parse(['--session', 'session_work', 'browser', 'bind', '--index', '0'])).toThrow(CommanderStructuralError); + expect(() => parse(['--session', 'session_work', 'browser', 'bind', '--page', ' '])).toThrow(CommanderStructuralError); }); it('accepts only run program options', () => { - expect(parse(['browser', 'work', 'run', '--file', 'job.js', '--timeout', '12', '--max-output', '1000', '--snapshot-mode', 'tree', '--no-snapshot-diff'])) + expect(parse(['--session', 'session_work', 'browser', 'run', '--file', 'job.js', '--timeout', '12', '--max-output', '1000', '--snapshot-mode', 'tree', '--no-snapshot-diff'])) .toMatchObject({ commandName: 'run', - session: 'work', + session: 'session_work', options: { file: 'job.js', timeout: 12, maxOutput: 1000, snapshotMode: 'tree', noSnapshotDiff: true }, }); - expect(() => parse(['browser', 'work', 'run', '--snapshot-mode', 'read'])).toThrow(CommanderStructuralError); - expect(() => parse(['browser', 'work', 'run', '--tab', 'page-123'])).toThrow(CommanderStructuralError); + expect(() => parse(['--session', 'session_work', 'browser', 'run', '--snapshot-mode', 'read'])).toThrow(CommanderStructuralError); + expect(() => parse(['--session', 'session_work', 'browser', 'run', '--tab', 'page-123'])).toThrow(CommanderStructuralError); }); it('parses snapshot inspection options', () => { - expect(parse(['browser', 'work', 'snapshot', '--snapshot-mode', 'read', '--max-output', '1000'])) + expect(parse(['--session', 'session_work', 'browser', 'snapshot', '--snapshot-mode', 'read', '--max-output', '1000'])) .toMatchObject({ commandName: 'snapshot', - session: 'work', + session: 'session_work', options: { snapshotMode: 'read', maxOutput: 1000 }, }); }); diff --git a/src/hosted/browser-args.ts b/src/hosted/browser-args.ts index 76c480ee..6e5f7644 100644 --- a/src/hosted/browser-args.ts +++ b/src/hosted/browser-args.ts @@ -5,6 +5,8 @@ import { browserOptionValueParser, } from '../browser/command-catalog.js'; import { CommanderStructuralError } from '../command-surface.js'; +import { CliError, EXIT_CODES } from '../errors.js'; +import { configureRootCommandSurface } from '../root-command-surface.js'; export class HostedBrowserHelp extends Error { constructor(readonly output: string) { @@ -22,28 +24,28 @@ export interface ParsedHostedBrowserStructure { profile?: string; } +export function validateRawBrowserSession(value: unknown, profile?: string): string { + const session = typeof value === 'string' ? value.trim() : ''; + const profileFlag = profile?.trim() ? ` --profile ${profile.trim()}` : ''; + const help = `Create one: webcmd${profileFlag} session create\nList sessions: webcmd${profileFlag} session list`; + if (!session) throw new CliError('SESSION_REQUIRED', 'A Session selector is required for browser commands.', help, EXIT_CODES.USAGE_ERROR); + if (!/^session_[A-Za-z0-9_-]+$/u.test(session)) { + throw new CliError('INVALID_SESSION_SELECTOR', 'Session selector must be an opaque Session ID.', help, EXIT_CODES.USAGE_ERROR); + } + return session; +} + /** * Parse the hosted browser argv with the exact canonical Commander grammar. * The returned values are the values produced by Commander's action boundary; * callers must not reinterpret the original argv with a second parser. */ export function parseHostedBrowserStructure(argv: readonly string[]): ParsedHostedBrowserStructure { - const root = new Command('webcmd') - .option('--profile ', 'Chrome profile/context alias for browser runtime commands') - .enablePositionalOptions(); + const root = configureRootCommandSurface(new Command('webcmd')); const browser = root .command('browser') - .addOption(new Option('--session ', 'Internal — set automatically from the positional').hideHelp()) - .description('Run Playwright programs against named browser sessions') - .usage(' [options]') - .addHelpText('after', ` - is a required positional: pass the name of the browser session every subcommand should operate on. Reuse the same name across calls to keep the tab/state alive; pick a different name to isolate parallel browser work. - -Examples: - $ webcmd browser work tabs - $ webcmd browser work bind --page page-123 - $ printf 'await page.goto("https://example.com")' | webcmd browser work run --stdin -`); + .description('Run Playwright programs against an explicit browser Session') + ; let parsed: ParsedHostedBrowserStructure | undefined; const namespaces = new Map([['', browser]]); @@ -115,21 +117,6 @@ Examples: for (const child of command.commands) configure(child); }; configure(root); - const browserAwareUsage = (command: Command): string => { - const ancestors: string[] = []; - let ancestor = command.parent; - while (ancestor) { - ancestors.unshift(ancestor === browser ? `${ancestor.name()} ` : ancestor.name()); - ancestor = ancestor.parent; - } - return [...ancestors, command.name(), command.usage()].filter(Boolean).join(' ').trim(); - }; - const configureBrowserUsage = (command: Command): void => { - command.configureHelp({ commandUsage: browserAwareUsage }); - for (const child of command.commands) configureBrowserUsage(child); - }; - configureBrowserUsage(browser); - try { root.parse([...argv], { from: 'user' }); } catch (error) { @@ -161,7 +148,7 @@ function readBrowserGlobals(root: Command, browser: Command): Pick< const rootOptions = root.opts>(); const browserOptions = browser.opts>(); return { - ...(typeof browserOptions.session === 'string' ? { session: browserOptions.session } : {}), + ...(typeof rootOptions.session === 'string' ? { session: rootOptions.session } : {}), ...(typeof browserOptions.window === 'string' ? { window: browserOptions.window } : {}), ...(typeof rootOptions.profile === 'string' ? { profile: rootOptions.profile } : {}), }; diff --git a/src/hosted/client.test.ts b/src/hosted/client.test.ts index 15ff6959..69b80d41 100644 --- a/src/hosted/client.test.ts +++ b/src/hosted/client.test.ts @@ -476,6 +476,26 @@ describe('HostedClient', () => { } satisfies Partial); }); + it('carries the root session selector in execute requests', async () => { + let requestBody: unknown; + const client = new HostedClient({ + apiBaseUrl: 'https://api.example.com', + apiKey: 'key', + fetchImpl: async (_url, init) => { + requestBody = JSON.parse(String(init?.body)); + return new Response(JSON.stringify({ + ok: true, + result: [], + execution: { id: 'exec_1', command: 'github/whoami', status: 'succeeded' }, + })); + }, + }); + + await client.execute({ command: 'github/whoami', args: {}, session: 'session_a' }); + + expect(requestBody).toMatchObject({ command: 'github/whoami', session: 'session_a' }); + }); + it('prepares, uploads, runs, and downloads execution artifacts with raw byte bodies', async () => { const requests: Array<{ url: string; method: string; body?: unknown; filename?: string | null }> = []; const bytes = new Uint8Array(Buffer.from('hello cloud')); @@ -558,6 +578,7 @@ describe('HostedClient', () => { executionId: 'exec_files', command: 'twitter/post', args: {}, + session: 'session_a', })).resolves.toMatchObject({ artifacts: [{ artifactId: 'artifact_out' }] }); await expect(client.downloadExecutionArtifact({ executionId: 'exec_files', @@ -574,6 +595,7 @@ describe('HostedClient', () => { filename: 'one.png', body: new Uint8Array(Buffer.from('png')), }); + expect(JSON.parse(String(requests[2]?.body))).toMatchObject({ session: 'session_a' }); }); it('preserves execution and trace metadata from hosted failure envelopes', async () => { diff --git a/src/hosted/client.ts b/src/hosted/client.ts index 14ef5aee..fe0969a7 100644 --- a/src/hosted/client.ts +++ b/src/hosted/client.ts @@ -159,6 +159,7 @@ export class HostedClient { format?: string; trace?: string; profile?: string; + session?: string; }): Promise { const traceMode = normalizeTraceMode(input.trace); const body = await this.request('/v1/execute', { @@ -211,6 +212,7 @@ export class HostedClient { format?: string; trace?: string; profile?: string; + session?: string; }): Promise { const traceMode = normalizeTraceMode(input.trace); const body = await this.request(`/v1/executions/${encodeURIComponent(input.executionId)}/run`, { @@ -221,6 +223,7 @@ export class HostedClient { ...(input.format !== undefined ? { format: input.format } : {}), ...(input.trace !== undefined ? { trace: input.trace } : {}), ...(input.profile !== undefined ? { profile: input.profile } : {}), + ...(input.session !== undefined ? { session: input.session } : {}), }), }, { command: input.command, traceMode }); if (!isHostedExecuteResponse(body, input.command, traceMode)) { diff --git a/src/hosted/root-command-surface.test.ts b/src/hosted/root-command-surface.test.ts index 92f1c793..d40dbee1 100644 --- a/src/hosted/root-command-surface.test.ts +++ b/src/hosted/root-command-surface.test.ts @@ -180,6 +180,18 @@ const generatedTerminalCorpus = profileForms.flatMap(profile => ); describe('hosted root command surface', () => { + it('parses the canonical root session selector', () => { + expect(parseHostedRootCommandSurface([ + '--profile', 'work', '--session', 'session_a', 'github', 'issues', + ])).toEqual({ + kind: 'dispatch', + argv: ['github', 'issues'], + profile: 'work', + session: 'session_a', + literal: false, + }); + }); + it('advertises hosted profile management as a root command, not a local-only namespace', () => { expect(HOSTED_ROOT_HELP.commands).toContainEqual({ name: 'profile', diff --git a/src/hosted/runner.test.ts b/src/hosted/runner.test.ts index a87836f8..175c5c73 100644 --- a/src/hosted/runner.test.ts +++ b/src/hosted/runner.test.ts @@ -7,7 +7,7 @@ import type { Command } from 'commander'; import { describe, expect, it, vi } from 'vitest'; import { browserCommandCatalog } from '../browser/command-catalog.js'; import { buildHostedContract } from './contract.js'; -import { rewriteBrowserArgv } from '../cli-argv-preprocess.js'; +import { rejectPositionalBrowserSessionArgv } from '../cli-argv-preprocess.js'; import { createProgram } from '../cli.js'; import { formatRootHelp } from '../command-presentation.js'; import { HOSTED_ROOT_HELP } from '../completion-shared.js'; @@ -251,7 +251,7 @@ function captureLocalBrowserStructure(argv: string[]): { }; configure(program); try { - program.parse(rewriteBrowserArgv(argv), { from: 'user' }); + program.parse(rejectPositionalBrowserSessionArgv(argv), { from: 'user' }); return { exitCode: 0, stdout, stderr }; } catch (error) { const commander = error as { exitCode?: number }; @@ -1182,7 +1182,7 @@ describe('runHostedCli', () => { const requests: Array<{ url: string; body?: unknown }> = []; const stdout = sink(); - const result = await runHostedCli(['github', 'whoami', '-f', 'json'], { + const result = await runHostedCli(['--session', 'session_a', 'github', 'whoami', '-f', 'json'], { config: makeHostedConfig({ apiBaseUrl: 'https://api.example.com', apiKey: 'key' }), stdout: stdout.stream, fetchImpl: async (url, init) => { @@ -1205,6 +1205,7 @@ describe('runHostedCli', () => { args: {}, format: 'json', trace: 'off', + session: 'session_a', }, }); expect(stdout.text()).toBe('[\n {\n "username": "octocat"\n }\n]\n'); @@ -1656,7 +1657,7 @@ describe('runHostedCli', () => { const stderr = sink(); const fetchImpl = vi.fn(); - const result = await runHostedCli(['browser', 'work', ...parts, '--help'], { + const result = await runHostedCli(['--session', 'session_work', 'browser', ...parts, '--help'], { config: makeHostedConfig({ apiBaseUrl: 'https://api.example.com', apiKey: 'key' }), stdout: stdout.stream, stderr: stderr.stream, @@ -1688,7 +1689,7 @@ describe('runHostedCli', () => { : contract.command === 'run' ? ['--file', uploadFile] : []; - const result = await runHostedCli(['browser', 'work', ...contract.command.split('/'), ...positionals, ...options], { + const result = await runHostedCli(['--session', 'session_work', 'browser', ...contract.command.split('/'), ...positionals, ...options], { config: makeHostedConfig({ apiBaseUrl: 'https://api.example.com', apiKey: 'key' }), stdout: sink().stream, stderr: sink().stream, @@ -1697,7 +1698,7 @@ describe('runHostedCli', () => { const body = init?.body ? JSON.parse(String(init.body)) as Record : undefined; requests.push({ pathname: parsedUrl.pathname, ...(body ? { body } : {}) }); if (parsedUrl.pathname === '/v1/manifest') return manifestResponse(); - if (parsedUrl.pathname === '/v1/browser/work/commands') { + if (parsedUrl.pathname === '/v1/browser/session_work/commands') { return new Response(JSON.stringify({ ok: true, result: {}, @@ -1705,7 +1706,7 @@ describe('runHostedCli', () => { trace: null, run: { executionId: `exec_${contract.command.replaceAll('/', '_')}`, - session: 'work', + session: 'session_work', profile: { id: 'profile_default', displayName: 'default' }, }, execution: { id: `exec_${contract.command.replaceAll('/', '_')}`, status: 'succeeded' }, @@ -1724,7 +1725,7 @@ describe('runHostedCli', () => { }); expect({ command: contract.command, - action: requests.find(request => request.pathname === '/v1/browser/work/commands')?.body, + action: requests.find(request => request.pathname === '/v1/browser/session_work/commands')?.body, }).toMatchObject({ command: contract.command, action: { command: `browser/${contract.command}`, action: contract.action }, @@ -1741,7 +1742,7 @@ describe('runHostedCli', () => { await writeFile(sourcePath, 'return 42;'); const requests: Array<{ url: string; body?: Record }> = []; try { - const result = await runHostedCli(['browser', 'work', 'run', '--file', sourcePath, '--snapshot-mode', 'tree', '--no-snapshot-diff'], { + const result = await runHostedCli(['--session', 'session_work', 'browser', 'run', '--file', sourcePath, '--snapshot-mode', 'tree', '--no-snapshot-diff'], { config: makeHostedConfig({ apiBaseUrl: 'https://api.example.com', apiKey: 'key' }), stdout: sink().stream, stderr: sink().stream, @@ -1754,7 +1755,7 @@ describe('runHostedCli', () => { result: {}, columns: [], trace: null, - run: { executionId: 'exec_browser_run', session: 'work', profile: { id: 'profile_default', displayName: 'default' } }, + run: { executionId: 'exec_browser_run', session: 'session_work', profile: { id: 'profile_default', displayName: 'default' } }, execution: { id: 'exec_browser_run', status: 'succeeded' }, }), { status: 200 }); }, @@ -1774,7 +1775,7 @@ describe('runHostedCli', () => { it('forwards browser snapshot mode to hosted browser actions', async () => { const requests: Array<{ url: string; body?: Record }> = []; - const result = await runHostedCli(['browser', 'work', 'snapshot', '--snapshot-mode', 'read', '--ref', 'l7', '--max-output', '1000'], { + const result = await runHostedCli(['--session', 'session_work', 'browser', 'snapshot', '--snapshot-mode', 'read', '--ref', 'l7', '--max-output', '1000'], { config: makeHostedConfig({ apiBaseUrl: 'https://api.example.com', apiKey: 'key' }), stdout: sink().stream, stderr: sink().stream, @@ -1784,7 +1785,7 @@ describe('runHostedCli', () => { if (String(url).endsWith('/v1/manifest')) return manifestResponse(); return new Response(JSON.stringify({ ok: true, - run: { executionId: 'exec_browser_snapshot', session: 'work', profile: { id: 'profile_default', displayName: 'default' } }, + run: { executionId: 'exec_browser_snapshot', session: 'session_work', profile: { id: 'profile_default', displayName: 'default' } }, result: { ok: true, tree: '', page: { url: 'https://example.test', title: 'Example' }, warnings: [], limits: { snapshotTruncated: false } }, }), { status: 200 }); }, @@ -1799,7 +1800,7 @@ describe('runHostedCli', () => { it('prints hosted snapshot trees', async () => { const stdout = sink(); - const result = await runHostedCli(['browser', 'work', 'snapshot'], { + const result = await runHostedCli(['--session', 'session_work', 'browser', 'snapshot'], { config: makeHostedConfig({ apiBaseUrl: 'https://api.example.com', apiKey: 'key' }), stdout: stdout.stream, stderr: sink().stream, @@ -1807,7 +1808,7 @@ describe('runHostedCli', () => { ? manifestResponse() : new Response(JSON.stringify({ ok: true, - run: { executionId: 'exec_browser_snapshot', session: 'work', profile: { id: 'profile_default', displayName: 'default' } }, + run: { executionId: 'exec_browser_snapshot', session: 'session_work', profile: { id: 'profile_default', displayName: 'default' } }, result: { ok: true, tree: '', page: { url: 'https://example.test', title: 'Example' }, warnings: [], limits: { snapshotTruncated: false } }, }), { status: 200 }), }); @@ -1829,14 +1830,32 @@ describe('runHostedCli', () => { expect(stderr.text()).not.toContain('# webcmd default github'); }); - it('rejects the retired hosted browser --session flag', async () => { + it('rejects retired positional hosted browser sessions', async () => { const stderr = sink(); - const result = await runHostedCli(['browser', '--session', 'work', 'state'], { + const result = await runHostedCli(['browser', 'work', 'state'], { config: makeHostedConfig({ apiBaseUrl: 'https://api.example.com', apiKey: 'key' }), stderr: stderr.stream, }); - expect(result.exitCode).toBe(78); - expect(stderr.text()).toMatch(/session.*no longer a public option/i); + expect(result.exitCode).toBe(2); + expect(stderr.text()).toMatch(/Browser sessions are root selectors/i); + }); + + it.each([ + { argv: ['browser', 'tabs'], code: 'SESSION_REQUIRED' }, + { argv: ['--session', 'work', 'browser', 'tabs'], code: 'INVALID_SESSION_SELECTOR' }, + ])('rejects an unusable raw selector before hosted transport: $code', async ({ argv, code }) => { + const stderr = sink(); + const fetchImpl = vi.fn(); + + const result = await runHostedCli(argv, { + config: makeHostedConfig({ apiBaseUrl: 'https://api.example.com', apiKey: 'key' }), + stderr: stderr.stream, + fetchImpl, + }); + + expect(result.exitCode).toBe(2); + expect(fetchImpl).not.toHaveBeenCalled(); + expect(stderr.text()).toContain(code); }); }); diff --git a/src/hosted/runner.ts b/src/hosted/runner.ts index e4205332..0991442d 100644 --- a/src/hosted/runner.ts +++ b/src/hosted/runner.ts @@ -11,7 +11,7 @@ import { configurePluginUninstallSurface, configurePluginUpdateSurface, } from '../builtin-command-surface.js'; -import { BrowserSessionArgvError, rewriteBrowserArgv } from '../cli-argv-preprocess.js'; +import { BrowserSessionArgvError, rejectPositionalBrowserSessionArgv } from '../cli-argv-preprocess.js'; import { CommanderStructuralError, MissingRequiredPositionalError } from '../command-surface.js'; import { filterCommandsByTag, formatRootHelp, getCommandCompletionCandidates } from '../command-presentation.js'; import { @@ -33,7 +33,7 @@ import { CLI_COMMAND } from '../brand.js'; import { missingPluginGuidance } from '../discovery.js'; import { HostedClient, HostedClientError, resolveWorkspace } from './client.js'; import { parseHostedInvocation } from './args.js'; -import { HostedBrowserHelp, parseHostedBrowserStructure } from './browser-args.js'; +import { HostedBrowserHelp, parseHostedBrowserStructure, validateRawBrowserSession } from './browser-args.js'; import { materializeHostedOutputs, prepareHostedFiles, rewriteHostedOutputResultPaths } from './files.js'; import { findHostedCommand, @@ -93,6 +93,7 @@ export async function runHostedCli(argv: string[], opts: HostedRunnerOptions = { const stderr = opts.stderr ?? process.stderr; try { + argv = rejectPositionalBrowserSessionArgv(argv); const credential = await resolveHostedApiKey(config, { credentialStore: opts.credentialStore, env: opts.env, @@ -111,6 +112,10 @@ export async function runHostedCli(argv: string[], opts: HostedRunnerOptions = { return { handled: true, exitCode: EXIT_CODES.SUCCESS }; } catch (err) { if (err instanceof StreamWriteError) throw err; + if (err instanceof BrowserSessionArgvError) { + await writeToStream(stderr, `error: ${err.message}\n`); + return { handled: true, exitCode: EXIT_CODES.USAGE_ERROR }; + } if (err instanceof CliError && err.code === 'UNSUPPORTED_SHELL') throw err; if (err instanceof CommanderStructuralError) { await writeToStream(stderr, err.output); @@ -187,7 +192,7 @@ async function dispatchHosted( ); } if (args[0] === 'browser') { - const invocation = await parseHostedBrowserInvocation(args, normalized.profile); + const invocation = await parseHostedBrowserInvocation(args, normalized.profile, normalized.session); const manifest = await client.getManifest(); validateManifestContractIdentity(manifest); await dispatchHostedBrowser(invocation, client, stdout); @@ -398,6 +403,7 @@ async function dispatchHosted( format: parsed.format, trace: parsed.trace, profile: parsed.profile ?? normalized.profile, + session: normalized.session, }) : await client.execute({ command: command.command, @@ -405,6 +411,7 @@ async function dispatchHosted( format: parsed.format, trace: parsed.trace, profile: parsed.profile ?? normalized.profile, + session: normalized.session, }); let format: string = parsed.format; if (!parsed.formatExplicit && format === 'table' && command.defaultFormat) { @@ -446,6 +453,7 @@ async function executeHostedFileCommand(input: { format: string; trace: string; profile?: string; + session?: string; }): Promise { const prepared = await prepareHostedFiles({ client: input.client, @@ -459,6 +467,7 @@ async function executeHostedFileCommand(input: { format: input.format, trace: input.trace, ...(input.profile !== undefined ? { profile: input.profile } : {}), + ...(input.session !== undefined ? { session: input.session } : {}), }); const materialized = await materializeHostedOutputs({ client: input.client, @@ -541,33 +550,21 @@ function contentTypeForUpload(filePath: string): string { } } -async function parseHostedBrowserInvocation(argv: string[], profile: string | undefined): Promise { - let rewritten: string[]; - try { - rewritten = rewriteBrowserArgv(argv); - } catch (error) { - if (error instanceof BrowserSessionArgvError) { - throw new ConfigError(error.message, 'Use: webcmd browser '); - } - throw error; - } +async function parseHostedBrowserInvocation( + argv: string[], + profile: string | undefined, + session: string | undefined, +): Promise { let structure; try { - structure = parseHostedBrowserStructure(rewritten); + structure = parseHostedBrowserStructure(session === undefined ? argv : ['--session', session, ...argv]); } catch (error) { if (error instanceof HostedBrowserHelp) throw new CommanderCompatibleError('', 0, error.output); throw error; } - if (rewritten[0] !== 'browser') { + if (argv[0] !== 'browser') { throw new ConfigError('Hosted browser invocation must start with browser.'); } - if (!structure.session) { - throw new ConfigError( - ' is required for hosted browser commands.', - 'Use: webcmd browser ', - ); - } - if (!structure.commandName) { throw new ConfigError( 'Hosted browser command is required.', @@ -579,7 +576,7 @@ async function parseHostedBrowserInvocation(argv: string[], profile: string | un const parsed = parseBrowserLeaf(structure.commandName, structure.positionals, structure.options); const browserArgs = await materializeBrowserRunSource(parsed.commandName, parsed.args); return { - session: structure.session, + session: validateRawBrowserSession(structure.session, profile), command: `browser/${parsed.commandName}`, action: parsed.action, args: browserArgs, diff --git a/src/main.ts b/src/main.ts index 57fd60da..8879304e 100644 --- a/src/main.ts +++ b/src/main.ts @@ -178,13 +178,9 @@ if (getCompIdx !== -1) { process.exit(EXIT_CODES.SUCCESS); } -// Rewrite `webcmd browser ...` so commander (which -// can't combine a parent positional with subcommand dispatch) sees the internal -// `--session ` flag form. Also refuses the retired `webcmd browser -// --session foo ...` user form with a friendly usage error. -const { rewriteBrowserArgv, BrowserSessionArgvError, escapeLeadingDashPositional } = await import('./cli-argv-preprocess.js'); +const { rejectPositionalBrowserSessionArgv, BrowserSessionArgvError, escapeLeadingDashPositional } = await import('./cli-argv-preprocess.js'); try { - let rewritten = rewriteBrowserArgv(process.argv.slice(2)); + let rewritten = rejectPositionalBrowserSessionArgv(process.argv.slice(2)); // Use the metadata that discovery actually registered. The core manifest is // intentionally empty, while installed plugins and legacy user CLIs are not. const { getRegistry } = await import('./registry.js'); @@ -193,7 +189,7 @@ try { } catch (err) { if (err instanceof BrowserSessionArgvError) { process.stderr.write(`error: ${err.message}\n`); - process.exit(EXIT_CODES.GENERIC_ERROR); + process.exit(EXIT_CODES.USAGE_ERROR); } throw err; } diff --git a/src/root-command-surface.ts b/src/root-command-surface.ts index 5ef75c47..7f8278a1 100644 --- a/src/root-command-surface.ts +++ b/src/root-command-surface.ts @@ -4,6 +4,8 @@ import { PKG_VERSION } from './version.js'; export const ROOT_PROFILE_FLAGS = '--profile '; export const ROOT_PROFILE_DESCRIPTION = 'Chrome profile/context alias for browser runtime commands'; +export const ROOT_SESSION_FLAGS = '--session '; +export const ROOT_SESSION_DESCRIPTION = 'Existing opaque Session ID from `webcmd session create`'; export const COMPLETION_SENTINEL = '--get-completions'; /** @@ -15,6 +17,7 @@ export function configureRootCommandSurface(program: Command): Command { return program .version(PKG_VERSION) .option(ROOT_PROFILE_FLAGS, ROOT_PROFILE_DESCRIPTION) + .option(ROOT_SESSION_FLAGS, ROOT_SESSION_DESCRIPTION) .enablePositionalOptions(); } @@ -22,7 +25,7 @@ export type HostedRootCommandSurface = | { kind: 'help'; exitCode: number } | { kind: 'version'; output: string } | { kind: 'completion'; argv: string[] } - | { kind: 'dispatch'; argv: string[]; profile?: string; literal: boolean }; + | { kind: 'dispatch'; argv: string[]; profile?: string; session?: string; literal: boolean }; /** * Parse only the root command surface without registering or discovering local @@ -85,7 +88,7 @@ export function parseHostedRootCommandSurface(argv: readonly string[]): HostedRo throw new CommanderStructuralError(stderr || `${error.message}\n`, error.exitCode); } - const profile = root.opts<{ profile?: string }>().profile; + const { profile, session } = root.opts<{ profile?: string; session?: string }>(); if (boundary.commandIndex === undefined && boundary.separatorIndex === undefined) return { kind: 'help', exitCode: 1 }; const literal = boundary.separatorIndex !== undefined; const parsedArgv = boundary.commandIndex !== undefined @@ -96,6 +99,7 @@ export function parseHostedRootCommandSurface(argv: readonly string[]): HostedRo kind: 'dispatch', argv: parsedArgv, ...(profile !== undefined ? { profile } : {}), + ...(session !== undefined ? { session } : {}), literal, }; } @@ -109,7 +113,7 @@ interface RootCommandBoundary { function findRootCommandBoundary(argv: readonly string[]): RootCommandBoundary { for (let index = 0; index < argv.length; index += 1) { const token = argv[index]!; - if (token === '--profile' || token === '--workspace') { + if (token === '--profile' || token === '--session' || token === '--workspace') { // Commander requires and consumes the next token even when it is `--` or // starts with a dash. Structural failures have already been reported. // `--workspace` is hosted-only (not a registered Commander option here) @@ -118,7 +122,7 @@ function findRootCommandBoundary(argv: readonly string[]): RootCommandBoundary { index += 1; continue; } - if (token.startsWith('--profile=') || token.startsWith('--workspace=')) continue; + if (token.startsWith('--profile=') || token.startsWith('--session=') || token.startsWith('--workspace=')) continue; if (token === '--') return { separatorIndex: index }; if (!token.startsWith('-') || token === '-') return { commandIndex: index }; } From 85a945d9edc15e120adab6a65f8bf7c68c70227b Mon Sep 17 00:00:00 2001 From: beubax Date: Wed, 12 Aug 2026 01:13:04 +0530 Subject: [PATCH 02/27] feat: persist local browser sessions --- src/browser/protocol.ts | 9 +- src/browser/runtime/local-cloak/provider.ts | 32 ++- .../runtime/local-cloak/session-manager.ts | 23 ++ src/browser/runtime/provider.ts | 6 + src/browser/sessions.test.ts | 83 ++++++ src/browser/sessions.ts | 244 ++++++++++++++++++ src/cli.test.ts | 76 ++++++ src/cli.ts | 60 +++++ src/daemon/server.test.ts | 90 +++++++ src/daemon/server.ts | 73 +++++- vitest.config.ts | 2 +- 11 files changed, 685 insertions(+), 13 deletions(-) create mode 100644 src/browser/sessions.test.ts create mode 100644 src/browser/sessions.ts diff --git a/src/browser/protocol.ts b/src/browser/protocol.ts index b645d5c0..6a1c11af 100644 --- a/src/browser/protocol.ts +++ b/src/browser/protocol.ts @@ -1,4 +1,5 @@ import type { SessionLeaseStatus } from '../session-lease.js'; +import type { BrowserSessionListRow } from './sessions.js'; import type { SnapshotMode } from './snapshot/index.js'; export type BrowserRuntimeAction = @@ -18,7 +19,10 @@ export type BrowserRuntimeAction = | 'frames' | 'run' | 'snapshot' - | 'lease-release'; + | 'lease-release' + | 'session-create' + | 'session-list' + | 'session-close'; export type BrowserSurface = 'browser' | 'adapter'; export type SiteSessionMode = 'ephemeral' | 'persistent'; @@ -30,6 +34,8 @@ export interface BrowserRuntimeCommand { page?: string; code?: string; session?: string; + sessionId?: string; + sessionKind?: 'explicit' | 'adapter-default'; surface?: BrowserSurface; siteSession?: SiteSessionMode; /** Close any existing leased page and start on a new one (sent on the first action of a command run). */ @@ -111,4 +117,5 @@ export interface BrowserRuntimeStatus { commandResultUnknown?: number; /** Active local leases with internal run ownership tokens removed. */ sessionLeases?: SessionLeaseStatus[]; + sessions?: BrowserSessionListRow[]; } diff --git a/src/browser/runtime/local-cloak/provider.ts b/src/browser/runtime/local-cloak/provider.ts index 74b0f696..a4ca8d85 100644 --- a/src/browser/runtime/local-cloak/provider.ts +++ b/src/browser/runtime/local-cloak/provider.ts @@ -1,5 +1,6 @@ import type { BrowserRuntimeCommand, BrowserRuntimeResult, BrowserRuntimeStatus } from '../../protocol.js'; import type { BrowserRuntimeProvider, RuntimeStatusOptions } from '../provider.js'; +import { LocalBrowserSessionStore, type BrowserSessionListRow, type BrowserSessionRecord } from '../../sessions.js'; import { dispatchCloakAction, resolveCloakCommandProfileId } from './actions.js'; import type { LaunchPersistentContext } from './session-manager.js'; import { @@ -14,13 +15,15 @@ export interface LocalCloakRuntimeProviderOptions { export class LocalCloakRuntimeProvider implements BrowserRuntimeProvider { private readonly manager: CloakSessionManager; + private readonly sessions: LocalBrowserSessionStore; private readonly sessionQueues = new Map>(); constructor(private readonly opts: LocalCloakRuntimeProviderOptions = {}) { this.manager = new CloakSessionManager(opts); + this.sessions = new LocalBrowserSessionStore({ baseDir: opts.baseDir }); } - async status(_opts: RuntimeStatusOptions = {}): Promise { + async status(opts: RuntimeStatusOptions = {}): Promise { const profiles = this.manager.profileStatuses(); return { runtimeConnected: true, @@ -29,6 +32,7 @@ export class LocalCloakRuntimeProvider implements BrowserRuntimeProvider { profiles, pending: 0, commandResultUnknown: 0, + sessions: await this.listSessions({ profileId: opts.contextId }), }; } @@ -36,6 +40,32 @@ export class LocalCloakRuntimeProvider implements BrowserRuntimeProvider { return resolveCloakCommandProfileId(this.manager, command); } + async createSession(command: BrowserRuntimeCommand): Promise { + return this.sessions.create(this.resolveProfileId(command)); + } + + async requireSession(command: BrowserRuntimeCommand): Promise { + return this.sessions.require(this.resolveProfileId(command), command.session); + } + + async resolveAdapterDefault(command: BrowserRuntimeCommand): Promise { + return this.sessions.resolveAdapterDefault(this.resolveProfileId(command)); + } + + async listSessions(input: { profileId?: string }): Promise { + return this.sessions.list(input.profileId).map((session) => ({ + ...session, + runtimeState: this.manager.hasSession(session.profileId, session.id) ? 'active' : 'idle', + })); + } + + async closeSession(command: BrowserRuntimeCommand): Promise<{ closed: boolean; alreadyIdle: boolean; session: string }> { + const record = this.sessions.require(this.resolveProfileId(command), command.session); + const closedCount = await this.manager.closeSession(record.profileId, record.id); + this.sessions.touch(record.profileId, record.id); + return { closed: closedCount > 0, alreadyIdle: closedCount === 0, session: record.id }; + } + async dispatch(command: BrowserRuntimeCommand): Promise { const key = this.commandQueueKey(command); const previous = this.sessionQueues.get(key) ?? Promise.resolve(); diff --git a/src/browser/runtime/local-cloak/session-manager.ts b/src/browser/runtime/local-cloak/session-manager.ts index 644fb679..8ba0a13b 100644 --- a/src/browser/runtime/local-cloak/session-manager.ts +++ b/src/browser/runtime/local-cloak/session-manager.ts @@ -396,6 +396,29 @@ export class CloakSessionManager { } } + hasSession(profileIdInput: string | undefined, sessionInput: string | undefined): boolean { + const profileId = normalizeProfileId(profileIdInput); + const session = requireSession(sessionInput); + const runtime = this.profiles.get(profileId); + return Boolean(runtime && this.openEntries(runtime).some(([, entry]) => entry.session === session)); + } + + async closeSession(profileIdInput: string | undefined, sessionInput: string | undefined): Promise { + const profileId = normalizeProfileId(profileIdInput); + const session = requireSession(sessionInput); + const runtime = this.profiles.get(profileId); + if (!runtime) return 0; + const entries = this.openEntries(runtime).filter(([, entry]) => entry.session === session); + for (const [key, entry] of entries) { + runtime.pages.delete(key); + this.clearIdleTimer(entry); + if (runtime.selectedPageId === entry.pageId) runtime.selectedPageId = undefined; + if (!pageIsClosed(entry.page)) await entry.page.close().catch(() => {}); + } + if (entries.length > 0) runtime.lastSeenAt = Date.now(); + return entries.length; + } + async shutdown(): Promise { for (const runtime of this.profiles.values()) { for (const entry of runtime.pages.values()) this.clearIdleTimer(entry); diff --git a/src/browser/runtime/provider.ts b/src/browser/runtime/provider.ts index cb295b1f..b6dc6ebe 100644 --- a/src/browser/runtime/provider.ts +++ b/src/browser/runtime/provider.ts @@ -1,4 +1,5 @@ import type { BrowserRuntimeCommand, BrowserRuntimeResult, BrowserRuntimeStatus } from '../protocol.js'; +import type { BrowserSessionListRow, BrowserSessionRecord } from '../sessions.js'; export interface RuntimeStatusOptions { contextId?: string; @@ -7,6 +8,11 @@ export interface RuntimeStatusOptions { export interface BrowserRuntimeProvider { status(opts?: RuntimeStatusOptions): Promise; resolveProfileId?(command: BrowserRuntimeCommand): string; + createSession?(command: BrowserRuntimeCommand): Promise; + requireSession?(command: BrowserRuntimeCommand): Promise; + resolveAdapterDefault?(command: BrowserRuntimeCommand): Promise; + listSessions?(input: { profileId?: string }): Promise; + closeSession?(command: BrowserRuntimeCommand): Promise<{ closed: boolean; alreadyIdle: boolean; session: string }>; dispatch(command: BrowserRuntimeCommand): Promise; shutdown(): Promise; } diff --git a/src/browser/sessions.test.ts b/src/browser/sessions.test.ts new file mode 100644 index 00000000..2a9f76fd --- /dev/null +++ b/src/browser/sessions.test.ts @@ -0,0 +1,83 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { LocalBrowserSessionStore } from './sessions.js'; + +const tempDirs: string[] = []; + +function tempDir(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'webcmd-sessions-')); + tempDirs.push(dir); + return dir; +} + +afterEach(() => { + while (tempDirs.length) fs.rmSync(tempDirs.pop()!, { recursive: true, force: true }); +}); + +describe('LocalBrowserSessionStore', () => { + it('creates unique explicit sessions and persists them', () => { + const baseDir = tempDir(); + const store = new LocalBrowserSessionStore({ + baseDir, + now: () => new Date('2026-08-11T00:00:00.000Z'), + idFactory: () => 'session_11111111-1111-4111-8111-111111111111', + }); + + const created = store.create('profile_work'); + + expect(created).toMatchObject({ + id: 'session_11111111-1111-4111-8111-111111111111', + profileId: 'profile_work', + kind: 'explicit', + createdAt: '2026-08-11T00:00:00.000Z', + updatedAt: '2026-08-11T00:00:00.000Z', + lastUsedAt: '2026-08-11T00:00:00.000Z', + }); + expect(store.create('profile_work').id).not.toBe(created.id); + expect(new LocalBrowserSessionStore({ baseDir }).find('profile_work', created.id)?.id).toBe(created.id); + }); + + it('scopes lookup by profile and validates opaque ids', () => { + const store = new LocalBrowserSessionStore({ baseDir: tempDir(), idFactory: () => 'session_a' }); + const created = store.create('profile_work'); + + expect(() => store.require('profile_other', created.id)).toThrowError(expect.objectContaining({ code: 'SESSION_NOT_FOUND' })); + expect(() => store.find('profile_work', 'work')).toThrowError(expect.objectContaining({ code: 'INVALID_SESSION_SELECTOR' })); + }); + + it('resolves one lazy adapter-default per profile without list side effects', () => { + const store = new LocalBrowserSessionStore({ + baseDir: tempDir(), + idFactory: () => 'session_default', + }); + + expect(store.list('profile_work')).toEqual([]); + const adapterDefault = store.resolveAdapterDefault('profile_work'); + + expect(adapterDefault.kind).toBe('adapter-default'); + expect(store.resolveAdapterDefault('profile_work').id).toBe(adapterDefault.id); + expect(store.list('profile_work')).toHaveLength(1); + }); + + it('writes state atomically with private file mode', () => { + const baseDir = tempDir(); + const store = new LocalBrowserSessionStore({ baseDir, idFactory: () => 'session_private' }); + + store.create('profile_work'); + + const statePath = path.join(baseDir, 'browser-sessions.json'); + expect(fs.existsSync(statePath)).toBe(true); + expect(fs.statSync(statePath).mode & 0o777).toBe(0o600); + expect(fs.readdirSync(baseDir).filter((name) => name.includes('.tmp'))).toEqual([]); + }); + + it('fails closed on malformed persisted JSON', () => { + const baseDir = tempDir(); + fs.writeFileSync(path.join(baseDir, 'browser-sessions.json'), '{not json', { mode: 0o600 }); + + expect(() => new LocalBrowserSessionStore({ baseDir }).list('profile_work')) + .toThrowError(expect.objectContaining({ code: 'CONFIG' })); + }); +}); diff --git a/src/browser/sessions.ts b/src/browser/sessions.ts new file mode 100644 index 00000000..49671577 --- /dev/null +++ b/src/browser/sessions.ts @@ -0,0 +1,244 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { randomUUID } from 'node:crypto'; +import { CONFIG_DIR_NAME, ENV_PREFIX } from '../brand.js'; +import { CliError, ConfigError, EXIT_CODES } from '../errors.js'; + +export interface BrowserSessionRecord { + id: string; + profileId: string; + kind: 'explicit' | 'adapter-default'; + createdAt: string; + updatedAt: string; + lastUsedAt: string; + handoff?: { site: string; expiresAt: string }; +} + +export interface BrowserSessionListRow extends BrowserSessionRecord { + runtimeState: 'idle' | 'active'; +} + +export interface LocalBrowserSessionStoreOptions { + baseDir?: string; + now?: () => Date; + idFactory?: () => string; +} + +type StateFile = { version: 1; sessions: BrowserSessionRecord[] }; + +export class SessionNotFoundError extends CliError { + constructor(sessionId: string, profileId: string) { + super( + 'SESSION_NOT_FOUND', + `Session not found: ${sessionId}`, + `Run \`webcmd --profile ${profileId} session list\` to choose an existing Session.`, + EXIT_CODES.EMPTY_RESULT, + ); + } +} + +export class InvalidSessionSelectorError extends CliError { + constructor(sessionId: string) { + super( + 'INVALID_SESSION_SELECTOR', + `Session selector must be an opaque Session ID: ${sessionId}`, + 'Run `webcmd session create` and pass the returned `session_...` ID.', + EXIT_CODES.USAGE_ERROR, + ); + } +} + +export class LocalBrowserSessionStore { + private readonly baseDir: string; + private readonly now: () => Date; + private readonly idFactory: () => string; + + constructor(opts: LocalBrowserSessionStoreOptions = {}) { + this.baseDir = opts.baseDir ?? getWebcmdConfigDir(); + this.now = opts.now ?? (() => new Date()); + this.idFactory = opts.idFactory ?? (() => `session_${randomUUID()}`); + } + + create(profileId: string): BrowserSessionRecord { + const state = this.load(); + const record = this.newRecord(profileId, 'explicit', state.sessions); + state.sessions.push(record); + this.save(state); + return { ...record }; + } + + find(profileId: string, sessionId: string): BrowserSessionRecord | undefined { + requireSessionIdShape(sessionId); + const state = this.load(); + const record = state.sessions.find((row) => row.id === sessionId && row.profileId === profileId); + return record ? { ...record } : undefined; + } + + require(profileId: string, sessionId: string | undefined): BrowserSessionRecord { + const id = sessionId?.trim() ?? ''; + requireSessionIdShape(id); + const state = this.load(); + const record = state.sessions.find((row) => row.id === id && row.profileId === profileId); + if (!record) throw new SessionNotFoundError(id, profileId); + this.touchRecord(state, record); + return { ...record }; + } + + resolveAdapterDefault(profileId: string): BrowserSessionRecord { + const state = this.load(); + const existing = state.sessions.find((row) => row.profileId === profileId && row.kind === 'adapter-default'); + if (existing) { + this.touchRecord(state, existing); + return { ...existing }; + } + const record = this.newRecord(profileId, 'adapter-default', state.sessions); + state.sessions.push(record); + this.save(state); + return { ...record }; + } + + list(profileId?: string): BrowserSessionListRow[] { + const rows = this.load().sessions + .filter((row) => profileId === undefined || row.profileId === profileId); + return rows.map((row) => ({ ...row, runtimeState: 'idle' as const })); + } + + markHandoff(profileId: string, sessionId: string, handoff: { site: string; expiresAt: string }): BrowserSessionRecord { + const state = this.load(); + const record = this.requireMutable(state, profileId, sessionId); + record.handoff = handoff; + this.touchRecord(state, record); + return { ...record }; + } + + clearHandoff(profileId: string, sessionId: string): BrowserSessionRecord { + const state = this.load(); + const record = this.requireMutable(state, profileId, sessionId); + delete record.handoff; + this.touchRecord(state, record); + return { ...record }; + } + + touch(profileId: string, sessionId: string): BrowserSessionRecord { + const state = this.load(); + const record = this.requireMutable(state, profileId, sessionId); + this.touchRecord(state, record); + return { ...record }; + } + + private newRecord( + profileId: string, + kind: BrowserSessionRecord['kind'], + existing: BrowserSessionRecord[], + ): BrowserSessionRecord { + const timestamp = this.now().toISOString(); + const id = this.uniqueId(existing); + return { id, profileId, kind, createdAt: timestamp, updatedAt: timestamp, lastUsedAt: timestamp }; + } + + private uniqueId(existing: BrowserSessionRecord[]): string { + const used = new Set(existing.map((row) => row.id)); + const first = this.idFactory(); + if (!used.has(first)) { + requireSessionIdShape(first); + return first; + } + let candidate = `session_${randomUUID()}`; + while (used.has(candidate)) candidate = `session_${randomUUID()}`; + return candidate; + } + + private touchRecord(state: StateFile, record: BrowserSessionRecord): void { + const timestamp = this.now().toISOString(); + record.updatedAt = timestamp; + record.lastUsedAt = timestamp; + this.save(state); + } + + private requireMutable(state: StateFile, profileId: string, sessionId: string): BrowserSessionRecord { + requireSessionIdShape(sessionId); + const record = state.sessions.find((row) => row.id === sessionId && row.profileId === profileId); + if (!record) throw new SessionNotFoundError(sessionId, profileId); + return record; + } + + private load(): StateFile { + const file = this.statePath(); + if (!fs.existsSync(file)) return { version: 1, sessions: [] }; + let parsed: unknown; + try { + parsed = JSON.parse(fs.readFileSync(file, 'utf8')); + } catch (error) { + throw new ConfigError(`Could not read browser sessions: ${error instanceof Error ? error.message : String(error)}`); + } + return validateState(parsed); + } + + private save(state: StateFile): void { + fs.mkdirSync(this.baseDir, { recursive: true }); + const target = this.statePath(); + const tmp = path.join(this.baseDir, `.browser-sessions.${process.pid}.${randomUUID()}.tmp`); + fs.writeFileSync(tmp, `${JSON.stringify(state, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 }); + fs.renameSync(tmp, target); + fs.chmodSync(target, 0o600); + } + + private statePath(): string { + return path.join(this.baseDir, 'browser-sessions.json'); + } +} + +function validateState(value: unknown): StateFile { + if (!value || typeof value !== 'object') throw new ConfigError('browser-sessions.json must contain an object.'); + const state = value as { version?: unknown; sessions?: unknown }; + if (state.version !== 1 || !Array.isArray(state.sessions)) { + throw new ConfigError('browser-sessions.json has an unsupported schema.'); + } + const adapterDefaults = new Set(); + const sessions = state.sessions.map((row) => validateRecord(row, adapterDefaults)); + return { version: 1, sessions }; +} + +function validateRecord(value: unknown, adapterDefaults: Set): BrowserSessionRecord { + if (!value || typeof value !== 'object') throw new ConfigError('browser-sessions.json contains an invalid Session record.'); + const row = value as Partial; + if (typeof row.id !== 'string') throw new ConfigError('browser-sessions.json contains a Session without an id.'); + requireSessionIdShape(row.id); + if (typeof row.profileId !== 'string' || !row.profileId.trim()) throw new ConfigError('browser-sessions.json contains a Session without a profileId.'); + if (row.kind !== 'explicit' && row.kind !== 'adapter-default') throw new ConfigError('browser-sessions.json contains an invalid Session kind.'); + const createdAt = row.createdAt; + const updatedAt = row.updatedAt; + const lastUsedAt = row.lastUsedAt; + if (typeof createdAt !== 'string' || Number.isNaN(Date.parse(createdAt))) { + throw new ConfigError('browser-sessions.json contains an invalid createdAt.'); + } + if (typeof updatedAt !== 'string' || Number.isNaN(Date.parse(updatedAt))) { + throw new ConfigError('browser-sessions.json contains an invalid updatedAt.'); + } + if (typeof lastUsedAt !== 'string' || Number.isNaN(Date.parse(lastUsedAt))) { + throw new ConfigError('browser-sessions.json contains an invalid lastUsedAt.'); + } + if (row.kind === 'adapter-default') { + const key = row.profileId; + if (adapterDefaults.has(key)) throw new ConfigError(`browser-sessions.json contains multiple adapter-default Sessions for ${key}.`); + adapterDefaults.add(key); + } + return { + id: row.id, + profileId: row.profileId, + kind: row.kind, + createdAt, + updatedAt, + lastUsedAt, + ...(row.handoff ? { handoff: row.handoff } : {}), + }; +} + +export function requireSessionIdShape(sessionId: string): void { + if (!/^session_[A-Za-z0-9_-]+$/u.test(sessionId)) throw new InvalidSessionSelectorError(sessionId); +} + +function getWebcmdConfigDir(): string { + return process.env[`${ENV_PREFIX}_CONFIG_DIR`] || path.join(os.homedir(), CONFIG_DIR_NAME); +} diff --git a/src/cli.test.ts b/src/cli.test.ts index 01edc156..af1abd71 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -1683,6 +1683,82 @@ describe('browser raw session commands', () => { }); }); +describe('browser Session lifecycle commands', () => { + const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + beforeEach(() => { + process.exitCode = undefined; + consoleLogSpy.mockClear(); + mockSendCommand.mockReset(); + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('daemon offline'))); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('creates a Session through the daemon mutation path', async () => { + mockSendCommand.mockResolvedValue({ + id: 'session_abc', + kind: 'explicit', + profileId: 'default', + runtimeState: 'idle', + }); + + await createProgram('', '').parseAsync(['node', 'webcmd', 'session', 'create']); + + expect(mockSendCommand).toHaveBeenCalledWith('session-create', { contextId: 'default' }); + expect(consoleLogSpy.mock.calls.flat().join('\n')).toContain('session_abc'); + }); + + it('lists persisted Sessions without creating the adapter default when daemon is absent', async () => { + const baseDir = path.join(isolatedCliTestHome, '.webcmd'); + fs.mkdirSync(baseDir, { recursive: true }); + fs.writeFileSync(path.join(baseDir, 'browser-sessions.json'), JSON.stringify({ + version: 1, + sessions: [{ + id: 'session_existing', + profileId: 'default', + kind: 'explicit', + createdAt: '2026-08-11T00:00:00.000Z', + updatedAt: '2026-08-11T00:00:00.000Z', + lastUsedAt: '2026-08-11T00:00:00.000Z', + }], + }), { mode: 0o600 }); + + await createProgram('', '').parseAsync(['node', 'webcmd', 'session', 'list', '-f', 'json']); + + expect(mockSendCommand).not.toHaveBeenCalled(); + const rows = JSON.parse(consoleLogSpy.mock.calls.flat().join('\n')); + expect(rows).toEqual([expect.objectContaining({ id: 'session_existing', runtimeState: 'idle' })]); + }); + + it('closes an idle persisted Session as a no-op when daemon is absent', async () => { + const baseDir = path.join(isolatedCliTestHome, '.webcmd'); + fs.mkdirSync(baseDir, { recursive: true }); + fs.writeFileSync(path.join(baseDir, 'browser-sessions.json'), JSON.stringify({ + version: 1, + sessions: [{ + id: 'session_idle', + profileId: 'default', + kind: 'explicit', + createdAt: '2026-08-11T00:00:00.000Z', + updatedAt: '2026-08-11T00:00:00.000Z', + lastUsedAt: '2026-08-11T00:00:00.000Z', + }], + }), { mode: 0o600 }); + + await createProgram('', '').parseAsync(['node', 'webcmd', 'session', 'close', 'session_idle', '-f', 'json']); + + expect(mockSendCommand).not.toHaveBeenCalled(); + expect(JSON.parse(consoleLogSpy.mock.calls.flat().join('\n'))).toMatchObject({ + closed: false, + alreadyIdle: true, + session: 'session_idle', + }); + }); +}); + // Shared helper for the selector-first describe blocks below. // Each block spies console.log, mocks the IPage surface it touches, and // parses the last stringified call to inspect the JSON envelope — the diff --git a/src/cli.ts b/src/cli.ts index bc3988f3..7a5ad123 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -48,6 +48,7 @@ import type { BrowserDownloadWaitResult, IPage, ScreenshotOptions } from './type import type { BrowserWindowMode } from './runtime.js'; import { configureRootCommandSurface } from './root-command-surface.js'; import { validateRawBrowserSession } from './hosted/browser-args.js'; +import { LocalBrowserSessionStore, requireSessionIdShape, type BrowserSessionListRow } from './browser/sessions.js'; import { missingPluginGuidance, PLUGINS_DIR } from './discovery.js'; import { loadBrowserRunSource } from './browser/run/input.js'; import { BrowserRunError } from './browser/run/types.js'; @@ -548,6 +549,14 @@ function getBrowserProfileSelection(command?: Command): ProfileSelection | undef return resolveProfileSelection(typeof raw === 'string' && raw.trim() ? raw.trim() : undefined); } +function getSelectedProfileId(command?: Command): string { + return getBrowserProfileSelection(command)?.contextId ?? 'default'; +} + +function formatHandoff(row: BrowserSessionListRow): string { + return row.handoff ? `${row.handoff.site} until ${row.handoff.expiresAt}` : ''; +} + function applyVerbose(opts: { verbose?: boolean }): void { if (opts.verbose) process.env.WEBCMD_VERBOSE = '1'; } @@ -782,6 +791,57 @@ export function createProgram(BUILTIN_CLIS: string, USER_CLIS: string, pluginsDi if (opts.strict && !report.ok) process.exitCode = EXIT_CODES.GENERIC_ERROR; }); + const sessionCmd = program.command('session').description('Create, list, and close browser Sessions'); + + sessionCmd + .command('create') + .description('Create a new opaque browser Session ID for the selected Profile') + .option('-f, --format ', 'Output format: table, json, yaml', 'yaml') + .action(async (opts, command) => { + const profileId = getSelectedProfileId(command); + const data = await sendCommand('session-create', { contextId: profileId }); + await renderOutput(data, { fmt: opts.format, columns: ['id', 'kind', 'profileId'] }); + }); + + sessionCmd + .command('list') + .description('List browser Sessions for the selected Profile') + .option('-f, --format ', 'Output format: table, json, yaml', 'table') + .action(async (opts, command) => { + const profileId = getSelectedProfileId(command); + let rows: BrowserSessionListRow[]; + const status = await fetchDaemonStatus({ contextId: profileId }); + if (status?.runtimeConnected && !isDaemonStale(status, PKG_VERSION)) { + rows = await sendCommand('session-list', { contextId: profileId }) as BrowserSessionListRow[]; + } else { + rows = new LocalBrowserSessionStore().list(profileId); + } + const output = rows.map((row) => ({ ...row, handoff: formatHandoff(row) })); + if (output.length === 0 && String(opts.format ?? 'table') === 'table') { + console.log(`No browser Sessions found for Profile ${profileId}.`); + return; + } + await renderOutput(output, { fmt: opts.format, columns: ['id', 'kind', 'runtimeState', 'handoff'] }); + }); + + sessionCmd + .command('close') + .description('Close a browser Session runtime without deleting its durable record') + .argument('', 'Existing opaque Session ID from `webcmd session create`') + .option('-f, --format ', 'Output format: table, json, yaml', 'yaml') + .action(async (sessionId: string, opts, command) => { + const profileId = getSelectedProfileId(command); + requireSessionIdShape(sessionId); + const status = await fetchDaemonStatus({ contextId: profileId }); + if (status?.runtimeConnected && !isDaemonStale(status, PKG_VERSION)) { + const data = await sendCommand('session-close', { contextId: profileId, session: sessionId }); + await renderOutput(data, { fmt: opts.format }); + return; + } + new LocalBrowserSessionStore().require(profileId, sessionId); + await renderOutput({ closed: false, alreadyIdle: true, session: sessionId }, { fmt: opts.format }); + }); + // ── Built-in: browser (browser control for Claude Code skill) ─────────────── // // Make websites accessible for AI agents. diff --git a/src/daemon/server.test.ts b/src/daemon/server.test.ts index d3bd5c8e..a30b93c4 100644 --- a/src/daemon/server.test.ts +++ b/src/daemon/server.test.ts @@ -2,15 +2,19 @@ import { AddressInfo } from 'node:net'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { DAEMON_HEADER_NAME } from '../constants.js'; import type { BrowserRuntimeCommand, BrowserRuntimeResult, BrowserRuntimeStatus } from '../browser/protocol.js'; +import type { BrowserSessionListRow, BrowserSessionRecord } from '../browser/sessions.js'; import type { BrowserRuntimeProvider } from '../browser/runtime/provider.js'; import { createDaemonServer } from './server.js'; class FakeProvider implements BrowserRuntimeProvider { commands: BrowserRuntimeCommand[] = []; + sessions: BrowserSessionRecord[] = []; + activeSessions = new Set(); shutdownCalled = false; delayMs = 0; dispatchImpl?: (command: BrowserRuntimeCommand) => Promise; resolveProfileId?: (command: BrowserRuntimeCommand) => string; + sessionId = 'session_11111111-1111-4111-8111-111111111111'; private result(command: BrowserRuntimeCommand) { return { id: command.id, ok: true as const, data: { action: command.action }, page: 'page-1' }; @@ -24,9 +28,52 @@ class FakeProvider implements BrowserRuntimeProvider { profiles: [{ contextId: 'default', runtimeConnected: true, runtimeVersion: '1.2.3', pending: 0 }], pending: 0, commandResultUnknown: 0, + sessions: this.listSessionRows(), }; } + async createSession(command: BrowserRuntimeCommand): Promise { + const profileId = command.contextId ?? 'default'; + const session = { + id: this.sessionId, + profileId, + kind: 'explicit' as const, + createdAt: '2026-08-11T00:00:00.000Z', + updatedAt: '2026-08-11T00:00:00.000Z', + lastUsedAt: '2026-08-11T00:00:00.000Z', + }; + this.sessions.push(session); + return session; + } + + async listSessions(input: { profileId?: string }): Promise { + return this.listSessionRows(input.profileId); + } + + async closeSession(command: BrowserRuntimeCommand): Promise<{ closed: boolean; alreadyIdle: boolean; session: string }> { + const session = String(command.session); + const wasActive = this.activeSessions.delete(session); + return { closed: wasActive, alreadyIdle: !wasActive, session }; + } + + async requireSession(command: BrowserRuntimeCommand): Promise { + const profileId = command.contextId ?? 'default'; + const existing = this.sessions.find((session) => session.profileId === profileId && session.id === command.session); + if (existing) return existing; + return { + id: String(command.session), + profileId, + kind: 'explicit', + createdAt: '2026-08-11T00:00:00.000Z', + updatedAt: '2026-08-11T00:00:00.000Z', + lastUsedAt: '2026-08-11T00:00:00.000Z', + }; + } + + async resolveAdapterDefault(command: BrowserRuntimeCommand): Promise { + return this.requireSession({ ...command, session: command.session ?? 'session_default' }); + } + async dispatch(command: BrowserRuntimeCommand) { this.commands.push(command); if (this.dispatchImpl) return this.dispatchImpl(command); @@ -37,6 +84,15 @@ class FakeProvider implements BrowserRuntimeProvider { async shutdown() { this.shutdownCalled = true; } + + private listSessionRows(profileId?: string): BrowserSessionListRow[] { + return this.sessions + .filter((session) => profileId === undefined || session.profileId === profileId) + .map((session) => ({ + ...session, + runtimeState: this.activeSessions.has(session.id) ? 'active' as const : 'idle' as const, + })); + } } describe('createDaemonServer', () => { @@ -115,6 +171,40 @@ describe('createDaemonServer', () => { expect(provider.commands[0]).toMatchObject({ id: 'cmd-1', action: 'navigate', session: 'work' }); }); + it('handles local Session lifecycle controls outside normal dispatch', async () => { + const { provider, baseUrl } = await start(); + + const created = await postCommand(baseUrl, { id: 'create-session', action: 'session-create' as BrowserRuntimeCommand['action'], contextId: 'profile_work' }); + expect(created.status).toBe(200); + await expect(created.json()).resolves.toMatchObject({ + ok: true, + data: { id: provider.sessionId, profileId: 'profile_work', kind: 'explicit' }, + }); + + provider.activeSessions.add(provider.sessionId); + const listed = await postCommand(baseUrl, { id: 'list-sessions', action: 'session-list' as BrowserRuntimeCommand['action'], contextId: 'profile_work' }); + expect(listed.status).toBe(200); + await expect(listed.json()).resolves.toMatchObject({ + ok: true, + data: [{ id: provider.sessionId, runtimeState: 'active' }], + }); + + const closed = await postCommand(baseUrl, { id: 'close-session', action: 'session-close' as BrowserRuntimeCommand['action'], contextId: 'profile_work', session: provider.sessionId }); + expect(closed.status).toBe(200); + await expect(closed.json()).resolves.toMatchObject({ + ok: true, + data: { closed: true, alreadyIdle: false, session: provider.sessionId }, + }); + + const closedAgain = await postCommand(baseUrl, { id: 'close-session-again', action: 'session-close' as BrowserRuntimeCommand['action'], contextId: 'profile_work', session: provider.sessionId }); + expect(closedAgain.status).toBe(200); + await expect(closedAgain.json()).resolves.toMatchObject({ + ok: true, + data: { closed: false, alreadyIdle: true, session: provider.sessionId }, + }); + expect(provider.commands).toEqual([]); + }); + it('accepts the maximum browser-run source envelope', async () => { const { provider, baseUrl } = await start(); const source = 'x'.repeat(256 * 1024); diff --git a/src/daemon/server.ts b/src/daemon/server.ts index 30b9a085..4efbe302 100644 --- a/src/daemon/server.ts +++ b/src/daemon/server.ts @@ -4,6 +4,7 @@ import type { BrowserRuntimeCommand, BrowserRuntimeResult } from '../browser/pro import type { BrowserRuntimeProvider } from '../browser/runtime/provider.js'; import { buildCommandTimeoutFailure, getResponseCorsHeaders } from '../daemon-utils.js'; import { getSessionLeaseKey, isSessionLeaseCommand, SessionLeaseRegistry } from '../session-lease.js'; +import type { BrowserSessionRecord } from '../browser/sessions.js'; const MAX_BODY = 1024 * 1024; const LOG_BUFFER_SIZE = 200; @@ -58,6 +59,55 @@ function waitForCommandResult( }); } +const SESSION_LIFECYCLE_ACTIONS = new Set([ + 'session-create', + 'session-list', + 'session-close', +]); + +function commandProfileId(provider: BrowserRuntimeProvider, command: BrowserRuntimeCommand): string | undefined { + return provider.resolveProfileId?.(command) + ?? command.profileId + ?? command.contextId + ?? command.preferredContextId; +} + +async function resolveBrowserSession( + provider: BrowserRuntimeProvider, + command: BrowserRuntimeCommand, +): Promise { + if (command.action === 'lease-release' || SESSION_LIFECYCLE_ACTIONS.has(command.action)) return command; + let session: BrowserSessionRecord | undefined; + if (command.surface === 'adapter' && !command.session) { + session = await provider.resolveAdapterDefault?.(command); + } else { + session = await provider.requireSession?.(command); + } + return session ? { ...command, session: session.id, sessionId: session.id, sessionKind: session.kind } : command; +} + +async function handleSessionLifecycle( + provider: BrowserRuntimeProvider, + command: BrowserRuntimeCommand, +): Promise { + switch (command.action) { + case 'session-create': { + const session = await provider.createSession?.(command); + return session ? { id: command.id, ok: true, data: session } : null; + } + case 'session-list': { + const sessions = await provider.listSessions?.({ profileId: commandProfileId(provider, command) }); + return sessions ? { id: command.id, ok: true, data: sessions } : null; + } + case 'session-close': { + const closed = await provider.closeSession?.(command); + return closed ? { id: command.id, ok: true, data: closed } : null; + } + default: + return null; + } +} + function readBody(req: IncomingMessage): Promise { return new Promise((resolve, reject) => { const chunks: Buffer[] = []; @@ -198,21 +248,24 @@ export function createDaemonServer(provider: BrowserRuntimeProvider, opts: Daemo jsonResponse(res, 200, { id: body.id, ok: true, data: { released } }); return; } + const lifecycleResult = await handleSessionLifecycle(provider, body); + if (lifecycleResult) { + jsonResponse(res, 200, lifecycleResult); + return; + } + const resolvedBody = await resolveBrowserSession(provider, body); let leaseKey: string | undefined; let runId: string | undefined; - if (isSessionLeaseCommand(body)) { - const profileId = provider.resolveProfileId?.(body) - ?? body.profileId - ?? body.contextId - ?? body.preferredContextId + if (isSessionLeaseCommand(resolvedBody)) { + const profileId = commandProfileId(provider, resolvedBody) ?? 'default'; - leaseKey = getSessionLeaseKey(profileId, body.surface, body.session); - runId = body.runId; + leaseKey = getSessionLeaseKey(profileId, resolvedBody.surface, resolvedBody.session); + runId = resolvedBody.runId; const acquired = leases.acquire({ key: leaseKey, runId, - command: body.command ?? body.action, - pid: body.pid, + command: resolvedBody.command ?? resolvedBody.action, + pid: resolvedBody.pid, }, hasPendingWork); if (!acquired.acquired) { const { key: _key, runId: _runId, ...holder } = acquired.holder; @@ -220,7 +273,7 @@ export function createDaemonServer(provider: BrowserRuntimeProvider, opts: Daemo return; } } - const commandPromise = provider.dispatch(body).finally(() => { + const commandPromise = provider.dispatch(resolvedBody).finally(() => { if (leaseKey && runId) leases.heartbeat(leaseKey, runId); pending.delete(body.id); }); diff --git a/vitest.config.ts b/vitest.config.ts index d264b1e0..0cffd8b6 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -22,7 +22,7 @@ export default defineConfig({ { test: { name: 'unit', - include: ['src/*.test.ts', 'src/!(browser)/**/*.test.ts', 'src/browser/verify-fixture.test.ts'], + include: ['src/*.test.ts', 'src/!(browser)/**/*.test.ts', 'src/browser/verify-fixture.test.ts', 'src/browser/sessions.test.ts'], sequence: { groupOrder: 0 }, }, }, From 821621b1654b97cff2b294b9160882d2fe979317 Mon Sep 17 00:00:00 2001 From: beubax Date: Wed, 12 Aug 2026 01:19:53 +0530 Subject: [PATCH 03/27] feat: admit local browser work by session --- src/browser/bridge.ts | 5 +-- src/browser/daemon-client.test.ts | 3 -- src/browser/daemon-client.ts | 1 - src/browser/page.ts | 8 ++--- src/cli.test.ts | 22 ++++++++++++ src/cli.ts | 11 ++++-- src/daemon/server.test.ts | 13 ++++++- src/daemon/server.ts | 2 +- src/execution.test.ts | 30 +++++++++------- src/execution.ts | 20 +++-------- src/session-lease.test.ts | 57 ++++++++++++++++++------------- src/session-lease.ts | 30 ++++++---------- vitest.config.ts | 8 ++++- 13 files changed, 125 insertions(+), 85 deletions(-) diff --git a/src/browser/bridge.ts b/src/browser/bridge.ts index 392a0dfd..d97bb60a 100644 --- a/src/browser/bridge.ts +++ b/src/browser/bridge.ts @@ -38,8 +38,9 @@ export class BrowserBridge implements IBrowserFactory { ? { contextId: opts.contextId, preferredContextId: opts.preferredContextId } : profileRouteParams(resolveProfileSelection()); await this._ensureDaemon(opts.timeout, routing.contextId); - if (!opts.session?.trim()) throw new Error('Browser session is required'); - this._page = new Page(opts.session.trim(), opts.idleTimeout, routing.contextId, opts.windowMode, opts.surface, opts.siteSession, routing.preferredContextId, opts.freshPage); + const session = opts.session?.trim(); + if (!session && opts.surface !== 'adapter') throw new Error('Browser session is required'); + this._page = new Page(session, opts.idleTimeout, routing.contextId, opts.windowMode, opts.surface, opts.siteSession, routing.preferredContextId, opts.freshPage); this._state = 'connected'; return this._page; } catch (err) { diff --git a/src/browser/daemon-client.test.ts b/src/browser/daemon-client.test.ts index d37afbda..b469f9b8 100644 --- a/src/browser/daemon-client.test.ts +++ b/src/browser/daemon-client.test.ts @@ -205,7 +205,6 @@ describe('daemon-client', () => { setDaemonRunContext({ runId: 'run_4242_1000_1', command: 'example write', - access: 'write', }); vi.mocked(fetch).mockResolvedValue({ status: 200, @@ -218,7 +217,6 @@ describe('daemon-client', () => { expect(body).toMatchObject({ runId: 'run_4242_1000_1', command: 'example write', - access: 'write', pid: process.pid, }); }); @@ -323,7 +321,6 @@ describe('daemon-client', () => { setDaemonRunContext({ runId: 'run_9999_newer_2', command: 'newer write', - access: 'write', }); const ensureSpy = vi.spyOn(daemonLifecycle, 'ensureBrowserBridgeReady'); vi.mocked(fetch).mockRejectedValueOnce(new TypeError('fetch failed')); diff --git a/src/browser/daemon-client.ts b/src/browser/daemon-client.ts index b69771b0..4158540d 100644 --- a/src/browser/daemon-client.ts +++ b/src/browser/daemon-client.ts @@ -166,7 +166,6 @@ async function sendCommandRaw( ...(run && { runId: run.runId, command: run.command, - access: run.access, pid: process.pid, }), }; diff --git a/src/browser/page.ts b/src/browser/page.ts index 927de36d..25445b09 100644 --- a/src/browser/page.ts +++ b/src/browser/page.ts @@ -43,7 +43,7 @@ export class Page extends BasePage { private readonly _idleTimeout: number | undefined; constructor( - private readonly session: string, + private readonly session: string | undefined, idleTimeout?: number, public readonly contextId?: string, private readonly windowMode?: 'foreground' | 'background', @@ -72,10 +72,10 @@ export class Page extends BasePage { private _networkCaptureWarned = false; /** Helper: spread session into command params */ - private _sessionOpts(): { session: string; surface: 'browser' | 'adapter'; idleTimeout?: number; contextId?: string; preferredContextId?: string; windowMode?: 'foreground' | 'background'; siteSession?: 'ephemeral' | 'persistent' } { + private _sessionOpts(): { session?: string; surface: 'browser' | 'adapter'; idleTimeout?: number; contextId?: string; preferredContextId?: string; windowMode?: 'foreground' | 'background'; siteSession?: 'ephemeral' | 'persistent' } { return { - session: this.session, surface: this.surface, + ...(this.session && { session: this.session }), ...(this.contextId && { contextId: this.contextId }), ...(this.preferredContextId && { preferredContextId: this.preferredContextId }), ...(this._idleTimeout != null && { idleTimeout: this._idleTimeout }), @@ -88,8 +88,8 @@ export class Page extends BasePage { /** Helper: spread session + page identity into command params */ private _cmdOpts(): Record { return { - session: this.session, surface: this.surface, + ...(this.session && { session: this.session }), ...(this.contextId && { contextId: this.contextId }), ...(this.preferredContextId && { preferredContextId: this.preferredContextId }), ...(this._page !== undefined && { page: this._page }), diff --git a/src/cli.test.ts b/src/cli.test.ts index af1abd71..c37b5026 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -5,6 +5,7 @@ import * as path from 'node:path'; import yaml from 'js-yaml'; import { cli, getRegistry, runWithDiscoverySource, Strategy } from './registry.js'; import { BrowserCommandError } from './browser/daemon-client.js'; +import { getDaemonRunContext } from './session-lease.js'; import type { IPage } from './types.js'; import { TargetError } from './browser/target-errors.js'; import { PKG_VERSION } from './version.js'; @@ -23,6 +24,7 @@ const { mockBrowserClose, mockBindTab, mockListExistingBrowserTabs, + mockReleaseSiteSessionLease, mockSendCommand, mockExecFileSync, browserState, @@ -31,6 +33,7 @@ const { mockBrowserClose: vi.fn(), mockBindTab: vi.fn(), mockListExistingBrowserTabs: vi.fn(), + mockReleaseSiteSessionLease: vi.fn(), mockSendCommand: vi.fn(), mockExecFileSync: vi.fn(), browserState: { page: null as IPage | null }, @@ -51,6 +54,7 @@ vi.mock('./browser/daemon-client.js', async () => { ...actual, bindTab: mockBindTab, listExistingBrowserTabs: mockListExistingBrowserTabs, + releaseSiteSessionLease: mockReleaseSiteSessionLease, sendCommand: mockSendCommand, }; }); @@ -1590,6 +1594,7 @@ describe('browser raw session commands', () => { stderrSpy.mockClear(); mockBrowserConnect.mockClear(); mockListExistingBrowserTabs.mockReset().mockResolvedValue([]); + mockReleaseSiteSessionLease.mockReset().mockResolvedValue(undefined); mockSendCommand.mockReset().mockResolvedValue({ ok: true }); }); @@ -1652,6 +1657,23 @@ describe('browser raw session commands', () => { }); }); + it('binds raw browser daemon operations to one logical run', async () => { + let run = getDaemonRunContext(); + mockSendCommand.mockImplementation(async () => { + run = getDaemonRunContext(); + return { ok: true }; + }); + const program = createProgram('', ''); + + await program.parseAsync(['node', 'webcmd', '--session', 'session_test', 'browser', 'snapshot']); + + expect(run).toMatchObject({ + runId: expect.stringMatching(/^run_/), + command: 'browser/snapshot', + }); + expect(getDaemonRunContext()).toBeUndefined(); + }); + it('reads program files for run and rejects mutually exclusive input', async () => { const sourcePath = path.join(os.tmpdir(), `webcmd-run-${Date.now()}.js`); fs.writeFileSync(sourcePath, 'return 42;', 'utf8'); diff --git a/src/cli.ts b/src/cli.ts index 7a5ad123..7a9f23bf 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -38,7 +38,7 @@ import { browserOptionValueParser } from './browser/command-catalog.js'; import { registerAuthCommands } from './commands/auth.js'; import { daemonRestart, daemonStatus, daemonStop } from './commands/daemon.js'; import { isVerbose, log } from './logger.js'; -import { BrowserCommandError, listExistingBrowserTabs, sendCommand } from './browser/daemon-client.js'; +import { BrowserCommandError, listExistingBrowserTabs, releaseSiteSessionLease, sendCommand } from './browser/daemon-client.js'; import { fetchDaemonStatus } from './browser/daemon-transport.js'; import { aliasForContextId, loadProfileConfig, profileRouteParams, renameProfile, resolveProfileSelection, setDefaultProfile, type ProfileSelection } from './browser/profile.js'; import { formatDaemonVersion, isDaemonStale } from './browser/daemon-version.js'; @@ -54,6 +54,7 @@ import { loadBrowserRunSource } from './browser/run/input.js'; import { BrowserRunError } from './browser/run/types.js'; import { classifyCommandOrigin, formatCommandOrigin } from './command-origin.js'; import { readOverrideRecords, removeOverrideRecords } from './override-provenance.js'; +import { clearDaemonRunContext, generateRunId, runWithDaemonRunContext } from './session-lease.js'; const CLI_FILE = fileURLToPath(import.meta.url); const FOLLOW_POLL_MS = 1_000; @@ -1084,10 +1085,13 @@ cli({ function rawBrowserAction(fn: (session: string, routing: { contextId?: string; preferredContextId?: string }, opts: Record) => Promise) { return async (opts: Record, command: Command) => { + const runId = generateRunId(); + const commandName = `browser/${command.name()}`; try { const session = getBrowserSession(command); const routing = profileRouteParams(getBrowserProfileSelection(command)); - console.log(JSON.stringify(await fn(session, routing, opts), null, 2)); + const result = await runWithDaemonRunContext({ runId, command: commandName }, () => fn(session, routing, opts)); + console.log(JSON.stringify(result, null, 2)); } catch (error) { if (error instanceof BrowserCommandError && error.code) { console.log(JSON.stringify({ @@ -1102,6 +1106,9 @@ cli({ log.error(error instanceof CliError ? `${error.code}: ${error.message}` : error instanceof Error ? error.message : String(error)); if (error instanceof CliError && error.hint) log.error(error.hint); process.exitCode = error instanceof CliError ? error.exitCode : EXIT_CODES.GENERIC_ERROR; + } finally { + clearDaemonRunContext(runId); + await releaseSiteSessionLease(runId); } }; } diff --git a/src/daemon/server.test.ts b/src/daemon/server.test.ts index a30b93c4..ad05235d 100644 --- a/src/daemon/server.test.ts +++ b/src/daemon/server.test.ts @@ -341,7 +341,7 @@ describe('createDaemonServer', () => { const status = await fetch(`${baseUrl}/status`, { headers: { [DAEMON_HEADER_NAME]: '1' } }); await expect(status.json()).resolves.toMatchObject({ sessionLeases: [{ - key: 'default␟adapter␟site%3Aexample', + key: 'default␟site%3Aexample', command: 'example write', acquiredAt: 1_000, heartbeatAt: 20_000, @@ -354,6 +354,17 @@ describe('createDaemonServer', () => { ['read access', { access: 'read' as const }], ['ephemeral sessions', { siteSession: 'ephemeral' as const }], ['raw browser surface', { surface: 'browser' as const }], + ])('conflicts across %s when the resolved Session is the same', async (_case, overrides) => { + const provider = new FakeProvider(); + provider.resolveProfileId = (command) => command.profileId ?? 'default'; + const { baseUrl } = await start(provider); + + expect((await postCommand(baseUrl, persistentWrite('owner', 'run_100_1_1'))).status).toBe(200); + expect((await postCommand(baseUrl, persistentWrite('other', 'run_200_2_2', overrides))).status).toBe(409); + expect(provider.commands.map((command) => command.id)).toEqual(['owner']); + }); + + it.each([ ['different sites', { session: 'site:other' }], ['different resolved profiles', { profileId: 'other' }], ])('does not conflict across %s', async (_case, overrides) => { diff --git a/src/daemon/server.ts b/src/daemon/server.ts index 4efbe302..6ef0f01a 100644 --- a/src/daemon/server.ts +++ b/src/daemon/server.ts @@ -259,7 +259,7 @@ export function createDaemonServer(provider: BrowserRuntimeProvider, opts: Daemo if (isSessionLeaseCommand(resolvedBody)) { const profileId = commandProfileId(provider, resolvedBody) ?? 'default'; - leaseKey = getSessionLeaseKey(profileId, resolvedBody.surface, resolvedBody.session); + leaseKey = getSessionLeaseKey(profileId, resolvedBody.sessionId); runId = resolvedBody.runId; const acquired = leases.acquire({ key: leaseKey, diff --git a/src/execution.test.ts b/src/execution.test.ts index c4dc9316..33b7d1a7 100644 --- a/src/execution.test.ts +++ b/src/execution.test.ts @@ -207,7 +207,8 @@ describe('executeCommand — non-browser timeout', () => { vi.unstubAllGlobals(); }); - it('binds a run only for browser-backed persistent writes', async () => { + it('binds a run for every browser-backed command and skips non-browser commands', async () => { + mockReleaseSiteSessionLease.mockClear(); const seen = new Map>(); const mockPage = { closeWindow: vi.fn().mockResolvedValue(undefined) } as any; @@ -242,12 +243,17 @@ describe('executeCommand — non-browser timeout', () => { expect(eligibleRun).toMatchObject({ runId: expect.stringMatching(/^run_/), command: 'test-execution/run-eligible', - access: 'write', }); - expect(seen.get('run-read')).toBeUndefined(); - expect(seen.get('run-ephemeral')).toBeUndefined(); + expect(seen.get('run-read')).toMatchObject({ + runId: expect.stringMatching(/^run_/), + command: 'test-execution/run-read', + }); + expect(seen.get('run-ephemeral')).toMatchObject({ + runId: expect.stringMatching(/^run_/), + command: 'test-execution/run-ephemeral', + }); expect(seen.get('run-non-browser')).toBeUndefined(); - expect(mockReleaseSiteSessionLease).toHaveBeenCalledOnce(); + expect(mockReleaseSiteSessionLease).toHaveBeenCalledTimes(3); expect(mockReleaseSiteSessionLease).toHaveBeenCalledWith(eligibleRun?.runId); }); @@ -298,7 +304,6 @@ describe('executeCommand — non-browser timeout', () => { expect(entry.run).toEqual({ runId, command: 'test-execution/run-bound-before-operations', - access: 'write', }); } expect(getDaemonRunContext()).toBeUndefined(); @@ -689,8 +694,10 @@ describe('executeCommand — non-browser timeout', () => { await executeCommand(cmd, {}, false, { keepTab: 'false' }); expect(sessionOpts).toHaveLength(2); - expect(sessionOpts[0]).toMatchObject({ session: 'site:test-execution', windowMode: 'background', siteSession: 'persistent' }); - expect(sessionOpts[1]).toMatchObject({ session: 'site:test-execution', windowMode: 'background', siteSession: 'persistent' }); + expect(sessionOpts[0]).toMatchObject({ windowMode: 'background', siteSession: 'persistent' }); + expect(sessionOpts[1]).toMatchObject({ windowMode: 'background', siteSession: 'persistent' }); + expect(sessionOpts[0]?.session).toBeUndefined(); + expect(sessionOpts[1]?.session).toBeUndefined(); expect(sessionOpts[0]?.idleTimeout).toBeUndefined(); expect(sessionOpts[1]?.idleTimeout).toBeUndefined(); expect(closeWindow).not.toHaveBeenCalled(); @@ -721,9 +728,8 @@ describe('executeCommand — non-browser timeout', () => { await executeCommand(cmd, {}); expect(sessionOpts).toHaveLength(2); - expect(sessionOpts[0]?.session).toMatch(/^site:test-execution:/); - expect(sessionOpts[1]?.session).toMatch(/^site:test-execution:/); - expect(sessionOpts[0]?.session).not.toBe(sessionOpts[1]?.session); + expect(sessionOpts[0]?.session).toBeUndefined(); + expect(sessionOpts[1]?.session).toBeUndefined(); expect(sessionOpts[0]?.idleTimeout).toBeUndefined(); expect(sessionOpts[1]?.idleTimeout).toBeUndefined(); expect(sessionOpts[0]?.windowMode).toBe('background'); @@ -757,7 +763,7 @@ describe('executeCommand — non-browser timeout', () => { await executeCommand(cmd, {}, false, { siteSession: 'ephemeral' }); expect(sessionOpts).toHaveLength(1); - expect(sessionOpts[0]?.session).toMatch(/^site:test-execution:/); + expect(sessionOpts[0]?.session).toBeUndefined(); expect(sessionOpts[0]?.idleTimeout).toBeUndefined(); expect(closeWindow).toHaveBeenCalledTimes(1); } finally { diff --git a/src/execution.ts b/src/execution.ts index dfe920fa..036bd65f 100644 --- a/src/execution.ts +++ b/src/execution.ts @@ -21,7 +21,6 @@ import { } from './registry.js'; import type { IPage } from './types.js'; import { pathToFileURL } from 'node:url'; -import * as crypto from 'node:crypto'; import * as fs from 'node:fs'; import * as os from 'node:os'; import { executePipeline } from './pipeline/index.js'; @@ -217,15 +216,12 @@ export async function executeCommand( const contextId = profileSelection?.contextId; const internal = cmd as InternalCliCommand; const siteSession = resolveSiteSession(cmd, opts.siteSession); - const session = resolveAdapterBrowserSession(cmd, siteSession); + const session = opts.session?.trim() || undefined; const keepTab = resolveKeepTab(siteSession, opts.keepTab); const windowMode = resolveBrowserWindowMode(opts.windowMode); const surface = 'adapter' as const; const canonicalCommand = fullName(cmd); - const leaseEligible = surface === 'adapter' - && siteSession === 'persistent' - && cmd.access === 'write'; - const runId = leaseEligible ? generateRunId() : undefined; + const runId = generateRunId(); let releaseRun = true; let deferRunFinalization = false; @@ -235,7 +231,7 @@ export async function executeCommand( : new ObservationSession({ scope: { contextId, - session, + session: session ?? 'adapter-default', target: page.getActivePage?.(), site: cmd.site, command: fullName(cmd), @@ -374,10 +370,7 @@ export async function executeCommand( try { result = runId - ? await runWithDaemonRunContext( - { runId, command: canonicalCommand, access: 'write' }, - executeBrowser, - ) + ? await runWithDaemonRunContext({ runId, command: canonicalCommand }, executeBrowser) : await executeBrowser(); } catch (err) { if (runId && isUnknownOutcomeError(err)) releaseRun = false; @@ -512,11 +505,6 @@ function resolveSiteSession(cmd: CliCommand, rawOption?: unknown): SiteSessionMo return normalizeSiteSession(rawOption) ?? cmd.siteSession ?? 'ephemeral'; } -function resolveAdapterBrowserSession(cmd: CliCommand, siteSession: SiteSessionMode): string { - if (siteSession === 'persistent') return `site:${cmd.site}`; - return `site:${cmd.site}:${crypto.randomUUID()}`; -} - function normalizeBooleanOption(name: string, raw: unknown): boolean | null { if (raw === undefined || raw === '') return null; if (raw === 'true') return true; diff --git a/src/session-lease.test.ts b/src/session-lease.test.ts index e97a5486..73f49dc0 100644 --- a/src/session-lease.test.ts +++ b/src/session-lease.test.ts @@ -26,7 +26,7 @@ describe('logical daemon run context', () => { it('keeps one run id stable while a logical run is bound and generates a different id for the next run', () => { vi.spyOn(Date, 'now').mockReturnValue(1_763_000_000_000); const firstRunId = generateRunId(); - setDaemonRunContext({ runId: firstRunId, command: 'chatgpt ask', access: 'write' }); + setDaemonRunContext({ runId: firstRunId, command: 'chatgpt ask' }); expect(getDaemonRunContext()?.runId).toBe(firstRunId); expect(getDaemonRunContext()?.runId).toBe(firstRunId); @@ -38,14 +38,13 @@ describe('logical daemon run context', () => { }); it('does not let deferred cleanup from an older run clear a newer run context', () => { - setDaemonRunContext({ runId: 'run_111_1_1', command: 'chatgpt ask', access: 'write' }); - setDaemonRunContext({ runId: 'run_222_2_2', command: 'claude ask', access: 'write' }); + setDaemonRunContext({ runId: 'run_111_1_1', command: 'chatgpt ask' }); + setDaemonRunContext({ runId: 'run_222_2_2', command: 'claude ask' }); clearDaemonRunContext('run_111_1_1'); expect(getDaemonRunContext()).toEqual({ runId: 'run_222_2_2', command: 'claude ask', - access: 'write', }); clearDaemonRunContext('run_222_2_2'); @@ -62,12 +61,10 @@ describe('logical daemon run context', () => { const firstContext: DaemonRunContext = { runId: 'run_111_1_1', command: 'first write', - access: 'write', }; const secondContext: DaemonRunContext = { runId: 'run_222_2_2', command: 'second write', - access: 'write', }; const first = runWithDaemonRunContext(firstContext, async () => { @@ -105,39 +102,53 @@ describe('isUnknownOutcomeError', () => { }); describe('session lease partitions', () => { - it('partitions persistent writes by resolved profile, surface, and encoded site', () => { - const workChatgpt = getSessionLeaseKey('work', 'adapter', 'site:chatgpt'); - expect(workChatgpt).toBe('work␟adapter␟site%3Achatgpt'); - expect(workChatgpt).not.toBe(getSessionLeaseKey('personal', 'adapter', 'site:chatgpt')); - expect(workChatgpt).not.toBe(getSessionLeaseKey('work', 'adapter', 'site:claude')); - expect(workChatgpt).not.toBe(getSessionLeaseKey('work', 'browser', 'site:chatgpt')); + it('partitions admission by resolved profile and immutable Session id', () => { + const workSession = getSessionLeaseKey('work', 'session_a'); + expect(workSession).toBe('work␟session_a'); + expect(workSession).not.toBe(getSessionLeaseKey('personal', 'session_a')); + expect(workSession).not.toBe(getSessionLeaseKey('work', 'session_b')); }); - it('does not arbitrate reads, ephemeral sessions, raw browser operations, or incomplete identities', () => { + it('arbitrates any resolved browser-backed command with complete run identity', () => { const eligible = { - surface: 'adapter', - siteSession: 'persistent', - access: 'write', - session: 'site:chatgpt', + action: 'exec', + sessionId: 'session_a', runId: 'run_111_1_1', }; expect(isSessionLeaseCommand(eligible)).toBe(true); - expect(isSessionLeaseCommand({ ...eligible, access: 'read' })).toBe(false); - expect(isSessionLeaseCommand({ ...eligible, siteSession: 'ephemeral' })).toBe(false); - expect(isSessionLeaseCommand({ ...eligible, surface: 'browser' })).toBe(false); - expect(isSessionLeaseCommand({ ...eligible, session: '' })).toBe(false); + expect(isSessionLeaseCommand({ ...eligible, action: 'lease-release' })).toBe(false); + expect(isSessionLeaseCommand({ ...eligible, action: 'run-cancel' })).toBe(false); + expect(isSessionLeaseCommand({ ...eligible, sessionId: '' })).toBe(false); expect(isSessionLeaseCommand({ ...eligible, runId: undefined })).toBe(false); }); }); describe('SessionLeaseRegistry', () => { - const KEY = getSessionLeaseKey('work', 'adapter', 'site:chatgpt'); + const KEY = getSessionLeaseKey('work', 'session_a'); let now = T0; beforeEach(() => { now = T0; }); + it('allows same-run re-entry, rejects overlapping different runs, and allows sibling Sessions', () => { + const leases = registry(); + const keyA = getSessionLeaseKey('profile_work', 'session_a'); + const keyB = getSessionLeaseKey('profile_work', 'session_b'); + + expect(leases.acquire({ key: keyA, runId: 'run_7_one', command: 'browser/run', pid: 7 }, () => true).acquired) + .toBe(true); + expect(leases.acquire({ key: keyA, runId: 'run_7_one', command: 'browser/tabs', pid: 7 }, () => true).acquired) + .toBe(true); + expect(leases.acquire({ key: keyA, runId: 'run_7_two', command: 'github/issues', pid: 7 }, () => true)) + .toMatchObject({ acquired: false }); + expect(leases.acquire({ key: keyB, runId: 'run_7_two', command: 'github/issues', pid: 7 }, () => true).acquired) + .toBe(true); + leases.releaseByRunId('run_7_one'); + expect(leases.acquire({ key: keyA, runId: 'run_7_three', command: 'browser/run' }, () => false).acquired) + .toBe(true); + }); + function registry(): SessionLeaseRegistry { return new SessionLeaseRegistry(() => now); } @@ -269,7 +280,7 @@ describe('SessionLeaseRegistry', () => { it('releases only leases owned by the requested run and reports the count', () => { const leases = registry(); acquire(leases, 'run_111_1_1'); - acquire(leases, 'run_111_1_1', getSessionLeaseKey('work', 'adapter', 'site:claude')); + acquire(leases, 'run_111_1_1', getSessionLeaseKey('work', 'session_b')); expect(leases.releaseByRunId('run_999_9_9')).toBe(0); expect(leases.list(() => false)).toHaveLength(2); diff --git a/src/session-lease.ts b/src/session-lease.ts index d5b79897..3a68657b 100644 --- a/src/session-lease.ts +++ b/src/session-lease.ts @@ -13,7 +13,6 @@ export function generateRunId(): string { export interface DaemonRunContext { runId: string; command: string; - access: 'read' | 'write'; } let activeRun: DaemonRunContext | undefined; @@ -118,11 +117,10 @@ export type AcquireResult = | { acquired: false; holder: SessionLease }; /** - * Lease key for a site session after the daemon has resolved its actual Cloak - * profile. Encoding the session keeps key partitions unambiguous. + * Lease key after the daemon has resolved the immutable browser Session. */ -export function getSessionLeaseKey(profileId: string, surface: string, session: string): string { - return `${profileId}␟${surface}␟${encodeURIComponent(session)}`; +export function getSessionLeaseKey(profileId: string, sessionId: string): string { + return `${profileId}␟${encodeURIComponent(sessionId)}`; } /** Whether a process id is safe to interpolate into local process guidance. */ @@ -138,28 +136,22 @@ function pidFromRunId(runId: string): number | undefined { } export interface SessionLeaseCommand { - surface?: unknown; - siteSession?: unknown; - access?: unknown; - session?: unknown; + action?: unknown; + sessionId?: unknown; runId?: unknown; } -/** Only persistent adapter writes with a complete owner identity need a lease. */ +/** Every resolved browser-backed top-level run with a complete owner identity needs a lease. */ export function isSessionLeaseCommand( command: T, ): command is T & { - surface: 'adapter'; - siteSession: 'persistent'; - access: 'write'; - session: string; + sessionId: string; runId: string; } { - return command.surface === 'adapter' - && command.siteSession === 'persistent' - && command.access === 'write' - && typeof command.session === 'string' - && command.session.length > 0 + return command.action !== 'lease-release' + && command.action !== 'run-cancel' + && typeof command.sessionId === 'string' + && command.sessionId.length > 0 && typeof command.runId === 'string' && command.runId.length > 0; } diff --git a/vitest.config.ts b/vitest.config.ts index 0cffd8b6..c0d518d9 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -22,7 +22,13 @@ export default defineConfig({ { test: { name: 'unit', - include: ['src/*.test.ts', 'src/!(browser)/**/*.test.ts', 'src/browser/verify-fixture.test.ts', 'src/browser/sessions.test.ts'], + include: [ + 'src/*.test.ts', + 'src/!(browser)/**/*.test.ts', + 'src/browser/verify-fixture.test.ts', + 'src/browser/sessions.test.ts', + 'src/browser/daemon-client.test.ts', + ], sequence: { groupOrder: 0 }, }, }, From eea7848c528888c175d45af763746ddba35cefa7 Mon Sep 17 00:00:00 2001 From: beubax Date: Wed, 12 Aug 2026 01:45:47 +0530 Subject: [PATCH 04/27] feat: recover stale session leases --- src/browser/daemon-client.test.ts | 25 +++++++++++++++++++++++++ src/browser/daemon-client.ts | 12 ++++++++++-- src/browser/protocol.ts | 1 + src/daemon/server.test.ts | 28 +++++++++++++++++++++++++++- src/daemon/server.ts | 4 ++-- src/errors.test.ts | 11 +++++++++-- src/errors.ts | 13 +++++++------ src/session-lease.test.ts | 18 +++++++++++++++++- src/session-lease.ts | 20 ++++++++++++++++++-- 9 files changed, 116 insertions(+), 16 deletions(-) diff --git a/src/browser/daemon-client.test.ts b/src/browser/daemon-client.test.ts index b469f9b8..446e2f2e 100644 --- a/src/browser/daemon-client.test.ts +++ b/src/browser/daemon-client.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { BrowserCommandError, + cancelDaemonRun, fetchDaemonStatus, getDaemonHealth, listExistingBrowserTabs, @@ -361,6 +362,30 @@ describe('daemon-client', () => { } }); + it('cancelDaemonRun makes one best-effort POST without inheriting active run metadata', async () => { + setDaemonRunContext({ + runId: 'run_9999_newer_2', + command: 'newer write', + }); + const ensureSpy = vi.spyOn(daemonLifecycle, 'ensureBrowserBridgeReady'); + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ id: 'cancel', ok: true, data: { released: 1 } }), + } as Response); + + await expect(cancelDaemonRun('run_4242_1000_1')).resolves.toBeUndefined(); + + expect(fetch).toHaveBeenCalledTimes(1); + expect(ensureSpy).not.toHaveBeenCalled(); + const body = JSON.parse(String(vi.mocked(fetch).mock.calls[0][1]?.body)); + expect(body).toMatchObject({ + action: 'run-cancel', + runId: 'run_4242_1000_1', + }); + expect(body.command).toBeUndefined(); + expect(body.pid).toBeUndefined(); + }); + it('sendCommand does not retry command_result_unknown even when the message looks transient', async () => { const fetchMock = vi.mocked(fetch); fetchMock.mockResolvedValue({ diff --git a/src/browser/daemon-client.ts b/src/browser/daemon-client.ts index 4158540d..19f9820c 100644 --- a/src/browser/daemon-client.ts +++ b/src/browser/daemon-client.ts @@ -153,7 +153,7 @@ async function sendCommandRaw( } const remainingMs = Math.max(1000, deadlineAt - Date.now()); - const run = action === 'lease-release' ? undefined : getDaemonRunContext(); + const run = action === 'lease-release' || action === 'run-cancel' ? undefined : getDaemonRunContext(); const command: DaemonCommand = { id, action, @@ -270,9 +270,17 @@ export async function sendCommandFull( } export async function releaseSiteSessionLease(runId: string): Promise { + await postRunControl('lease-release', runId); +} + +export async function cancelDaemonRun(runId: string): Promise { + await postRunControl('run-cancel', runId); +} + +async function postRunControl(action: 'lease-release' | 'run-cancel', runId: string): Promise { const command: DaemonCommand = { id: generateId(), - action: 'lease-release', + action, runId, }; await requestDaemon('/command', { diff --git a/src/browser/protocol.ts b/src/browser/protocol.ts index 6a1c11af..d47185fc 100644 --- a/src/browser/protocol.ts +++ b/src/browser/protocol.ts @@ -20,6 +20,7 @@ export type BrowserRuntimeAction = | 'run' | 'snapshot' | 'lease-release' + | 'run-cancel' | 'session-create' | 'session-list' | 'session-close'; diff --git a/src/daemon/server.test.ts b/src/daemon/server.test.ts index ad05235d..bb39b465 100644 --- a/src/daemon/server.test.ts +++ b/src/daemon/server.test.ts @@ -1,5 +1,5 @@ import { AddressInfo } from 'node:net'; -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { DAEMON_HEADER_NAME } from '../constants.js'; import type { BrowserRuntimeCommand, BrowserRuntimeResult, BrowserRuntimeStatus } from '../browser/protocol.js'; import type { BrowserSessionListRow, BrowserSessionRecord } from '../browser/sessions.js'; @@ -98,9 +98,14 @@ class FakeProvider implements BrowserRuntimeProvider { describe('createDaemonServer', () => { const servers: Array<{ close: () => Promise }> = []; + beforeEach(() => { + vi.spyOn(process, 'kill').mockImplementation(() => true); + }); + afterEach(async () => { while (servers.length) await servers.pop()!.close(); vi.useRealTimers(); + vi.restoreAllMocks(); }); async function start(provider = new FakeProvider()) { @@ -505,6 +510,27 @@ describe('createDaemonServer', () => { expect(provider.commands.map((command) => command.id)).toEqual(['owner', 'next-owner']); }); + it('handles run-cancel locally and permits a new owner', async () => { + const { provider, baseUrl } = await start(); + expect((await postCommand(baseUrl, persistentWrite('owner', 'run_100_1_1'))).status).toBe(200); + + const canceled = await postCommand(baseUrl, { + id: 'cancel', + action: 'run-cancel' as BrowserRuntimeCommand['action'], + runId: 'run_100_1_1', + }); + expect(canceled.status).toBe(200); + await expect(canceled.json()).resolves.toMatchObject({ + id: 'cancel', + ok: true, + data: { released: 1 }, + }); + expect(provider.commands.map((command) => command.id)).toEqual(['owner']); + + expect((await postCommand(baseUrl, persistentWrite('next-owner', 'run_200_2_2'))).status).toBe(200); + expect(provider.commands.map((command) => command.id)).toEqual(['owner', 'next-owner']); + }); + it('returns only sanitized current holders from status', async () => { let now = 1_000; vi.spyOn(Date, 'now').mockImplementation(() => now); diff --git a/src/daemon/server.ts b/src/daemon/server.ts index 6ef0f01a..8a76a83d 100644 --- a/src/daemon/server.ts +++ b/src/daemon/server.ts @@ -76,7 +76,7 @@ async function resolveBrowserSession( provider: BrowserRuntimeProvider, command: BrowserRuntimeCommand, ): Promise { - if (command.action === 'lease-release' || SESSION_LIFECYCLE_ACTIONS.has(command.action)) return command; + if (command.action === 'lease-release' || command.action === 'run-cancel' || SESSION_LIFECYCLE_ACTIONS.has(command.action)) return command; let session: BrowserSessionRecord | undefined; if (command.surface === 'adapter' && !command.session) { session = await provider.resolveAdapterDefault?.(command); @@ -243,7 +243,7 @@ export function createDaemonServer(provider: BrowserRuntimeProvider, opts: Daemo jsonResponse(res, result.ok ? 200 : result.errorCode === 'command_result_unknown' ? 408 : 400, result); return; } - if (body.action === 'lease-release') { + if (body.action === 'lease-release' || body.action === 'run-cancel') { const released = typeof body.runId === 'string' ? leases.releaseByRunId(body.runId) : 0; jsonResponse(res, 200, { id: body.id, ok: true, data: { released } }); return; diff --git a/src/errors.test.ts b/src/errors.test.ts index b25f18ca..f40c9a93 100644 --- a/src/errors.test.ts +++ b/src/errors.test.ts @@ -180,7 +180,7 @@ describe('SessionBusyError platform hints', () => { }; it('uses PowerShell process guidance on Windows when the holder pid is known', () => { - const err = new SessionBusyError(holder, 'win32'); + const err = new SessionBusyError(holder, 'win32', () => true); expect(err.hint).toContain('Stop-Process -Id 4242'); expect(err.hint).not.toContain('kill 4242'); }); @@ -193,11 +193,18 @@ describe('SessionBusyError platform hints', () => { }); it('uses kill guidance on POSIX when the holder pid is known', () => { - const err = new SessionBusyError(holder, 'linux'); + const err = new SessionBusyError(holder, 'linux', () => true); expect(err.hint).toContain('kill 4242'); expect(err.hint).not.toContain('Stop-Process'); }); + it('does not suggest killing a holder pid that is no longer alive', () => { + const err = new SessionBusyError(holder, 'linux', () => false); + expect(err.message).toContain('chatgpt ask'); + expect(err.hint).toMatch(/wait/i); + expect(err.hint).not.toContain('kill 4242'); + }); + it.each([ 0, -1, diff --git a/src/errors.ts b/src/errors.ts index 1d13fc07..1b6bee51 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -20,7 +20,7 @@ * 130 Interrupted by Ctrl-C (set by tui.ts SIGINT handler) */ import type { ObservationTraceReceipt } from './observation/events.js'; -import { isActionablePid, type SessionLeaseHolder } from './session-lease.js'; +import { isActionablePid, isPidAlive, type SessionLeaseHolder } from './session-lease.js'; // ── Exit code table ────────────────────────────────────────────────────────── @@ -149,24 +149,25 @@ function formatBusyMessage(holder: SessionLeaseHolder): string { return `Session is busy: ${owner} is already driving it.`; } -function formatBusyHint(holder: SessionLeaseHolder, platform: string): string { +function formatBusyHint(holder: SessionLeaseHolder, platform: string, pidAlive: (pid: number) => boolean): string { + const hasLivePid = isActionablePid(holder.pid) && pidAlive(holder.pid); if (platform === 'win32') { - return !isActionablePid(holder.pid) + return !hasLivePid ? 'Wait for it to finish, or use Task Manager to stop the owning process if it is stuck.' : `Wait for it to finish, or run \`Stop-Process -Id ${holder.pid}\` in PowerShell if it is stuck.`; } - return !isActionablePid(holder.pid) + return !hasLivePid ? 'Wait for it to finish, or stop the owning process if it is stuck.' : `Wait for it to finish, or run \`kill ${holder.pid}\` if it is stuck.`; } /** A persistent write session is temporarily owned by another logical run. */ export class SessionBusyError extends CliError { - constructor(holder: SessionLeaseHolder, platform: string = process.platform) { + constructor(holder: SessionLeaseHolder, platform: string = process.platform, pidAlive: (pid: number) => boolean = isPidAlive) { super( 'SESSION_BUSY', formatBusyMessage(holder), - formatBusyHint(holder, platform), + formatBusyHint(holder, platform, pidAlive), EXIT_CODES.TEMPFAIL, ); } diff --git a/src/session-lease.test.ts b/src/session-lease.test.ts index 73f49dc0..94b05848 100644 --- a/src/session-lease.test.ts +++ b/src/session-lease.test.ts @@ -150,7 +150,7 @@ describe('SessionLeaseRegistry', () => { }); function registry(): SessionLeaseRegistry { - return new SessionLeaseRegistry(() => now); + return new SessionLeaseRegistry(() => now, () => true); } function acquire(registry: SessionLeaseRegistry, runId: string, key = KEY) { @@ -196,6 +196,22 @@ describe('SessionLeaseRegistry', () => { }); }); + it('lets a challenger acquire when the live-looking holder pid is gone', () => { + const leases = new SessionLeaseRegistry(() => now, () => false); + expect(acquire(leases, 'run_111_1_1')).toMatchObject({ acquired: true }); + + now += 1_000; + expect(acquire(leases, 'run_222_2_2')).toEqual({ + acquired: true, + lease: expect.objectContaining({ + runId: 'run_222_2_2', + pid: 222, + acquiredAt: now, + heartbeatAt: now, + }), + }); + }); + it.each([ 0, -1, diff --git a/src/session-lease.ts b/src/session-lease.ts index 3a68657b..8d742175 100644 --- a/src/session-lease.ts +++ b/src/session-lease.ts @@ -128,6 +128,16 @@ export function isActionablePid(pid: unknown): pid is number { return typeof pid === 'number' && Number.isSafeInteger(pid) && pid > 0; } +export function isPidAlive(pid: unknown): boolean { + if (!isActionablePid(pid)) return false; + try { + process.kill(pid, 0); + return true; + } catch (err) { + return (err as NodeJS.ErrnoException).code === 'EPERM'; + } +} + function pidFromRunId(runId: string): number | undefined { const match = /^run_(\d+)_/.exec(runId); if (!match) return undefined; @@ -159,7 +169,7 @@ export function isSessionLeaseCommand( export class SessionLeaseRegistry { private readonly leases = new Map(); - constructor(private readonly now = Date.now) {} + constructor(private readonly now = Date.now, private readonly pidAlive = isPidAlive) {} acquire( input: AcquireSessionLeaseInput, @@ -168,7 +178,13 @@ export class SessionLeaseRegistry { const now = this.now(); const current = this.leases.get(input.key); const currentIsLive = current !== undefined - && (now - current.heartbeatAt <= SESSION_LEASE_TTL_MS || hasPendingWork(current.runId)); + && ( + hasPendingWork(current.runId) + || ( + now - current.heartbeatAt <= SESSION_LEASE_TTL_MS + && (!isActionablePid(current.pid) || this.pidAlive(current.pid)) + ) + ); if (current && currentIsLive && current.runId !== input.runId) { return { acquired: false, holder: { ...current } }; From 4ed23a692aedeb192cd968b6773b26aaa385aaa0 Mon Sep 17 00:00:00 2001 From: beubax Date: Wed, 12 Aug 2026 01:54:03 +0530 Subject: [PATCH 05/27] feat: isolate local browser sessions --- src/browser/run/playwright-transport.ts | 60 +++++++++-- src/browser/run/runner.test.ts | 23 +++++ src/browser/run/runner.ts | 9 +- src/browser/runtime/local-cloak/actions.ts | 55 +++++++++-- .../runtime/local-cloak/provider.test.ts | 60 +++++++---- src/browser/runtime/local-cloak/provider.ts | 6 +- .../local-cloak/session-manager.test.ts | 2 +- .../runtime/local-cloak/session-manager.ts | 99 ++++++++++++++----- vitest.config.ts | 3 + 9 files changed, 249 insertions(+), 68 deletions(-) diff --git a/src/browser/run/playwright-transport.ts b/src/browser/run/playwright-transport.ts index 921361d9..e42431b5 100644 --- a/src/browser/run/playwright-transport.ts +++ b/src/browser/run/playwright-transport.ts @@ -78,6 +78,51 @@ function implementation(object: T): unknown { return value; } +function scopedContext(context: object, pages: () => object[]): object { + const pageListeners = new WeakMap(); + const isAllowedPage = (candidate: object) => { + const allowed = pages(); + if (allowed.includes(candidate)) return true; + const opener = Reflect.get(candidate, 'opener', candidate); + if (typeof opener !== 'function') return false; + try { + return allowed.includes(Reflect.apply(opener, candidate, [])); + } catch { + return false; + } + }; + let proxy: object; + proxy = new Proxy(context, { + get(target, property) { + if (property === 'pages') return pages; + if (property === 'on' || property === 'addListener') { + return (event: string, listener: Function) => { + let registered = listener; + if (event === 'page') { + registered = (candidate: object, ...args: unknown[]) => { + if (isAllowedPage(candidate)) listener(candidate, ...args); + }; + pageListeners.set(listener, registered); + } + Reflect.apply(Reflect.get(target, property), target, [event, registered]); + return proxy; + }; + } + if (property === 'off' || property === 'removeListener') { + return (event: string, listener: Function) => { + const registered = pageListeners.get(listener) ?? listener; + Reflect.apply(Reflect.get(target, property), target, [event, registered]); + pageListeners.delete(listener); + return proxy; + }; + } + const value = Reflect.get(target, property, target); + return typeof value === 'function' ? value.bind(target) : value; + }, + }); + return proxy; +} + function scopedBrowser(browser: object, context: object): object { const contextListeners = new WeakMap(); let proxy: object; @@ -117,13 +162,13 @@ export class PlaywrightTransport { readonly #connection: DispatcherConnection; readonly #root: RootDispatcher; readonly #deliver: (message: string) => void; - readonly #hostPages = new Set(); + #registerPageImpl: (page: Page) => void; #cancellation: Promise | undefined; #disposed = false; #browserWaitMs = 0; constructor( - input: { browser: Browser; context: BrowserContext; page: Page }, + input: { browser: Browser; context: BrowserContext; page: Page; pages?: Page[] }, deliver: (message: string) => void, ) { if ( @@ -137,10 +182,11 @@ export class PlaywrightTransport { } const browser = implementation(input.browser) as object; - const context = implementation(input.context) as object; + const allowedPages = new Set(); + const context = scopedContext(implementation(input.context) as object, () => [...allowedPages]); this.pageGuid = pageGuid(input.page); - this.#pages = () => input.context.pages(); - this.#hostPages.add(input.page); + this.#registerPageImpl = page => allowedPages.add(implementation(page) as object); + for (const page of input.pages?.length ? input.pages : [input.page]) this.registerPage(page); this.#deliver = deliver; this.#connection = new server.DispatcherConnection(); this.#connection.onmessage = message => { @@ -195,10 +241,8 @@ export class PlaywrightTransport { return this.#browserWaitMs; } - #pages: () => Page[]; - registerPage(page: Page): void { - this.#hostPages.add(page); + this.#registerPageImpl(page); } cancel(error: Error): Promise { diff --git a/src/browser/run/runner.test.ts b/src/browser/run/runner.test.ts index d525667e..e987828a 100644 --- a/src/browser/run/runner.test.ts +++ b/src/browser/run/runner.test.ts @@ -263,6 +263,29 @@ describe('runBrowserProgram', () => { expect(registered).toEqual([context.pages()[1]]); }); + it('hides pages outside the supplied session page set', async () => { + const other = await context.newPage(); + await other.setContent(''); + + const output = await runBrowserProgram({ + browser, + context, + page, + pageId: 'page-1', + pages: [page], + }, ` + return { + pages: context.pages().length, + urls: context.pages().map(page => page.url()), + }; + `); + + expect(output.result).toEqual({ + pages: 1, + urls: [page.url()], + }); + }); + it('waits for requests and responses', async () => { const output = await run(` const requestPromise = page.waitForRequest('**/data'); diff --git a/src/browser/run/runner.ts b/src/browser/run/runner.ts index 0cd8eb99..c9dfe6dc 100644 --- a/src/browser/run/runner.ts +++ b/src/browser/run/runner.ts @@ -40,6 +40,7 @@ export interface BrowserRunProgramHost { context: PlaywrightBrowserContext; page: PlaywrightPage; pageId: string; + pages?: PlaywrightPage[]; artifactSink?: BrowserRunArtifactSink; registerPage?: (page: PlaywrightPage) => string; } @@ -297,7 +298,7 @@ export async function runBrowserProgram( } finally { timings.quickjs_boot_ms = Math.max(0, Date.now() - quickjsBootStartedAt); } - const knownPages = new Set(input.context.pages()); + const knownPages = new Set(input.pages?.length ? input.pages : [input.page]); for (const page of knownPages) transport.registerPage(page); const registerNewPage = (page: PlaywrightPage) => { if (knownPages.has(page)) return; @@ -305,7 +306,7 @@ export async function runBrowserProgram( transport.registerPage(page); input.registerPage?.(page); }; - input.context.on('page', registerNewPage); + input.page.on('popup', registerNewPage); let timeout: ReturnType | undefined; let timeoutCleanup: Promise | undefined; @@ -323,7 +324,7 @@ export async function runBrowserProgram( .finally(() => { host.dispose(); void transport.dispose(timeoutError); - input.context.off('page', registerNewPage); + input.page.off('popup', registerNewPage); }); }; try { @@ -596,7 +597,7 @@ export async function runBrowserProgram( ).catch(() => undefined); await transport.dispose(completionError); host.dispose(); - input.context.off('page', registerNewPage); + input.page.off('popup', registerNewPage); } } } diff --git a/src/browser/runtime/local-cloak/actions.ts b/src/browser/runtime/local-cloak/actions.ts index b9e313ec..abd2f025 100644 --- a/src/browser/runtime/local-cloak/actions.ts +++ b/src/browser/runtime/local-cloak/actions.ts @@ -63,13 +63,19 @@ function invalidRequest(command: BrowserRuntimeCommand, error: string): BrowserR } async function resolveLease(manager: CloakSessionManager, command: BrowserRuntimeCommand) { + const profileId = resolveCloakCommandProfileId(manager, command); if (command.page) { - const existing = manager.findPageById(command.page, { idleTimeout: command.idleTimeout }); + const existing = manager.findPageById(command.page, { + profileId, + session: command.session, + surface: command.surface, + idleTimeout: command.idleTimeout, + }); if (existing) return existing; throw new CloakActionError('stale_page_identity', `Page not found: ${command.page} — stale page identity`); } return manager.getPage({ - profileId: resolveCloakCommandProfileId(manager, command), + profileId, session: command.session, surface: command.surface, siteSession: command.siteSession, @@ -80,13 +86,19 @@ async function resolveLease(manager: CloakSessionManager, command: BrowserRuntim } function resolveExistingLease(manager: CloakSessionManager, command: BrowserRuntimeCommand) { + const profileId = resolveCloakCommandProfileId(manager, command); if (command.page) { - const existing = manager.findPageById(command.page, { idleTimeout: command.idleTimeout }); + const existing = manager.findPageById(command.page, { + profileId, + session: command.session, + surface: command.surface, + idleTimeout: command.idleTimeout, + }); if (existing) return existing; throw new CloakActionError('stale_page_identity', `Page not found: ${command.page} — stale page identity`); } const existing = manager.findPage({ - profileId: resolveCloakCommandProfileId(manager, command), + profileId, session: command.session, surface: command.surface, idleTimeout: command.idleTimeout, @@ -219,6 +231,11 @@ export async function dispatchCloakAction(manager: CloakSessionManager, command: context: lease.context, page: lease.page, pageId: lease.pageId, + pages: manager.sessionPages({ + profileId: lease.profileId, + session: command.session, + surface: command.surface, + }), registerPage: (page) => manager.registerPage({ profileId: lease.profileId, session: command.session, @@ -311,7 +328,12 @@ export async function dispatchCloakAction(manager: CloakSessionManager, command: } case 'close-window': { if (command.page) { - const closed = await manager.closePage({ profileId: resolveCloakCommandProfileId(manager, command), pageId: command.page }); + const closed = await manager.closePage({ + profileId: resolveCloakCommandProfileId(manager, command), + session: command.session, + surface: command.surface, + pageId: command.page, + }); return { id: command.id, ok: true, data: { closed: Boolean(closed), page: closed ?? command.page, session: command.session } }; } else { await manager.release({ @@ -325,7 +347,11 @@ export async function dispatchCloakAction(manager: CloakSessionManager, command: case 'tabs': { switch (command.op ?? 'list') { case 'list': { - const tabs = await manager.listPages({ profileId: resolveCloakCommandProfileId(manager, command) }); + const tabs = await manager.listPages({ + profileId: resolveCloakCommandProfileId(manager, command), + session: command.session, + surface: command.surface, + }); return { id: command.id, ok: true, data: tabs }; } case 'new': { @@ -341,12 +367,25 @@ export async function dispatchCloakAction(manager: CloakSessionManager, command: return { id: command.id, ok: true, data: { title: await lease.page.title(), url: lease.page.url() }, page: lease.pageId }; } case 'select': { - const lease = await manager.selectPage({ profileId: resolveCloakCommandProfileId(manager, command), pageId: command.page, index: command.index, windowMode: command.windowMode }); + const lease = await manager.selectPage({ + profileId: resolveCloakCommandProfileId(manager, command), + session: command.session, + surface: command.surface, + pageId: command.page, + index: command.index, + windowMode: command.windowMode, + }); if (!lease) return { id: command.id, ok: false, errorCode: 'runtime_command_failed', error: 'Tab not found' }; return { id: command.id, ok: true, data: { selected: true, url: lease.page.url() }, page: lease.pageId }; } case 'close': { - const closed = await manager.closePage({ profileId: resolveCloakCommandProfileId(manager, command), pageId: command.page, index: command.index }); + const closed = await manager.closePage({ + profileId: resolveCloakCommandProfileId(manager, command), + session: command.session, + surface: command.surface, + pageId: command.page, + index: command.index, + }); if (!closed) return { id: command.id, ok: false, errorCode: 'runtime_command_failed', error: 'Tab not found' }; return { id: command.id, ok: true, data: { closed } }; } diff --git a/src/browser/runtime/local-cloak/provider.test.ts b/src/browser/runtime/local-cloak/provider.test.ts index 147f7d92..8b98a97c 100644 --- a/src/browser/runtime/local-cloak/provider.test.ts +++ b/src/browser/runtime/local-cloak/provider.test.ts @@ -147,11 +147,32 @@ describe('LocalCloakRuntimeProvider', () => { browser, context, page, + pages: [page], }), expect.stringContaining('return page.url()'), expect.objectContaining({ snapshotDiff: undefined, })); }); + it('browser-run receives only pages from the selected session', async () => { + const { provider, pages } = makeProviderWithFakePage(); + runBrowserProgram.mockResolvedValue(runOutput(null)); + await provider.dispatch({ id: 'first', action: 'navigate', session: 'first', surface: 'browser', url: 'https://first.example/', profileId: 'default' }); + await provider.dispatch({ id: 'second', action: 'tabs', op: 'new', session: 'second', surface: 'browser', url: 'https://second.example/', profileId: 'default' }); + + await provider.dispatch({ + id: 'run', + action: 'run', + session: 'first', + surface: 'browser', + source: 'return context.pages().length;', + profileId: 'default', + }); + + expect(runBrowserProgram).toHaveBeenCalledWith(expect.objectContaining({ + pages: [pages[0]], + }), expect.any(String), expect.any(Object)); + }); + it('preserves structured browser-run error details', async () => { const { provider } = makeProviderWithFakePage(); runBrowserProgram.mockRejectedValue(new BrowserRunError( @@ -268,19 +289,19 @@ describe('LocalCloakRuntimeProvider', () => { id: 'run', action: 'run', page: nav.page, - session: 'misleading-session', - surface: 'adapter', + session: 'work', + surface: 'browser', source: 'await new Promise(resolve => setTimeout(resolve, 30)); return 1;', - profileId: 'misleading-profile', + profileId: 'default', }); const second = provider.dispatch({ id: 'exec', action: 'exec', page: nav.page, - session: 'different-session', - surface: 'adapter', + session: 'work', + surface: 'browser', code: 'document.title', - profileId: 'different-profile', + profileId: 'default', }); await new Promise((resolve) => setTimeout(resolve, 5)); @@ -318,14 +339,14 @@ describe('LocalCloakRuntimeProvider', () => { id: 'run', action: 'run', page: nav.page, - session: 'misleading-session', - surface: 'adapter', + session: 'work', + surface: 'browser', source: ` await page.waitForEvent("popup"); await new Promise(resolve => setTimeout(resolve, 30)); return null; `, - profileId: 'misleading-profile', + profileId: 'default', }); await vi.waitFor(() => { expect(manager.pageIdFor(popup)).toEqual(expect.any(String)); @@ -337,10 +358,10 @@ describe('LocalCloakRuntimeProvider', () => { id: 'exec', action: 'exec', page: popupPageId, - session: 'different-session', - surface: 'adapter', + session: 'work', + surface: 'browser', code: 'document.title', - profileId: 'different-profile', + profileId: 'default', }); await new Promise((resolve) => setTimeout(resolve, 5)); @@ -398,7 +419,7 @@ describe('LocalCloakRuntimeProvider', () => { expect(page.evaluate).toHaveBeenCalledTimes(1); }); - it('joins target commands queued on opposite sides of a bind transition', async () => { + it('keeps explicit page commands queued behind a bind transition', async () => { const { provider, page, pages, context } = makeProviderWithFakePage(); const nav = await provider.dispatch({ id: 'nav', @@ -490,7 +511,8 @@ describe('LocalCloakRuntimeProvider', () => { finishFirstTargetExec(); await Promise.all([bind, beforeMapping, afterMapping]); - expect(page.evaluate).toHaveBeenCalledTimes(2); + expect(page.evaluate).toHaveBeenCalledTimes(1); + expect(priorTargetPage.evaluate).toHaveBeenCalledTimes(2); }); it('evaluates JavaScript in the requested iframe', async () => { @@ -751,16 +773,20 @@ describe('LocalCloakRuntimeProvider', () => { expect(pages[1].bringToFront).toHaveBeenCalledOnce(); }); - it('closes a window by page identity when command.page is provided', async () => { + it('rejects a page identity from a different session', async () => { const { provider, pages } = makeProviderWithFakePage(); const first = await provider.dispatch({ id: 'first', action: 'navigate', session: 'first', surface: 'browser', url: 'https://first.example/', profileId: 'default' }); const second = await provider.dispatch({ id: 'second', action: 'tabs', op: 'new', session: 'second', surface: 'browser', url: 'https://second.example/', profileId: 'default' }); await expect(provider.dispatch({ id: 'close-window', action: 'close-window', session: 'first', surface: 'browser', page: second.page, profileId: 'default' })) - .resolves.toMatchObject({ id: 'close-window', ok: true, data: { closed: true, page: second.page } }); + .resolves.toMatchObject({ id: 'close-window', ok: true, data: { closed: false, page: second.page } }); + + await expect(provider.dispatch({ id: 'exec', action: 'exec', session: 'first', surface: 'browser', page: second.page, code: 'document.title', profileId: 'default' })) + .resolves.toMatchObject({ id: 'exec', ok: false, errorCode: 'stale_page_identity' }); expect(pages[0].isClosed()).toBe(false); - expect(pages[1].close).toHaveBeenCalled(); + expect(pages[1].close).not.toHaveBeenCalled(); + expect(pages[1].evaluate).not.toHaveBeenCalled(); expect(first.page).not.toBe(second.page); }); }); diff --git a/src/browser/runtime/local-cloak/provider.ts b/src/browser/runtime/local-cloak/provider.ts index a4ca8d85..511349d2 100644 --- a/src/browser/runtime/local-cloak/provider.ts +++ b/src/browser/runtime/local-cloak/provider.ts @@ -92,8 +92,8 @@ export class LocalCloakRuntimeProvider implements BrowserRuntimeProvider { private commandQueueKey(command: BrowserRuntimeCommand): string { if (command.page) { - const profileId = this.manager.profileIdForPage(command.page); - if (profileId) return `profile\u0000${profileId}`; + const owner = this.manager.pageOwner(command.page); + if (owner) return `session\u0000${owner.profileId}\u0000${owner.surface}\u0000${owner.session}`; } let profileId: string; @@ -105,6 +105,6 @@ export class LocalCloakRuntimeProvider implements BrowserRuntimeProvider { ?? command.preferredContextId ?? 'default'; } - return `profile\u0000${profileId.trim() || 'default'}`; + return `session\u0000${profileId.trim() || 'default'}\u0000${command.surface ?? 'browser'}\u0000${command.session ?? ''}`; } } diff --git a/src/browser/runtime/local-cloak/session-manager.test.ts b/src/browser/runtime/local-cloak/session-manager.test.ts index 5b531871..711e6009 100644 --- a/src/browser/runtime/local-cloak/session-manager.test.ts +++ b/src/browser/runtime/local-cloak/session-manager.test.ts @@ -115,7 +115,7 @@ describe('CloakSessionManager', () => { windowMode: 'background', }); - await manager.selectPage({ profileId: 'default', pageId: lease.pageId, windowMode: 'foreground' }); + await manager.selectPage({ profileId: 'default', session: 'work', surface: 'browser', pageId: lease.pageId, windowMode: 'foreground' }); expect(activateBackgroundContext).toHaveBeenCalledWith(launched.context); }); diff --git a/src/browser/runtime/local-cloak/session-manager.ts b/src/browser/runtime/local-cloak/session-manager.ts index 8ba0a13b..314d9221 100644 --- a/src/browser/runtime/local-cloak/session-manager.ts +++ b/src/browser/runtime/local-cloak/session-manager.ts @@ -81,7 +81,7 @@ export interface CloakTabInfo { interface ProfileRuntime { context: BrowserContext; pages: Map; - selectedPageId?: string; + selectedPageIds: Map; lastSeenAt: number; } @@ -164,7 +164,7 @@ export class CloakSessionManager { if (existing && freshPage) { runtime.pages.delete(leaseKey); this.clearIdleTimer(existing); - if (runtime.selectedPageId === existing.pageId) runtime.selectedPageId = undefined; + this.clearSelectedPage(runtime, existing); if (!pageIsClosed(existing.page)) await existing.page.close().catch(() => {}); } @@ -183,7 +183,7 @@ export class CloakSessionManager { const entry: PageEntry = { page, pageId, session, surface, siteSession: input.siteSession, idleTimeout: input.idleTimeout }; candidate.pages.set(leaseKey, entry); this.refreshIdleTimer(candidate, leaseKey, entry); - candidate.selectedPageId = pageId; + this.setSelectedPage(candidate, entry); candidate.lastSeenAt = Date.now(); return { profileId, leaseKey, context: candidate.context, page, pageId }; }, @@ -203,10 +203,19 @@ export class CloakSessionManager { return { profileId, leaseKey, context: runtime.context, page: entry.page, pageId: entry.pageId }; } - findPageById(pageId: string, opts: Pick = {}): CloakPageLease | null { + findPageById(pageId: string, opts: Pick = {}): CloakPageLease | null { + const expectedProfileId = opts.profileId ? normalizeProfileId(opts.profileId) : undefined; + const expectedSession = opts.session?.trim(); + const expectedSurface = opts.surface ? normalizeSurface(opts.surface) : undefined; for (const [profileId, runtime] of this.profiles.entries()) { + if (expectedProfileId && expectedProfileId !== profileId) continue; for (const [leaseKey, entry] of runtime.pages.entries()) { - if (entry.pageId === pageId && !pageIsClosed(entry.page)) { + if ( + entry.pageId === pageId + && !pageIsClosed(entry.page) + && (!expectedSession || entry.session === expectedSession) + && (!expectedSurface || entry.surface === expectedSurface) + ) { entry.idleTimeout = opts.idleTimeout; this.refreshIdleTimer(runtime, leaseKey, entry); return { profileId, leaseKey, context: runtime.context, page: entry.page, pageId: entry.pageId }; @@ -216,11 +225,11 @@ export class CloakSessionManager { return null; } - profileIdForPage(pageId: string): string | null { + pageOwner(pageId: string): { profileId: string; session: string; surface: BrowserSurface } | null { for (const [profileId, runtime] of this.profiles.entries()) { for (const entry of runtime.pages.values()) { if (entry.pageId === pageId && !pageIsClosed(entry.page)) { - return profileId; + return { profileId, session: entry.session, surface: entry.surface }; } } } @@ -247,16 +256,30 @@ export class CloakSessionManager { const entry: PageEntry = { page, pageId, session, surface, siteSession: input.siteSession, idleTimeout: input.idleTimeout }; runtime.pages.set(leaseKey, entry); this.refreshIdleTimer(runtime, leaseKey, entry); - runtime.selectedPageId = pageId; + this.setSelectedPage(runtime, entry); runtime.lastSeenAt = Date.now(); return pageId; } - async listPages(input: Pick): Promise { + sessionPages(input: Pick): PlaywrightPage[] { const profileId = normalizeProfileId(input.profileId); + const session = input.session?.trim(); + const surface = input.surface ? normalizeSurface(input.surface) : undefined; + const runtime = this.profiles.get(profileId); + if (!runtime || !session) return []; + return this.openEntries(runtime) + .filter(([, entry]) => entry.session === session && (!surface || entry.surface === surface)) + .map(([, entry]) => entry.page); + } + + async listPages(input: Pick): Promise { + const profileId = normalizeProfileId(input.profileId); + const session = input.session?.trim(); + const surface = input.surface ? normalizeSurface(input.surface) : undefined; const runtime = this.profiles.get(profileId); if (!runtime) return []; - const entries = this.openEntries(runtime); + const entries = this.openEntries(runtime) + .filter(([, entry]) => (!session || entry.session === session) && (!surface || entry.surface === surface)); return Promise.all(entries.map(async ([, entry], index) => ({ id: entry.pageId, page: entry.pageId, @@ -266,7 +289,7 @@ export class CloakSessionManager { profileId, session: entry.session, surface: entry.surface, - selected: runtime.selectedPageId === entry.pageId, + selected: runtime.selectedPageIds.get(selectionKey(entry)) === entry.pageId, }))); } @@ -308,18 +331,19 @@ export class CloakSessionManager { return { profileId, leaseKey, context: acquired.runtime.context, page: acquired.page, pageId }; } - async selectPage(input: Pick & { pageId?: string; index?: number }): Promise { + async selectPage(input: Pick & { pageId?: string; index?: number }): Promise { const profileId = normalizeProfileId(input.profileId); const runtime = this.profiles.get(profileId); if (!runtime) return null; - const match = input.pageId ? this.findEntryByPageId(runtime, input.pageId) : this.openEntries(runtime)[input.index ?? -1]; + const candidates = this.sessionEntries(runtime, input); + const match = input.pageId ? candidates.find(([, entry]) => entry.pageId === input.pageId) : candidates[input.index ?? -1]; if (!match) return null; const [leaseKey, entry] = match; if (input.windowMode !== 'background') { await entry.page.bringToFront?.().catch(() => {}); await this.activateBackgroundContext(runtime.context); } - runtime.selectedPageId = entry.pageId; + this.setSelectedPage(runtime, entry); runtime.lastSeenAt = Date.now(); return { profileId, leaseKey, context: runtime.context, page: entry.page, pageId: entry.pageId }; } @@ -338,6 +362,11 @@ export class CloakSessionManager { const canonicalKey = resolveLeaseKey({ profileId, session, surface }); const currentCanonical = runtime.pages.get(canonicalKey); + if (input.windowMode !== 'background') { + await entry.page.bringToFront?.().catch(() => {}); + await this.activateBackgroundContext(runtime.context); + } + if (currentCanonical && currentCanonical !== entry && !pageIsClosed(currentCanonical.page)) { const preservedKey = `${canonicalKey}\u0000${currentCanonical.pageId}`; runtime.pages.delete(canonicalKey); @@ -351,26 +380,23 @@ export class CloakSessionManager { entry.siteSession = input.siteSession; entry.idleTimeout = input.idleTimeout; runtime.pages.set(canonicalKey, entry); - if (input.windowMode !== 'background') { - await entry.page.bringToFront?.().catch(() => {}); - await this.activateBackgroundContext(runtime.context); - } this.refreshIdleTimer(runtime, canonicalKey, entry); - runtime.selectedPageId = entry.pageId; + this.setSelectedPage(runtime, entry); runtime.lastSeenAt = Date.now(); return { profileId, leaseKey: canonicalKey, context: runtime.context, page: entry.page, pageId: entry.pageId }; } - async closePage(input: Pick & { pageId?: string; index?: number }): Promise { + async closePage(input: Pick & { pageId?: string; index?: number }): Promise { const profileId = normalizeProfileId(input.profileId); const runtime = this.profiles.get(profileId); if (!runtime) return null; - const match = input.pageId ? this.findEntryByPageId(runtime, input.pageId) : this.openEntries(runtime)[input.index ?? -1]; + const candidates = this.sessionEntries(runtime, input); + const match = input.pageId ? candidates.find(([, entry]) => entry.pageId === input.pageId) : candidates[input.index ?? -1]; if (!match) return null; const [leaseKey, entry] = match; runtime.pages.delete(leaseKey); this.clearIdleTimer(entry); - if (runtime.selectedPageId === entry.pageId) runtime.selectedPageId = undefined; + this.clearSelectedPage(runtime, entry); if (!pageIsClosed(entry.page)) await entry.page.close().catch(() => {}); runtime.lastSeenAt = Date.now(); return entry.pageId; @@ -389,7 +415,7 @@ export class CloakSessionManager { for (const [key, entry] of entries) { runtime.pages.delete(key); this.clearIdleTimer(entry); - if (runtime.selectedPageId === entry.pageId) runtime.selectedPageId = undefined; + this.clearSelectedPage(runtime, entry); if (entry.siteSession !== 'persistent' && !pageIsClosed(entry.page)) { await entry.page.close().catch(() => {}); } @@ -412,7 +438,7 @@ export class CloakSessionManager { for (const [key, entry] of entries) { runtime.pages.delete(key); this.clearIdleTimer(entry); - if (runtime.selectedPageId === entry.pageId) runtime.selectedPageId = undefined; + this.clearSelectedPage(runtime, entry); if (!pageIsClosed(entry.page)) await entry.page.close().catch(() => {}); } if (entries.length > 0) runtime.lastSeenAt = Date.now(); @@ -460,7 +486,7 @@ export class CloakSessionManager { if (!isProfileAlreadyInUseError(err) || !(await this.recoverLockedProfile(userDataDir))) throw err; context = await launchPersistentContext(launchOptions); } - const runtime = { context, pages: new Map(), lastSeenAt: Date.now() }; + const runtime = { context, pages: new Map(), selectedPageIds: new Map(), lastSeenAt: Date.now() }; this.attachRuntimeLifecycle(profileId, runtime); this.profiles.set(profileId, runtime); return runtime; @@ -474,7 +500,7 @@ export class CloakSessionManager { this.networkCapture.stop(entry.page); } runtime.pages.clear(); - runtime.selectedPageId = undefined; + runtime.selectedPageIds.clear(); } private attachRuntimeLifecycle(profileId: string, runtime: ProfileRuntime): void { @@ -564,6 +590,21 @@ export class CloakSessionManager { return this.openEntries(runtime).find(([, entry]) => entry.pageId === pageId) ?? null; } + private sessionEntries(runtime: ProfileRuntime, input: Pick): [string, PageEntry][] { + const session = requireSession(input.session); + const surface = normalizeSurface(input.surface); + return this.openEntries(runtime).filter(([, entry]) => entry.session === session && entry.surface === surface); + } + + private setSelectedPage(runtime: ProfileRuntime, entry: PageEntry): void { + runtime.selectedPageIds.set(selectionKey(entry), entry.pageId); + } + + private clearSelectedPage(runtime: ProfileRuntime, entry: PageEntry): void { + const key = selectionKey(entry); + if (runtime.selectedPageIds.get(key) === entry.pageId) runtime.selectedPageIds.delete(key); + } + private refreshIdleTimer(runtime: ProfileRuntime, leaseKey: string, entry: PageEntry): void { this.clearIdleTimer(entry); if (!entry.idleTimeout || entry.idleTimeout <= 0 || entry.siteSession === 'persistent') return; @@ -577,7 +618,7 @@ export class CloakSessionManager { if (runtime.pages.get(leaseKey) !== entry) return; runtime.pages.delete(leaseKey); this.clearIdleTimer(entry); - if (runtime.selectedPageId === entry.pageId) runtime.selectedPageId = undefined; + this.clearSelectedPage(runtime, entry); runtime.lastSeenAt = Date.now(); if (entry.siteSession !== 'persistent' && !pageIsClosed(entry.page)) { await entry.page.close().catch(() => {}); @@ -598,6 +639,10 @@ function normalizeSurface(surface: BrowserSurface | undefined): BrowserSurface { return surface === 'adapter' ? 'adapter' : 'browser'; } +function selectionKey(entry: Pick): string { + return `${entry.surface}\u0000${encodeURIComponent(entry.session)}`; +} + function requireSession(session: string | undefined): string { const normalized = session?.trim(); if (!normalized) throw new Error('Browser session is required.'); diff --git a/vitest.config.ts b/vitest.config.ts index c0d518d9..0a09fcc3 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -28,6 +28,9 @@ export default defineConfig({ 'src/browser/verify-fixture.test.ts', 'src/browser/sessions.test.ts', 'src/browser/daemon-client.test.ts', + 'src/browser/run/runner.test.ts', + 'src/browser/runtime/local-cloak/provider.test.ts', + 'src/browser/runtime/local-cloak/session-manager.test.ts', ], sequence: { groupOrder: 0 }, }, From d2f48c0b294ec90fff0bc1ea6b6179170535f4a1 Mon Sep 17 00:00:00 2001 From: beubax Date: Wed, 12 Aug 2026 01:58:52 +0530 Subject: [PATCH 06/27] feat: open local sessions in owned windows --- .../runtime/local-cloak/provider.test.ts | 22 ++++- .../local-cloak/session-manager.test.ts | 92 +++++++------------ .../runtime/local-cloak/session-manager.ts | 9 +- 3 files changed, 59 insertions(+), 64 deletions(-) diff --git a/src/browser/runtime/local-cloak/provider.test.ts b/src/browser/runtime/local-cloak/provider.test.ts index 8b98a97c..591d996e 100644 --- a/src/browser/runtime/local-cloak/provider.test.ts +++ b/src/browser/runtime/local-cloak/provider.test.ts @@ -48,11 +48,23 @@ function fakePage(url: string, initialViewport: { width: number; height: number function makeProviderWithFakePage(initialViewport: { width: number; height: number } | null = { width: 1280, height: 720 }) { const pages = [fakePage('https://example.com/', initialViewport)]; + const listeners = new Map void>>(); + const emit = (event: string, ...args: unknown[]) => { + for (const listener of listeners.get(event) ?? []) listener(...args); + }; const cdpSession = { send: vi.fn().mockResolvedValue(undefined), detach: vi.fn().mockResolvedValue(undefined) }; - const browser = { contexts: vi.fn(() => [context]) }; + const browser = { contexts: vi.fn(() => [context]), newBrowserCDPSession: vi.fn().mockResolvedValue(cdpSession) }; const context = { browser: vi.fn(() => browser), - on: vi.fn(), + on: vi.fn((event: string, listener: (...args: unknown[]) => void) => { + const bucket = listeners.get(event) ?? new Set(); + bucket.add(listener); + listeners.set(event, bucket); + }), + off: vi.fn((event: string, listener: (...args: unknown[]) => void) => { + listeners.get(event)?.delete(listener); + }), + waitForEvent: vi.fn((event: string) => new Promise((resolve) => context.on(event, resolve))), pages: vi.fn(() => pages.filter((page) => !page.isClosed())), newPage: vi.fn(async () => { const page = fakePage('about:blank'); @@ -63,6 +75,12 @@ function makeProviderWithFakePage(initialViewport: { width: number; height: numb cookies: vi.fn().mockResolvedValue([{ name: 'sid', value: '1', domain: 'example.com', path: '/' }]), close: vi.fn().mockResolvedValue(undefined), }; + cdpSession.send.mockImplementation(async (command: string) => { + if (command === 'Target.createTarget') { + const page = await context.newPage(); + queueMicrotask(() => emit('page', page)); + } + }); const provider = new LocalCloakRuntimeProvider({ baseDir: '/tmp/webcmd-test', launchPersistentContext: vi.fn().mockResolvedValue(context), diff --git a/src/browser/runtime/local-cloak/session-manager.test.ts b/src/browser/runtime/local-cloak/session-manager.test.ts index 711e6009..48368719 100644 --- a/src/browser/runtime/local-cloak/session-manager.test.ts +++ b/src/browser/runtime/local-cloak/session-manager.test.ts @@ -16,14 +16,16 @@ function fakeContext() { close: vi.fn().mockResolvedValue(undefined), }); const page = fakePage(); + const allPages = [page]; const backgroundPages: ReturnType[] = []; const emit = (event: string, ...args: unknown[]) => { for (const listener of listeners.get(event) ?? []) listener(...args); }; + let context: any; const cdp = { send: vi.fn(async (command: string) => { if (command === 'Target.createTarget') { - const backgroundPage = fakePage(); + const backgroundPage = await context.newPage(); backgroundPages.push(backgroundPage); queueMicrotask(() => emit('page', backgroundPage)); } @@ -31,7 +33,7 @@ function fakeContext() { detach: vi.fn().mockResolvedValue(undefined), }; return { - context: { + context: context = { on(event: string, listener: (...args: unknown[]) => void) { const bucket = listeners.get(event) ?? new Set(); bucket.add(listener); @@ -41,8 +43,12 @@ function fakeContext() { waitForEvent(event: string) { return new Promise((resolve) => this.on(event, resolve)); }, - pages: vi.fn().mockReturnValue([page]), - newPage: vi.fn().mockResolvedValue(page), + pages: vi.fn(() => allPages.filter((page) => !page.isClosed())), + newPage: vi.fn(async () => { + const created = fakePage(); + allPages.push(created); + return created; + }), browser: vi.fn().mockReturnValue({ newBrowserCDPSession: vi.fn().mockResolvedValue(cdp) }), cookies: vi.fn().mockResolvedValue([{ name: 'sid', value: '1', domain: 'example.com', path: '/' }]), close: vi.fn().mockResolvedValue(undefined), @@ -137,10 +143,10 @@ describe('CloakSessionManager', () => { expect(launched.cdp.send).toHaveBeenCalledWith('Target.createTarget', { url: 'about:blank', + newWindow: true, background: true, focus: false, }); - expect(launched.context.newPage).not.toHaveBeenCalled(); }); it('creates an explicit background tab without focusing Chromium', async () => { @@ -160,13 +166,13 @@ describe('CloakSessionManager', () => { expect(launched.cdp.send).toHaveBeenCalledWith('Target.createTarget', { url: 'about:blank', + newWindow: true, background: true, focus: false, }); - expect(launched.context.newPage).not.toHaveBeenCalled(); }); - it('creates an explicit foreground tab through Playwright', async () => { + it('creates an explicit foreground tab in a new CDP window', async () => { const launched = fakeContext(); const manager = new CloakSessionManager({ baseDir: '/tmp/webcmd-test', @@ -180,8 +186,12 @@ describe('CloakSessionManager', () => { windowMode: 'foreground', }); - expect(launched.context.newPage).toHaveBeenCalledOnce(); - expect(launched.cdp.send).not.toHaveBeenCalled(); + expect(launched.cdp.send).toHaveBeenCalledWith('Target.createTarget', { + url: 'about:blank', + newWindow: true, + background: false, + focus: true, + }); }); it('gives concurrent background tabs distinct pages', async () => { @@ -352,12 +362,10 @@ describe('CloakSessionManager', () => { vi.useFakeTimers(); const first = fakeContext(); first.context.pages.mockReturnValue([]); - first.context.newPage.mockImplementation(() => ({ - then(resolve: (page: typeof first.page) => void) { - resolve(first.page); - queueMicrotask(() => first.context.emit('close')); - }, - })); + first.context.newPage.mockImplementation(() => { + queueMicrotask(() => first.context.emit('close')); + return Promise.resolve(first.page); + }); const replacement = fakeContext(); replacement.context.pages.mockReturnValue([]); const launchPersistentContext = vi.fn() @@ -365,7 +373,8 @@ describe('CloakSessionManager', () => { .mockResolvedValueOnce(replacement.context); const manager = new CloakSessionManager({ baseDir: '/tmp/webcmd-test', launchPersistentContext }); - await manager.getPage({ profileId: 'default', session: 'first', surface: 'browser', idleTimeout: 25 }); + await expect(manager.getPage({ profileId: 'default', session: 'first', surface: 'browser', idleTimeout: 25 })) + .rejects.toThrow('Target page, context or browser has been closed'); expect(manager.activeProfileIds()).toEqual([]); expect(vi.getTimerCount()).toBe(0); @@ -445,6 +454,7 @@ describe('CloakSessionManager', () => { resolveNavigation = resolve; }); }); + launched.context.newPage.mockResolvedValue(launched.page); const manager = new CloakSessionManager({ baseDir: '/tmp/webcmd-test', launchPersistentContext: vi.fn().mockResolvedValue(launched.context), @@ -476,6 +486,7 @@ describe('CloakSessionManager', () => { const navigationFailure = new Error('Target page, context or browser has been closed'); const launched = fakeContext(); launched.page.goto.mockRejectedValue(navigationFailure); + launched.context.newPage.mockResolvedValue(launched.page); const launchPersistentContext = vi.fn().mockResolvedValue(launched.context); const manager = new CloakSessionManager({ baseDir: '/tmp/webcmd-test', launchPersistentContext }); @@ -487,7 +498,6 @@ describe('CloakSessionManager', () => { })).rejects.toBe(navigationFailure); expect(launchPersistentContext).toHaveBeenCalledTimes(1); - expect(launched.context.newPage).toHaveBeenCalledTimes(1); expect(launched.page.goto).toHaveBeenCalledTimes(1); expect(launched.page.close).toHaveBeenCalledTimes(1); expect(await manager.listPages({ profileId: 'default' })).toEqual([]); @@ -513,29 +523,10 @@ describe('CloakSessionManager', () => { }); it('freshPage closes the existing persistent lease page and creates a new one', async () => { - const makePage = () => ({ - goto: vi.fn().mockResolvedValue(undefined), - evaluate: vi.fn().mockResolvedValue('ok'), - title: vi.fn().mockResolvedValue('Title'), - url: vi.fn().mockReturnValue('about:blank'), - isClosed: vi.fn().mockReturnValue(false), - close: vi.fn().mockResolvedValue(undefined), - }); - const openPages: ReturnType[] = []; - const context = { - on: vi.fn(), - pages: vi.fn(() => openPages), - newPage: vi.fn(async () => { - const page = makePage(); - openPages.push(page); - return page; - }), - cookies: vi.fn().mockResolvedValue([]), - close: vi.fn().mockResolvedValue(undefined), - }; + const launched = fakeContext(); const manager = new CloakSessionManager({ baseDir: '/tmp/webcmd-test', - launchPersistentContext: vi.fn().mockResolvedValue(context), + launchPersistentContext: vi.fn().mockResolvedValue(launched.context), }); const key = { profileId: 'default', session: 'site:district', surface: 'adapter' as const, siteSession: 'persistent' as const }; @@ -551,31 +542,18 @@ describe('CloakSessionManager', () => { }); it('freshPage never adopts a leftover context tab', async () => { - const leftover = { - goto: vi.fn(), - isClosed: vi.fn().mockReturnValue(false), - close: vi.fn().mockResolvedValue(undefined), - }; - const created = { - goto: vi.fn(), - isClosed: vi.fn().mockReturnValue(false), - close: vi.fn().mockResolvedValue(undefined), - }; - const context = { - on: vi.fn(), - pages: vi.fn().mockReturnValue([leftover]), - newPage: vi.fn().mockResolvedValue(created), - cookies: vi.fn().mockResolvedValue([]), - close: vi.fn().mockResolvedValue(undefined), - }; + const launched = fakeContext(); + const leftover = launched.page; + const created = fakeContext().page; + launched.context.newPage.mockResolvedValue(created); const manager = new CloakSessionManager({ baseDir: '/tmp/webcmd-test', - launchPersistentContext: vi.fn().mockResolvedValue(context), + launchPersistentContext: vi.fn().mockResolvedValue(launched.context), }); const lease = await manager.getPage({ profileId: 'default', session: 'site:district', surface: 'adapter', siteSession: 'persistent', freshPage: true }); expect(lease.page).toBe(created); - expect(context.newPage).toHaveBeenCalled(); + expect(lease.page).not.toBe(leftover); }); it('closes ephemeral adapter sessions when released', async () => { diff --git a/src/browser/runtime/local-cloak/session-manager.ts b/src/browser/runtime/local-cloak/session-manager.ts index 314d9221..bb13fb16 100644 --- a/src/browser/runtime/local-cloak/session-manager.ts +++ b/src/browser/runtime/local-cloak/session-manager.ts @@ -562,18 +562,17 @@ export class CloakSessionManager { } private async createPage(context: BrowserContext, windowMode?: BrowserWindowMode): Promise { - if (windowMode !== 'background') return context.newPage(); - const browser = context.browser(); - if (!browser) throw new Error('Background page creation requires a Chromium browser connection.'); + if (!browser) throw new Error('Cloak page creation requires a Chromium browser connection.'); const cdp = await browser.newBrowserCDPSession(); try { const [page] = await Promise.all([ context.waitForEvent('page'), cdp.send('Target.createTarget', { url: 'about:blank', - background: true, - focus: false, + newWindow: true, + background: windowMode === 'background', + focus: windowMode !== 'background', }), ]); return page; From cb20e0c3c3623ad985504c6f0b7077d5c0dd1068 Mon Sep 17 00:00:00 2001 From: beubax Date: Wed, 12 Aug 2026 02:32:03 +0530 Subject: [PATCH 07/27] feat: document and host browser sessions --- README.md | 8 ++- docs/agents/cursor.md | 4 +- docs/agents/hermes.md | 2 +- docs/agents/opencode.md | 4 +- docs/cli-reference.mdx | 22 +++--- skills/webcmd-adapter-author/SKILL.md | 4 +- .../references/api-discovery.md | 14 ++-- .../references/field-decode-playbook.md | 2 +- .../references/jsdom-fixture-pattern.md | 2 +- .../references/site-recon.md | 12 ++-- .../references/strategy-selection.md | 2 +- skills/webcmd-autofix/SKILL.md | 12 ++-- skills/webcmd-browser-sitemap/SKILL.md | 4 +- skills/webcmd-browser/SKILL.md | 34 +++++----- .../references/browser-run-playwright.md | 4 +- skills/webcmd-sitemap-author/SKILL.md | 4 +- .../references/sitemap-schema.md | 10 +-- skills/webcmd-usage/SKILL.md | 16 ++++- src/browser/analyze.ts | 6 +- src/browser/base-page.ts | 6 +- src/browser/dom-snapshot.ts | 4 +- src/browser/run/playwright-transport.ts | 8 +++ src/browser/run/runner.test.ts | 12 ++++ src/browser/runtime/local-cloak/actions.ts | 4 +- src/browser/target-resolver.ts | 8 +-- src/cli.ts | 2 +- src/completion-shared.ts | 2 +- src/engine.test.ts | 2 +- src/hosted/client.ts | 68 +++++++++++++++++++ src/hosted/runner.test.ts | 40 +++++++++++ src/hosted/runner.ts | 66 +++++++++++++++++- src/hosted/types.ts | 28 ++++++++ src/skills.test.ts | 18 ++--- 33 files changed, 339 insertions(+), 95 deletions(-) diff --git a/README.md b/README.md index fcf2af09..4920f456 100644 --- a/README.md +++ b/README.md @@ -35,12 +35,14 @@ On top of live browser control, Webcmd adds 3 layers of learnings. Each layer co | 3. Extend existing CLIs | The workflow is deterministic enough to stop browsing. | Extend the `webcmd ` adapter with a tailored command so the workflow runs instantly with the least amount of tokens. | For local, multi-step browser exploration, agents can send one sandboxed -Playwright-style program to an existing CloakBrowser session: +Playwright-style program to an explicit browser session: ```bash -webcmd browser work run --file explore.js +webcmd session create -f json +webcmd --session session_abc browser run --file explore.js printf 'const page = await browser.currentPage(); return await page.title();' \ - | webcmd browser work run --stdin + | webcmd --session session_abc browser run --stdin +webcmd session close session_abc ``` ## Demo diff --git a/docs/agents/cursor.md b/docs/agents/cursor.md index ed5af67e..c77bb8b3 100644 --- a/docs/agents/cursor.md +++ b/docs/agents/cursor.md @@ -52,8 +52,8 @@ Use Webcmd for anything on the open web — fetching, authenticated third-party sites, multi-step automation, workflows worth making reusable: - Check `webcmd list -f json` for an adapter that covers the task; use it first. -- Otherwise drive a live browser with `webcmd browser ...` via the shell tool. -- Run `webcmd doctor` first and keep the session lifecycle (`tabs`, `bind`, `snapshot`, `run`, `close`). +- Otherwise create a session, then drive it with `webcmd --session browser ...` via the shell tool. +- Run `webcmd doctor` first; use `webcmd session list` to inspect state and `webcmd session close ` when finished. - For login walls, use Webcmd's human handoff; never type passwords, OTPs, cookies, or credentials. Use the native Browser tool only for the app being edited: localhost dev server, diff --git a/docs/agents/hermes.md b/docs/agents/hermes.md index 3da0be33..cd80892d 100644 --- a/docs/agents/hermes.md +++ b/docs/agents/hermes.md @@ -52,7 +52,7 @@ Hermes' web surface spans three toolsets: **Hermes toggles toolsets, not individual tools.** There is no way to drop `web_extract` while keeping `web_search`, so leave the `web` toolset on and steer the agent with instructions instead. Add this to your Hermes system prompt or project instructions: -> Use Webcmd (`webcmd list`, `webcmd browser ...` via the `terminal` toolset) for anything on the open web: fetching, authenticated third-party sites, multi-step automation. Prefer it over `web_extract`. Use the `browser_*` tools only for the app being edited — localhost dev server, console and network triage, visual checks. Keep using `web_search` and `x_search` to find URLs. +> Use Webcmd (`webcmd list`, then `webcmd session create -f json` and `webcmd --session browser ...` via the `terminal` toolset) for anything on the open web: fetching, authenticated third-party sites, multi-step automation. Prefer it over `web_extract`. Use the `browser_*` tools only for the app being edited — localhost dev server, console and network triage, visual checks. Keep using `web_search` and `x_search` to find URLs. Also check the `computer_use` toolset. It drives the whole desktop rather than a browser, so it overlaps with Webcmd whenever it is aimed at a website. Disable it if the user does not need desktop control. diff --git a/docs/agents/opencode.md b/docs/agents/opencode.md index a880c36a..1b24cc60 100644 --- a/docs/agents/opencode.md +++ b/docs/agents/opencode.md @@ -59,8 +59,8 @@ Deny `webfetch` so OpenCode cannot fall back to it while Webcmd is its browser s | Skills not loading in OpenCode | Run `webcmd skills add` with the `agents` provider, restart OpenCode, and check `/skills`. | | OpenCode still uses `webfetch` | Confirm `permission.webfetch` is `deny` in the active config, then restart OpenCode. | | `websearch` is missing entirely | It registers only with the OpenCode provider or `OPENCODE_ENABLE_EXA=1`. Not a Webcmd problem. | -| `webcmd browser` errors | Read `webcmd-usage` and `webcmd-browser` skills; sessions require a `` name after `browser`. | -| Browser sessions stop working after idle | Ask the agent to open a fresh session or re-bind with `tabs` and `bind --page`. | +| `webcmd browser` errors | Read `webcmd-usage` and `webcmd-browser` skills; create a session and pass its ID as root `--session`. | +| Browser sessions stop working after idle | Ask the agent to create a fresh session or inspect it with `webcmd session list`. | ## See also diff --git a/docs/cli-reference.mdx b/docs/cli-reference.mdx index 6f85e232..86e5f795 100644 --- a/docs/cli-reference.mdx +++ b/docs/cli-reference.mdx @@ -57,14 +57,19 @@ The old `web read` command has been renamed to `web fetch-browser`. ## Local Browser Programs -`browser run` executes one Playwright-style JavaScript program against an -existing local CloakBrowser session: +Create an opaque session before raw browser work. Profiles hold cookie/auth +state; sessions are browser workspaces within that profile. Adapter commands +may omit `--session` and use their profile's default session, but raw browser +commands must always pass it: ```bash -webcmd browser work snapshot --snapshot-mode act -webcmd browser work snapshot --snapshot-mode read -webcmd browser work run --stdin --timeout 45 -webcmd browser work run --stdin --no-snapshot-diff +webcmd session create -f json +webcmd --session session_abc browser snapshot --snapshot-mode act +webcmd --session session_abc browser snapshot --snapshot-mode read +webcmd --session session_abc browser run --stdin --timeout 45 +webcmd --session session_abc browser run --stdin --no-snapshot-diff +webcmd session list +webcmd session close session_abc ``` Use `snapshot` for explicit page inspection. `act` is the default @@ -79,11 +84,12 @@ The program runs in a fresh QuickJS sandbox with `page`, `context`, `browser`, and `console` globals. `page.snapshotForAI()` is not available. It can use the supported Page/Frame/Locator methods and passively inspect request and response events. It cannot access Node.js, the filesystem, environment variables, raw -CDP endpoints, browser launch/connect APIs, or browser-context ownership. +CDP endpoints, browser launch/connect APIs, browser-context ownership, or +`context.newPage()`. Screenshot bytes are written to a Webcmd-owned cache directory and returned as a receipt. -The public browser surface is `tabs`, `bind`, `run`, `snapshot`, and `close`. +The public raw-browser surface is `tabs`, `bind`, `run`, and `snapshot`. Reusable adapters continue to use the existing `IPage` API. Playwright-style programs are for reconnaissance and ad-hoc multi-step work; they are not pasted into adapter modules. diff --git a/skills/webcmd-adapter-author/SKILL.md b/skills/webcmd-adapter-author/SKILL.md index b7d7428d..c6343d55 100644 --- a/skills/webcmd-adapter-author/SKILL.md +++ b/skills/webcmd-adapter-author/SKILL.md @@ -149,8 +149,8 @@ Check these off step by step: [ ] If memory is older than 30 days according to `verified_at`, treat it as stale and use the cold-start path through Steps 3 and 4. [ ] 3. Recon (`site-recon.md`): - [ ] **Preferred:** use `webcmd browser recon run --stdin` for navigation, readiness, network hints, and page evidence in one Playwright-style program. - [ ] Use `webcmd browser recon snapshot --snapshot-mode tree` when structural page evidence is needed. + [ ] **Preferred:** create a session, then use `webcmd --session browser run --stdin` for navigation, readiness, network hints, and page evidence in one Playwright-style program. + [ ] Use `webcmd --session browser snapshot --snapshot-mode tree` when structural page evidence is needed. [ ] Use the run result as reconnaissance evidence; do not copy Playwright code into an adapter. [ ] Choose Pattern A / B / C / D / E. diff --git a/skills/webcmd-adapter-author/references/api-discovery.md b/skills/webcmd-adapter-author/references/api-discovery.md index 1cf9bbf4..c896d904 100644 --- a/skills/webcmd-adapter-author/references/api-discovery.md +++ b/skills/webcmd-adapter-author/references/api-discovery.md @@ -28,7 +28,7 @@ For example, a page on `jobs.51job.com` fetching an API on `cupid.51job.com` wil Probe it explicitly: ```bash -webcmd browser recon run --stdin <<'JS' +webcmd --session browser run --stdin <<'JS' await page.goto('https:///'); return await page.evaluate(async () => { try { @@ -54,7 +54,7 @@ When it is blocked, `credentials: include` is not a CORS fix across subdomains. Use for Pattern A and for deeper data in Pattern B. ```bash -webcmd browser recon run --stdin <<'JS' +webcmd --session browser run --stdin <<'JS' const candidates = []; page.on('response', async response => { const url = response.url(); @@ -92,7 +92,7 @@ Reject candidates that only contain telemetry, unrelated recommendations, beacon Replay directly when possible: ```bash -webcmd browser recon run --stdin <<'JS' +webcmd --session browser run --stdin <<'JS' return await page.evaluate(async () => fetch('', { credentials: 'include' }).then(r => r.text()) ); @@ -136,7 +136,7 @@ Look for: Commands: ```bash -webcmd browser recon run --stdin <<'JS' +webcmd --session browser run --stdin <<'JS' return await page.evaluate(() => ({ globals: Object.keys(window).filter(k => /STATE|DATA|NUXT|APP/i.test(k)), jsonScriptCount: document.querySelectorAll('script[type="application/json"], script:not([src])').length, @@ -154,7 +154,7 @@ Use for Pattern C. Collect script sources: ```bash -webcmd browser recon run --stdin <<'JS' +webcmd --session browser run --stdin <<'JS' return await page.evaluate(() => [...document.querySelectorAll('script[src]')].map(s => s.src)); JS ``` @@ -194,7 +194,7 @@ Find token sources in this order: Useful probes: ```bash -webcmd browser recon run --stdin <<'JS' +webcmd --session browser run --stdin <<'JS' return await page.evaluate(() => ({ csrf: document.querySelector('meta[name="csrf-token"]')?.content ?? null, localStorageKeys: Object.keys(localStorage), @@ -218,7 +218,7 @@ Use only after public API, cookie API, DOM state, and UI selector options are in For page actions: ```bash -webcmd browser recon run --stdin <<'JS' +webcmd --session browser run --stdin <<'JS' const pending = page.waitForResponse(response => response.url().includes('')); await page.locator('').click(); const response = await pending; diff --git a/skills/webcmd-adapter-author/references/field-decode-playbook.md b/skills/webcmd-adapter-author/references/field-decode-playbook.md index 28d67626..809dc5b3 100644 --- a/skills/webcmd-adapter-author/references/field-decode-playbook.md +++ b/skills/webcmd-adapter-author/references/field-decode-playbook.md @@ -18,7 +18,7 @@ Change the site's sort order in the UI, or change known query params, then compa Example workflow: ```bash -webcmd browser recon run --stdin <<'JS' +webcmd --session browser run --stdin <<'JS' const responses = []; page.on('response', async response => { if (!response.url().includes('')) return; diff --git a/skills/webcmd-adapter-author/references/jsdom-fixture-pattern.md b/skills/webcmd-adapter-author/references/jsdom-fixture-pattern.md index a243e646..6c3b4df8 100644 --- a/skills/webcmd-adapter-author/references/jsdom-fixture-pattern.md +++ b/skills/webcmd-adapter-author/references/jsdom-fixture-pattern.md @@ -35,7 +35,7 @@ Temporary debug dumps still belong only in: Use the browser to capture the specific DOM region, not the entire page. ```bash -webcmd browser recon run --stdin <<'JS' +webcmd --session browser run --stdin <<'JS' return await page.evaluate(() => document.querySelector('')?.outerHTML ?? ''); JS ``` diff --git a/skills/webcmd-adapter-author/references/site-recon.md b/skills/webcmd-adapter-author/references/site-recon.md index 61070b74..e2b14454 100644 --- a/skills/webcmd-adapter-author/references/site-recon.md +++ b/skills/webcmd-adapter-author/references/site-recon.md @@ -9,7 +9,7 @@ This file only classifies sites. It does not explain how to discover endpoints. Preferred flow: ```bash -webcmd browser recon run --stdin --snapshot-mode tree <<'JS' +webcmd --session browser run --stdin --snapshot-mode tree <<'JS' const responses = []; page.on('response', response => { const contentType = response.headers()['content-type'] || ''; @@ -42,7 +42,7 @@ JS Then inspect page structure when needed: ```bash -webcmd browser recon snapshot --snapshot-mode tree +webcmd --session browser snapshot --snapshot-mode tree ``` Use this evidence to choose Pattern A/B/C/D/E. Do not paste the Playwright-style program into the adapter. @@ -53,9 +53,9 @@ Use this when the user already has a relevant tab open. List pages, bind the cho then run dependent recon steps together: ```bash -webcmd browser recon tabs -webcmd browser recon bind --page page-123 -webcmd browser recon run --stdin <<'JS' +webcmd --session browser tabs +webcmd --session browser bind --page page-123 +webcmd --session browser run --stdin <<'JS' const responsePromise = page.waitForResponse( response => response.url().includes('/api/path-fragment'), ); @@ -72,7 +72,7 @@ JS Then inspect the current page when a snapshot is needed: ```bash -webcmd browser recon snapshot --snapshot-mode tree +webcmd --session browser snapshot --snapshot-mode tree ``` Use the snapshot and any response evidence collected in the run to classify the site: diff --git a/skills/webcmd-adapter-author/references/strategy-selection.md b/skills/webcmd-adapter-author/references/strategy-selection.md index 4d226999..6bc54e6a 100644 --- a/skills/webcmd-adapter-author/references/strategy-selection.md +++ b/skills/webcmd-adapter-author/references/strategy-selection.md @@ -64,7 +64,7 @@ Use when only page-context `fetch` can reuse same-origin/session/runtime state. Required evidence: -- `webcmd browser recon run --stdin` with page-context `fetch(...)` returns non-empty target data. +- `webcmd --session browser run --stdin` with page-context `fetch(...)` returns non-empty target data. - Simpler strategies are ruled out in the strategy note. - Internal endpoint drift risk is accepted and documented. diff --git a/skills/webcmd-autofix/SKILL.md b/skills/webcmd-autofix/SKILL.md index 5954e571..c1e10bb9 100644 --- a/skills/webcmd-autofix/SKILL.md +++ b/skills/webcmd-autofix/SKILL.md @@ -61,7 +61,7 @@ Persistent-session adapters (`siteSession: 'persistent'`) share one tab per site - Check the trace screenshot and `location.href`: a modal over a blank page or the wrong URL means the tab carried stale DOM from a previous command, not that the site rejected this request. - Check session-scoped context: sites often scope results to a selected city, date, or account. A "closed" / "unavailable" verdict can simply mean the browser's selected context does not match the request (for example, a seat layout opened while the site's location cookie points at another city). -- Reproduce in a separate browser session with `webcmd browser repair-clean run --stdin` before trusting the verdict. If it only fails in the adapter's persistent tab, fix state handling (`freshPage: true`, dismiss-and-renavigate, context preconditions) instead of selectors. +- Reproduce in a separate browser session with `webcmd --session browser run --stdin` before trusting the verdict. If it only fails in the adapter's persistent tab, fix state handling (`freshPage: true`, dismiss-and-renavigate, context preconditions) instead of selectors. ## Step 1: Collect Trace Context @@ -144,18 +144,18 @@ Use `webcmd browser` to inspect the live site. Do not use the broken adapter for For DOM changes: ```bash -webcmd browser repair run --stdin --snapshot-mode tree <<'JS' +webcmd --session browser run --stdin --snapshot-mode tree <<'JS' await page.goto('https://example.com/target-page'); await page.waitForLoadState('domcontentloaded'); return { url: page.url(), title: await page.title() }; JS -webcmd browser repair snapshot --snapshot-mode tree +webcmd --session browser snapshot --snapshot-mode tree ``` For API changes: ```bash -webcmd browser repair run --stdin <<'JS' +webcmd --session browser run --stdin <<'JS' const responses = []; page.on('response', async response => { if (!response.url().includes('')) return; @@ -290,8 +290,8 @@ In all stop cases, clearly report the situation instead of making speculative pa -> Page loaded, but post cards now use "[data-testid=post-container]" 4. Agent explores: - -> webcmd browser repair run --stdin --snapshot-mode tree - -> webcmd browser repair snapshot --snapshot-mode tree + -> webcmd --session browser run --stdin --snapshot-mode tree + -> webcmd --session browser snapshot --snapshot-mode tree 5. Agent patches adapterSourcePath: -> Replace old selector with stable scoped selector diff --git a/skills/webcmd-browser-sitemap/SKILL.md b/skills/webcmd-browser-sitemap/SKILL.md index e25b57e1..6cbf0f45 100644 --- a/skills/webcmd-browser-sitemap/SKILL.md +++ b/skills/webcmd-browser-sitemap/SKILL.md @@ -6,7 +6,7 @@ allowed-tools: Bash(webcmd:*), Read, Edit, Write, Grep # webcmd-browser-sitemap -Use this skill when `webcmd browser run --stdin` or an adapter trace reports `sitemap.available: true`, or when the user asks you to use a site's sitemap. +Use this skill when `webcmd --session browser run --stdin` or an adapter trace reports `sitemap.available: true`, or when the user asks you to use a site's sitemap. The sitemap is **prior knowledge**, not ground truth. It should reduce blind clicking, but it must never override the live browser state. @@ -14,7 +14,7 @@ The sitemap is **prior knowledge**, not ground truth. It should reduce blind cli ## Consumption Loop -1. Run or reuse `webcmd browser snapshot --snapshot-mode tree` to know the current page. +1. Run or reuse `webcmd --session browser snapshot --snapshot-mode tree` to know the current page. 2. Read only the smallest relevant sitemap files: - `SITE.md` for site-level orientation. - One matching `pages/.md` for current state. diff --git a/skills/webcmd-browser/SKILL.md b/skills/webcmd-browser/SKILL.md index bda8421b..600f3048 100644 --- a/skills/webcmd-browser/SKILL.md +++ b/skills/webcmd-browser/SKILL.md @@ -30,42 +30,42 @@ Until `doctor` is green, browser commands may fail. Registry and plugin discover ## Session lifecycle -- `webcmd browser *` commands require a `` positional immediately after `browser`. -- Use the same session name for a multi-step flow; use a different name to isolate parallel browser work. +- Create an opaque browser session before raw browser work: `webcmd session create -f json`. +- Raw `webcmd browser *` commands require that ID at the root: `webcmd --session browser ...`; positional `webcmd browser ...` is retired. +- Profiles are cookie jars and auth scope; sessions are browser workspaces/windows within a profile. Parallel agents use separate sessions. +- `webcmd session list` shows sessions and their handoff/runtime state; close finished work with `webcmd session close `. - Browser state in the bound page persists between calls, but each `run` gets a fresh JavaScript scope. -- `webcmd browser tabs` lists existing pages without creating a new one. -- `webcmd browser bind --page ` explicitly attaches a session to an existing page. -- `webcmd browser close` releases the session when finished. +- `webcmd --session browser tabs` lists existing pages without creating a new one. +- `webcmd --session browser bind --page ` explicitly attaches the session to an existing page. - If the user manually signs in or changes the visible tab, re-bind or inspect with a fresh snapshot before continuing. --- ## Command surface -The raw surface is `tabs`, `bind --page`, `snapshot`, `run`, and `close`. +The raw surface is `tabs`, `bind --page`, `snapshot`, and `run`; close through `webcmd session close`. Common calls: -1. `webcmd browser work tabs` lists existing pages and is read-only. -2. `webcmd browser work bind --page page-123` is an explicit bind that selects one page for `work`. -3. `webcmd browser work snapshot --snapshot-mode act` inspects actionable controls. Use `--snapshot-mode tree` for fuller page structure or `--snapshot-mode read` for readable article/content text. -4. `webcmd browser work run --stdin` runs one JavaScript program with fresh JavaScript scope and persistent browser state in the bound page. -5. `webcmd browser work close` detaches and closes the session when finished. +1. `webcmd --session browser tabs` lists existing pages and is read-only. +2. `webcmd --session browser bind --page page-123` is an explicit bind that selects one page. +3. `webcmd --session browser snapshot --snapshot-mode act` inspects actionable controls. Use `--snapshot-mode tree` for fuller page structure or `--snapshot-mode read` for readable article/content text. +4. `webcmd --session browser run --stdin` runs one JavaScript program with fresh JavaScript scope and persistent browser state in the bound page. +5. `webcmd session close ` closes the session when finished. | command | use | | --- | --- | | `tabs` | List existing pages. Read-only. | -| `bind --page ` | Bind a named session to an existing page. | +| `bind --page ` | Bind this session to an existing page. | | `snapshot --snapshot-mode act` | Inspect actionable controls. | | `snapshot --snapshot-mode tree` | Inspect fuller page structure. | | `snapshot --snapshot-mode read` | Extract readable article/content text. | | `run --stdin` / `run --file ` | Execute one Playwright-style program with `page`, `context`, `browser`, and `console`. | -| `close` | Release the browser session. | Keep related browser actions in one `run` and return compact JSON-compatible data. Successful runs return `snapshotDiff` automatically. Use `--no-snapshot-diff` only when the program is pure read-only and its result already contains the needed state. Do not call the legacy semantic-snapshot page helper; it is not part of Webcmd's Playwright runtime. ```bash -webcmd browser work run --stdin <<'JS' +webcmd --session session_abc browser run --stdin <<'JS' await page.goto('https://example.com'); await page.getByRole('link', { name: 'More information' }).click(); return { title: await page.title(), url: page.url() }; @@ -95,7 +95,7 @@ Prefer one `run` over shell-chaining multiple browser calls. It keeps Playwright Good: ```bash -webcmd browser checkout run --stdin <<'JS' +webcmd --session session_abc browser run --stdin <<'JS' await page.goto('https://example.com/cart'); const pending = page.waitForResponse(r => r.url().includes('/api/checkout')); await page.getByRole('button', { name: /checkout/i }).click(); @@ -136,7 +136,7 @@ For CAPTCHA or raw user takeover, stop automation, give the user any viewer URL For native controls, inspect structure first, then use Playwright's normal form APIs inside `run`. Do not guess date formats, option labels, or file constraints from memory; read them from the DOM. ```bash -webcmd browser form run --stdin <<'JS' +webcmd --session session_abc browser run --stdin <<'JS' await page.goto('https://example.com/form'); const country = page.locator('select[name="country"]'); return { @@ -157,7 +157,7 @@ For custom React/Radix/shadcn/Material UI dropdowns, use semantic locators and v ### Capture a request triggered by UI ```bash -webcmd browser app run --stdin <<'JS' +webcmd --session session_abc browser run --stdin <<'JS' await page.goto('https://example.com/search'); const pending = page.waitForResponse(r => r.url().includes('/api/search')); await page.getByRole('textbox', { name: /search/i }).fill('browser automation'); diff --git a/skills/webcmd-browser/references/browser-run-playwright.md b/skills/webcmd-browser/references/browser-run-playwright.md index c6de193a..fdb2ac31 100644 --- a/skills/webcmd-browser/references/browser-run-playwright.md +++ b/skills/webcmd-browser/references/browser-run-playwright.md @@ -4,6 +4,8 @@ `run` evaluates the supplied JavaScript in a fresh sandbox. Browser state in the bound session persists, but JavaScript variables and handles do not. `page`, `context`, `browser`, and `console` are normal Playwright globals; use the vendored Playwright client as the API reference. Return only JSON-compatible data. `page.snapshotForAI()` is not available. +`context.newPage()` is not available inside `run`; create or bind Session tabs through Webcmd commands so page ownership stays deterministic. + ## Artifact paths Artifacts written by Playwright must use a relative logical filename. Webcmd returns an artifact receipt with its locator; it does not grant host-path write access. @@ -14,7 +16,7 @@ Artifacts written by Playwright must use a relative logical filename. Webcmd ret ## Snapshot behavior -Use `webcmd browser snapshot --snapshot-mode act` to inspect actionable controls, `--snapshot-mode tree` for fuller page structure, or `--snapshot-mode read` for readable article/content text. Successful runs return `snapshotDiff` automatically and support `--snapshot-mode act|tree`; pass `--no-snapshot-diff` only for pure read-only code when its result already contains the needed state. A failed post-run snapshot becomes a warning, not a successful result change. +Use `webcmd --session browser snapshot --snapshot-mode act` to inspect actionable controls, `--snapshot-mode tree` for fuller page structure, or `--snapshot-mode read` for readable article/content text. Successful runs return `snapshotDiff` automatically and support `--snapshot-mode act|tree`; pass `--no-snapshot-diff` only for pure read-only code when its result already contains the needed state. A failed post-run snapshot becomes a warning, not a successful result change. ## Timing diff --git a/skills/webcmd-sitemap-author/SKILL.md b/skills/webcmd-sitemap-author/SKILL.md index 826b09d7..838a030a 100644 --- a/skills/webcmd-sitemap-author/SKILL.md +++ b/skills/webcmd-sitemap-author/SKILL.md @@ -45,7 +45,7 @@ The 800-token target remains the audit threshold. If a file exceeds it, either e ## Authoring Loop 1. Load existing memory: local overlay first, then global seed if present. -2. Verify reality with `webcmd browser state`, `find`, `network`, and `analyze`. Browser state is truth. +2. Verify reality with `webcmd --session browser state`, `find`, `network`, and `analyze`. Browser state is truth. 3. If you just completed `webcmd-adapter-author` for this site, seed from retained browse traces under `~/.webcmd/sites//traces/` instead of rediscovering from zero. 4. Record durable structure only: page purpose, stable anchors, state signatures, actions, workflows, API references, pitfalls. 5. Use stable ids for pages, actions, and workflows. They should survive URL params, locale text drift, and minor layout changes. @@ -116,7 +116,7 @@ Start fallback paths with trigger condition plus `adapter_health_update`: ```yaml on_adapter_fail: - adapter_health_update: webcmd twitter post -> suspect - - webcmd browser snapshot --snapshot-mode tree (verify current page) + - webcmd --session browser snapshot --snapshot-mode tree (verify current page) - if not on /home: goto /home - action:open_compose in pages/home.md ``` diff --git a/skills/webcmd-sitemap-author/references/sitemap-schema.md b/skills/webcmd-sitemap-author/references/sitemap-schema.md index 19fafa4e..4ce438ae 100644 --- a/skills/webcmd-sitemap-author/references/sitemap-schema.md +++ b/skills/webcmd-sitemap-author/references/sitemap-schema.md @@ -69,7 +69,7 @@ kind: site | page | partial | workflow | apis | pitfalls | draft id: stable-id status: verified | draft | stale verified_at: YYYY-MM-DD -source: webcmd browser snapshot --snapshot-mode tree | trace: | adapter:/ +source: webcmd --session browser snapshot --snapshot-mode tree | trace: | adapter:/ --- ``` @@ -145,7 +145,7 @@ kind: page id: repo status: verified verified_at: YYYY-MM-DD -source: webcmd browser snapshot --snapshot-mode tree +source: webcmd --session browser snapshot --snapshot-mode tree url_patterns: - https://github.com/*/* state_signature: @@ -222,7 +222,7 @@ kind: partial id: post-card status: verified verified_at: YYYY-MM-DD -source: webcmd browser snapshot --snapshot-mode tree +source: webcmd --session browser snapshot --snapshot-mode tree url_patterns: [] scope_root: article[role="article"] --- @@ -302,7 +302,7 @@ Read an issue and extract title, author, body, labels, and comments. on_adapter_fail: - adapter_health_update: webcmd github issue -> suspect - - webcmd browser snapshot --snapshot-mode tree + - webcmd --session browser snapshot --snapshot-mode tree - action:open_issue in pages/repo.md - action:extract_comments in pages/issue.md @@ -462,7 +462,7 @@ Do not delete stale evidence immediately. Mark it stale with reason and next ste ```yaml status: stale stale_reason: selector `[data-testid=old]` missing -next: rerun `webcmd browser run --stdin` against the current URL +next: rerun `webcmd --session browser run --stdin` against the current URL ``` ## 12. Security And Privacy diff --git a/skills/webcmd-usage/SKILL.md b/skills/webcmd-usage/SKILL.md index 48a45be5..ee66485c 100644 --- a/skills/webcmd-usage/SKILL.md +++ b/skills/webcmd-usage/SKILL.md @@ -34,7 +34,7 @@ Do not install Node.js or silently fall back to `npx`. ## The Three Pillars - **Adapter commands:** `webcmd [...]`. Core ships no site adapters; every site — official and community — lives as an independently installable plugin under `plugins//` in the main repo, or `~/.webcmd/plugins//` once installed. Private iteration adapters live in `~/.webcmd/clis/`. A command in `~/.webcmd/clis//.js` takes precedence over the same command from an installed plugin. Each command has a strategy such as `PUBLIC`, `COOKIE`, `INTERCEPT`, `UI`, or `LOCAL`. -- **Browser driving:** use an existing adapter command first; otherwise load `webcmd-browser` and run Playwright. +- **Browser driving:** use an existing adapter command first; otherwise load `webcmd-browser`, create a session, and run Playwright with root `--session `. - **External CLI passthrough:** `webcmd gh`, `webcmd docker`, `webcmd vercel`, and similar wrappers. Manage them with `webcmd external install ` or `webcmd external register `. **REQUIRED SUB-SKILL:** Before raw browser work, load `webcmd-browser`. @@ -57,6 +57,20 @@ npx tsx src/main.ts `webcmd doctor` reports daemon status, runtime connection, version checks, and live browser connectivity. It is required for `COOKIE`, `INTERCEPT`, `UI`, and `webcmd browser *` work. It is not required for `PUBLIC`, `LOCAL`, `webcmd list`, `validate`, `verify`, plugin commands, or external CLI passthrough. +## Sessions + +Profiles are cookie jars and authentication scope. Sessions are browser workspaces/windows within a profile. Create one for each parallel raw-browser agent, then route every raw command through the opaque ID: + +```bash +webcmd session create -f json +webcmd --session session_abc browser snapshot --snapshot-mode act +webcmd --session session_abc browser run --stdin +webcmd session list +webcmd session close session_abc +``` + +Adapter commands may omit `--session` and use the selected profile's adapter-default session. Pass `--session ` to route one into an explicit session. Raw `webcmd browser` commands never omit it; retired `webcmd browser ...` syntax is invalid. + ## Prerequisites By Strategy | Strategy | Needs | diff --git a/src/browser/analyze.ts b/src/browser/analyze.ts index 95d224b5..13e0e28c 100644 --- a/src/browser/analyze.ts +++ b/src/browser/analyze.ts @@ -507,15 +507,15 @@ export function analyzeSite( } else if (pattern.pattern === 'A') { next = 'Inspect `api_candidates`, then replay the best endpoint and record the status/content-type/sample shape in your strategy note; do not choose API strategy from XHR count alone.'; } else if (pattern.pattern === 'B') { - next = 'Read the SSR global with `webcmd browser run --stdin` and page.evaluate(() => window.__INITIAL_STATE__ ?? window.__NUXT__ ?? window.__NEXT_DATA__ ?? window.__APOLLO_STATE__) — no API needed.'; + next = 'Read the SSR global with `webcmd --session browser run --stdin` and page.evaluate(() => window.__INITIAL_STATE__ ?? window.__NUXT__ ?? window.__NEXT_DATA__ ?? window.__APOLLO_STATE__) — no API needed.'; } else if (pattern.pattern === 'C') { - next = 'No API visible — use `webcmd browser snapshot --snapshot-mode read` or DOM extraction inside `browser run` against the rendered page.'; + next = 'No API visible — use `webcmd --session browser snapshot --snapshot-mode read` or DOM extraction inside `browser run` against the rendered page.'; } else if (pattern.pattern === 'D') { next = 'Endpoints need auth. Re-open the page from a signed-in session, then retry analyze; see `field-decode-playbook` §4 for token tracing.'; } else if (pattern.pattern === 'E') { next = 'WebSocket stream detected — find the underlying HTTP poll/long-poll endpoint; raw WS is not supported.'; } else { - next = 'No strong signal. Use `webcmd browser run --stdin` with response listeners and pick a pattern from the captured evidence.'; + next = 'No strong signal. Use `webcmd --session browser run --stdin` with response listeners and pick a pattern from the captured evidence.'; } return { diff --git a/src/browser/base-page.ts b/src/browser/base-page.ts index 2993e4ce..d084be0f 100644 --- a/src/browser/base-page.ts +++ b/src/browser/base-page.ts @@ -727,7 +727,7 @@ export abstract class BasePage implements IPage { throw new TargetError({ code: 'not_checkable', message: `Target "${ref}" is not a checkbox, radio, switch, or aria-checked control.`, - hint: 'Use `webcmd browser snapshot --snapshot-mode tree` to pick an input[type=checkbox], input[type=radio], or role=checkbox/switch target.', + hint: 'Use `webcmd --session browser snapshot --snapshot-mode tree` to pick an input[type=checkbox], input[type=radio], or role=checkbox/switch target.', }); } if (before.disabled) { @@ -828,7 +828,7 @@ export abstract class BasePage implements IPage { throw new TargetError({ code: 'not_file_input', message: `Target "${ref}" is not an input[type=file].`, - hint: 'Use `webcmd browser snapshot --snapshot-mode tree` or a targeted `browser run` query to find an input[type=file].', + hint: 'Use `webcmd --session browser snapshot --snapshot-mode tree` or a targeted `browser run` query to find an input[type=file].', }); } if (files.length > 1 && !info?.multiple) { @@ -984,7 +984,7 @@ export abstract class BasePage implements IPage { throw new TargetError({ code: 'not_editable', message: `Target "${ref}" is not a fillable input, textarea, or contenteditable element.`, - hint: 'Use `webcmd browser snapshot --snapshot-mode tree` to pick an editable target, or use `browser run` for keyboard-like interactions.', + hint: 'Use `webcmd --session browser snapshot --snapshot-mode tree` to pick an editable target, or use `browser run` for keyboard-like interactions.', }); } diff --git a/src/browser/dom-snapshot.ts b/src/browser/dom-snapshot.ts index 41f95e38..024689cd 100644 --- a/src/browser/dom-snapshot.ts +++ b/src/browser/dom-snapshot.ts @@ -861,7 +861,7 @@ export function generateSnapshotJs(opts: DomSnapshotOptions = {}): string { if (!doc || !doc.body) { const attrs = serializeAttrs(el); const frameLabel = '[F' + crossOriginIndex + ']'; - lines.push(indent + '|iframe|' + frameLabel + ' (cross-origin, use: webcmd browser run --stdin and page.frames()[index])'); + lines.push(indent + '|iframe|' + frameLabel + ' (cross-origin, use: webcmd --session browser run --stdin and page.frames()[index])'); crossOriginIndex++; return false; } @@ -876,7 +876,7 @@ export function generateSnapshotJs(opts: DomSnapshotOptions = {}): string { } catch { const attrs = serializeAttrs(el); const frameLabel = '[F' + crossOriginIndex + ']'; - lines.push(indent + '|iframe|' + frameLabel + ' (blocked, use: webcmd browser run --stdin and page.frames()[index])'); + lines.push(indent + '|iframe|' + frameLabel + ' (blocked, use: webcmd --session browser run --stdin and page.frames()[index])'); crossOriginIndex++; return false; } diff --git a/src/browser/run/playwright-transport.ts b/src/browser/run/playwright-transport.ts index e42431b5..713602b6 100644 --- a/src/browser/run/playwright-transport.ts +++ b/src/browser/run/playwright-transport.ts @@ -95,6 +95,14 @@ function scopedContext(context: object, pages: () => object[]): object { proxy = new Proxy(context, { get(target, property) { if (property === 'pages') return pages; + if (property === 'newPage') { + return async () => { + throw new BrowserRunError( + 'BROWSER_RUN_API_UNSUPPORTED', + 'context.newPage() is not supported inside browser run; create or bind a Webcmd Session tab instead.', + ); + }; + } if (property === 'on' || property === 'addListener') { return (event: string, listener: Function) => { let registered = listener; diff --git a/src/browser/run/runner.test.ts b/src/browser/run/runner.test.ts index e987828a..d39b6b70 100644 --- a/src/browser/run/runner.test.ts +++ b/src/browser/run/runner.test.ts @@ -286,6 +286,18 @@ describe('runBrowserProgram', () => { }); }); + it('rejects context.newPage so browser-run cannot create unowned session pages', async () => { + await expect(runBrowserProgram({ + browser, + context, + page, + pageId: 'page-1', + pages: [page], + }, ` + await context.newPage(); + `)).rejects.toThrow(/context\.newPage\(\) is not supported/); + }); + it('waits for requests and responses', async () => { const output = await run(` const requestPromise = page.waitForRequest('**/data'); diff --git a/src/browser/runtime/local-cloak/actions.ts b/src/browser/runtime/local-cloak/actions.ts index abd2f025..698b8599 100644 --- a/src/browser/runtime/local-cloak/actions.ts +++ b/src/browser/runtime/local-cloak/actions.ts @@ -444,7 +444,7 @@ export async function dispatchCloakAction(manager: CloakSessionManager, command: ok: false, errorCode: 'invalid_request', error: 'Bind requires --page or --index for a Cloak runtime tab', - errorHint: 'Run `webcmd browser tab list`, then retry with `webcmd browser bind --page `.', + errorHint: 'Run `webcmd --session browser tab list`, then retry with `webcmd --session browser bind --page `.', }; } { @@ -464,7 +464,7 @@ export async function dispatchCloakAction(manager: CloakSessionManager, command: ok: false, errorCode: 'bound_tab_not_found', error: 'Cloak tab not found for bind target', - errorHint: 'Run `webcmd browser tab list` and choose a current Cloak tab id or index.', + errorHint: 'Run `webcmd --session browser tab list` and choose a current Cloak tab id or index.', }; } return { diff --git a/src/browser/target-resolver.ts b/src/browser/target-resolver.ts index 7ffe5de5..39d064de 100644 --- a/src/browser/target-resolver.ts +++ b/src/browser/target-resolver.ts @@ -187,7 +187,7 @@ export function resolveTargetJs(ref: string, opts: ResolveOptions = {}): string ok: false, code: 'not_found', message: 'ref=' + ref + ' not found in DOM', - hint: 'The element may have been removed. Re-run \`webcmd browser snapshot --snapshot-mode tree\` to get a fresh snapshot.', + hint: 'The element may have been removed. Re-run \`webcmd --session browser snapshot --snapshot-mode tree\` to get a fresh snapshot.', }; } @@ -224,7 +224,7 @@ export function resolveTargetJs(ref: string, opts: ResolveOptions = {}): string code: 'stale_ref', message: 'ref=' + ref + ' was <' + fp.tag + '>' + (fp.text ? '"' + fp.text + '"' : '') + ' but now points to <' + liveFp.tag + '>' + (liveFp.text ? '"' + liveFp.text.slice(0, 30) + '"' : ''), - hint: 'The page has changed since the last snapshot. Re-run \`webcmd browser snapshot --snapshot-mode tree\` to refresh.', + hint: 'The page has changed since the last snapshot. Re-run \`webcmd --session browser snapshot --snapshot-mode tree\` to refresh.', }; } @@ -247,7 +247,7 @@ export function resolveTargetJs(ref: string, opts: ResolveOptions = {}): string ok: false, code: 'selector_not_found', message: 'CSS selector "' + ref + '" matched 0 elements', - hint: 'The element may not exist or may be hidden. Re-run \`webcmd browser snapshot --snapshot-mode tree\` to check, or use \`webcmd browser run --stdin\` for a targeted selector query.', + hint: 'The element may not exist or may be hidden. Re-run \`webcmd --session browser snapshot --snapshot-mode tree\` to check, or use \`webcmd --session browser run --stdin\` for a targeted selector query.', matches_n: 0, }; } @@ -280,7 +280,7 @@ export function resolveTargetJs(ref: string, opts: ResolveOptions = {}): string ok: false, code: 'selector_ambiguous', message: 'CSS selector "' + ref + '" matched ' + matches.length + ' elements', - hint: 'Pass --nth (0-based) to pick one, or use a more specific selector. Use \`webcmd browser run --stdin\` to list matching candidates.', + hint: 'Pass --nth (0-based) to pick one, or use a more specific selector. Use \`webcmd --session browser run --stdin\` to list matching candidates.', candidates: candidates, matches_n: matches.length, }; diff --git a/src/cli.ts b/src/cli.ts index 7a9f23bf..e5acc30d 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -912,7 +912,7 @@ cli({ fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(filePath, template, 'utf-8'); console.log(`Created: ${filePath}`); - console.log('First time on this site? Run: webcmd browser recon run --stdin'); + console.log('First time on this site? Run: webcmd session create, then webcmd --session browser run --stdin'); console.log(`Edit the file to implement your adapter, then run: webcmd browser verify ${name}`); } catch (err) { console.error(`Error: ${err instanceof Error ? err.message : String(err)}`); diff --git a/src/completion-shared.ts b/src/completion-shared.ts index 36932766..f4a41caa 100644 --- a/src/completion-shared.ts +++ b/src/completion-shared.ts @@ -30,7 +30,7 @@ export const HOSTED_ROOT_HELP: RootHelpPresentation = { description: 'Make any website your CLI. Zero setup. AI-powered.', usage: [ `${CLI_COMMAND} [args] [options]`, - `${CLI_COMMAND} browser [args] [options]`, + `${CLI_COMMAND} --session browser [args] [options]`, `${CLI_COMMAND} list [options]`, `${CLI_COMMAND} setup`, ], diff --git a/src/engine.test.ts b/src/engine.test.ts index eaa3e9bf..3e4bf58f 100644 --- a/src/engine.test.ts +++ b/src/engine.test.ts @@ -135,7 +135,7 @@ cli({ expect(sessionOpts).toHaveLength(1); expect(sessionOpts[0]).toMatchObject({ - session: `site:${site}`, + session: undefined, siteSession: 'persistent', freshPage: true, }); diff --git a/src/hosted/client.ts b/src/hosted/client.ts index fe0969a7..d0a9ed44 100644 --- a/src/hosted/client.ts +++ b/src/hosted/client.ts @@ -4,6 +4,9 @@ import type { HostedBrowserActionResponse, HostedBrowserFinishRequest, HostedBrowserFinishResponse, + HostedBrowserSessionCloseResponse, + HostedBrowserSessionResponse, + HostedBrowserSessionsResponse, HostedBrowserRunActionInput, HostedBrowserRunActionResponse, HostedBrowserSnapshotActionResponse, @@ -104,6 +107,33 @@ export class HostedClient { return { ok: true, deleted: true }; } + async createBrowserSession(profile?: string): Promise { + const body = await this.request('/v1/sessions', { + method: 'POST', + body: JSON.stringify(profile !== undefined ? { profile } : {}), + }); + if (!isHostedBrowserSessionResponse(body)) { + throw protocolError('Webcmd Cloud returned an invalid browser session response.'); + } + return body; + } + + async listBrowserSessions(profile?: string): Promise { + const body = await this.request(`/v1/sessions${profileQuery(profile)}`); + if (!isHostedBrowserSessionsResponse(body)) { + throw protocolError('Webcmd Cloud returned an invalid browser session list.'); + } + return body; + } + + async closeBrowserSession(session: string, profile?: string): Promise { + const body = await this.request(`/v1/sessions/${encodeURIComponent(session)}${profileQuery(profile)}`, { method: 'DELETE' }); + if (!isHostedBrowserSessionCloseResponse(body)) { + throw protocolError('Webcmd Cloud returned an invalid browser session close response.'); + } + return body; + } + async searchMarketplacePlugins(query?: string): Promise { const params = new URLSearchParams(); if (query !== undefined) params.set('query', query); @@ -458,6 +488,44 @@ function isHostedProfilesResponse(value: unknown): value is HostedProfilesRespon && value.profiles.every(isHostedPublicProfile); } +function isHostedBrowserSessionResponse(value: unknown): value is HostedBrowserSessionResponse { + return hasExactKeys(value, ['ok', 'result']) + && value.ok === true + && isHostedBrowserSession(value.result); +} + +function isHostedBrowserSessionsResponse(value: unknown): value is HostedBrowserSessionsResponse { + return hasExactKeys(value, ['ok', 'result']) + && value.ok === true + && Array.isArray(value.result) + && value.result.every(isHostedBrowserSession); +} + +function isHostedBrowserSessionCloseResponse(value: unknown): value is HostedBrowserSessionCloseResponse { + return hasExactKeys(value, ['ok', 'result']) + && value.ok === true + && hasExactKeys(value.result, ['closed', 'alreadyIdle', 'session']) + && typeof value.result.closed === 'boolean' + && typeof value.result.alreadyIdle === 'boolean' + && typeof value.result.session === 'string'; +} + +function isHostedBrowserSession(value: unknown): boolean { + return hasExactKeys(value, ['id', 'kind', 'profileId', 'runtimeState', 'createdAt', 'lastUsedAt']) + && typeof value.id === 'string' + && value.kind === 'browser' + && typeof value.profileId === 'string' + && (value.runtimeState === 'active' || value.runtimeState === 'idle') + && typeof value.createdAt === 'string' + && typeof value.lastUsedAt === 'string'; +} + +function profileQuery(profile: string | undefined): string { + if (profile === undefined) return ''; + const params = new URLSearchParams({ profile }); + return `?${params}`; +} + function isHostedMarketplaceSearchResult(value: unknown): value is HostedMarketplaceSearchResult { return hasExactKeys(value, ['plugins', 'errors']) && Array.isArray(value.plugins) diff --git a/src/hosted/runner.test.ts b/src/hosted/runner.test.ts index 175c5c73..90009ac7 100644 --- a/src/hosted/runner.test.ts +++ b/src/hosted/runner.test.ts @@ -545,6 +545,46 @@ describe('runHostedCli', () => { expect(requests.some(request => request.url.endsWith('/v1/manifest'))).toBe(false); }); + it('manages hosted browser sessions without contacting the local daemon or manifest', async () => { + const requests: Array<{ url: string; method: string; body?: unknown }> = []; + const session = { + id: 'session_abc', kind: 'browser', profileId: 'profile_work', runtimeState: 'idle', + createdAt: '2026-01-01T00:00:00.000Z', lastUsedAt: '2026-01-01T00:00:00.000Z', + }; + const fetchImpl = vi.fn(async (url, init) => { + const request = { + url: String(url), method: init?.method ?? 'GET', + ...(init?.body ? { body: JSON.parse(String(init.body)) } : {}), + }; + requests.push(request); + if (request.method === 'POST') return new Response(JSON.stringify({ ok: true, result: session })); + if (request.method === 'DELETE') return new Response(JSON.stringify({ ok: true, result: { closed: false, alreadyIdle: true, session: session.id } })); + return new Response(JSON.stringify({ ok: true, result: [session] })); + }); + + for (const argv of [ + ['--profile', 'work', 'session', 'create', '-f', 'json'], + ['--profile', 'work', 'session', 'list', '-f', 'json'], + ['--profile', 'work', 'session', 'close', session.id, '-f', 'json'], + ]) { + const stdout = sink(); + const result = await runHostedCli(argv, { + config: makeHostedConfig({ apiBaseUrl: 'https://api.example.com', apiKey: 'key' }), + stdout: stdout.stream, + fetchImpl, + }); + expect(result).toEqual({ handled: true, exitCode: 0 }); + expect(stdout.text()).toContain(session.id); + } + + expect(requests).toEqual([ + { url: 'https://api.example.com/v1/sessions', method: 'POST', body: { profile: 'work' } }, + { url: 'https://api.example.com/v1/sessions?profile=work', method: 'GET' }, + { url: 'https://api.example.com/v1/sessions/session_abc?profile=work', method: 'DELETE' }, + ]); + expect(requests.some(request => request.url.endsWith('/v1/manifest'))).toBe(false); + }); + it.each(['create', 'get'])('rejects the removed profile %s subcommand', async (command) => { const stderr = sink(); const fetchImpl = vi.fn(); diff --git a/src/hosted/runner.ts b/src/hosted/runner.ts index 0991442d..602d7075 100644 --- a/src/hosted/runner.ts +++ b/src/hosted/runner.ts @@ -191,6 +191,15 @@ async function dispatchHosted( LOCAL_ONLY_COMMAND_HELP, ); } + if (args[0] === 'session') { + const parsed = parseHostedSessionSurface(args.slice(1), normalized.literal); + if (parsed.kind === 'help') { + await writeToStream(stdout, parsed.output); + return; + } + await dispatchHostedSession(parsed, client, stdout, normalized.profile); + return; + } if (args[0] === 'browser') { const invocation = await parseHostedBrowserInvocation(args, normalized.profile, normalized.session); const manifest = await client.getManifest(); @@ -435,6 +444,61 @@ async function dispatchHosted( } } +type ParsedHostedSessionSurface = + | { kind: 'help'; output: string } + | { kind: 'run'; command: 'create' | 'list' | 'close'; format: string; session?: string }; + +function parseHostedSessionSurface(argv: readonly string[], literal: boolean): ParsedHostedSessionSurface { + let stdout = ''; + let stderr = ''; + let parsed: Exclude | undefined; + const root = new Command('webcmd'); + const session = root.command('session').description('Create, list, and close browser Sessions'); + const output = { + writeOut: (value: string) => { stdout += value; }, + writeErr: (value: string) => { stderr += value; }, + }; + root.exitOverride().configureOutput(output); + session.exitOverride().configureOutput(output); + const configure = (command: Command, format: string): Command => command.option('-f, --format ', 'Output format: table, json, yaml', format); + configure(session.command('create'), 'yaml').action((options: { format: string }) => { parsed = { kind: 'run', command: 'create', format: options.format }; }); + configure(session.command('list'), 'table').action((options: { format: string }) => { parsed = { kind: 'run', command: 'list', format: options.format }; }); + configure(session.command('close').argument(''), 'yaml').action((sessionId: string, options: { format: string }) => { + parsed = { kind: 'run', command: 'close', format: options.format, session: sessionId }; + }); + try { + root.parse(literal ? ['--', 'session', ...argv] : ['session', ...argv], { from: 'user' }); + } catch (error) { + if (!(error instanceof CommanderError)) throw error; + if (error.code === 'commander.helpDisplayed') return { kind: 'help', output: stdout }; + throw new CommanderStructuralError(stderr || `${error.message}\n`, error.exitCode); + } + if (!parsed) throw new CommanderStructuralError("error: command 'session' did not run\n", 1); + return parsed; +} + +async function dispatchHostedSession( + parsed: Exclude, + client: HostedClient, + stdout: NodeJS.WritableStream, + profile?: string, +): Promise { + if (parsed.command === 'create') { + await renderOutput((await client.createBrowserSession(profile)).result, { fmt: parsed.format, columns: ['id', 'kind', 'profileId'], stdout }); + return; + } + if (parsed.command === 'list') { + const rows = (await client.listBrowserSessions(profile)).result; + if (rows.length === 0 && parsed.format === 'table') { + await writeToStream(stdout, `No browser Sessions found${profile ? ` for Profile ${profile}` : ''}.\n`); + return; + } + await renderOutput(rows, { fmt: parsed.format, columns: ['id', 'kind', 'runtimeState'], stdout }); + return; + } + await renderOutput((await client.closeBrowserSession(parsed.session!, profile)).result, { fmt: parsed.format, stdout }); +} + function hasPresentFileArgument( command: import('./types.js').HostedCommand, args: Record, @@ -568,7 +632,7 @@ async function parseHostedBrowserInvocation( if (!structure.commandName) { throw new ConfigError( 'Hosted browser command is required.', - 'Use: webcmd browser tabs, bind --page , run --stdin|--file , or close.', + 'Use: webcmd --session browser tabs, bind --page , or run --stdin|--file .', ); } diff --git a/src/hosted/types.ts b/src/hosted/types.ts index d75c41d7..04b835b4 100644 --- a/src/hosted/types.ts +++ b/src/hosted/types.ts @@ -62,6 +62,34 @@ export interface HostedProfilesResponse { profiles: HostedPublicProfile[]; } +export interface HostedBrowserSession { + id: string; + kind: 'browser'; + profileId: string; + runtimeState: 'active' | 'idle'; + createdAt: string; + lastUsedAt: string; +} + +export interface HostedBrowserSessionResponse { + ok: true; + result: HostedBrowserSession; +} + +export interface HostedBrowserSessionsResponse { + ok: true; + result: HostedBrowserSession[]; +} + +export interface HostedBrowserSessionCloseResponse { + ok: true; + result: { + closed: boolean; + alreadyIdle: boolean; + session: string; + }; +} + export interface HostedMarketplacePlugin { name: string; description?: string; diff --git a/src/skills.test.ts b/src/skills.test.ts index dc9c2314..3beb4bee 100644 --- a/src/skills.test.ts +++ b/src/skills.test.ts @@ -178,19 +178,19 @@ describe('webcmd skills content', () => { path.join(process.cwd(), 'skills', 'webcmd-adapter-author', 'references', 'site-recon.md'), 'utf8', ); - expect(usage).toMatch(/existing adapter command first[\s\S]{0,160}load `webcmd-browser` and run Playwright/i); - expect(browser).toMatch(/`tabs`, `bind --page`, `snapshot`, `run`, and `close`/i); - expect(browser).toContain('webcmd browser work tabs'); - expect(browser).toContain('webcmd browser work bind --page'); - expect(browser).toContain('webcmd browser work run --stdin'); - expect(browser).toContain('webcmd browser work close'); + expect(usage).toMatch(/existing adapter command first[\s\S]{0,220}load `webcmd-browser`[\s\S]{0,120}root `--session `/i); + expect(browser).toMatch(/`tabs`, `bind --page`, `snapshot`, and `run`/i); + expect(browser).toContain('webcmd --session browser tabs'); + expect(browser).toContain('webcmd --session browser bind --page'); + expect(browser).toContain('webcmd --session browser run --stdin'); + expect(browser).toContain('webcmd session close '); expect(browser).toMatch(/read-only/i); expect(browser).toMatch(/explicit(?:ly)? bind/i); expect(browser).toMatch(/fresh JavaScript scope/i); expect(browser).toMatch(/persistent browser state/i); expect(browser).toContain("run --stdin <<'JS'"); expect(browser).toContain("await page.getByRole('link', { name: 'More information' }).click()"); - expect(browser).toContain('webcmd browser work snapshot'); + expect(browser).toContain('webcmd --session browser snapshot'); expect(browser).toContain('--snapshot-mode act'); expect(browser).toContain('--snapshot-mode tree'); expect(browser).toContain('--snapshot-mode read'); @@ -214,7 +214,7 @@ describe('webcmd skills content', () => { expect(browserRunReference).not.toContain('browser.currentPage()'); expect(browserRunReference).not.toContain('--observe'); expect(browserRunReference).not.toContain('--tab'); - expect(siteReconReference).toContain("webcmd browser recon run --stdin <<'JS'"); + expect(siteReconReference).toContain("webcmd --session browser run --stdin <<'JS'"); expect(siteReconReference).toContain('page.waitForResponse('); expect(siteReconReference).not.toMatch(/webcmd browser \S+ (?:open|state|click|type|select|find|extract|network|wait|eval)/i); }); @@ -237,7 +237,7 @@ describe('webcmd skills content', () => { for (const required of [ 'Absence from truncated output never proves that no adapter exists', - 'Use the same session name for a multi-step flow', + 'Create an opaque browser session before raw browser work', 'fresh JavaScript scope', 'persistent browser state', 'Never ask for or type passwords', From 85503686444bfc123ef97c412cb69eb09781267c Mon Sep 17 00:00:00 2001 From: beubax Date: Wed, 12 Aug 2026 10:41:35 +0530 Subject: [PATCH 08/27] fix: partition adapter sessions by site --- src/browser/bridge.ts | 4 +- src/browser/cdp.ts | 2 +- src/browser/page.test.ts | 8 ++-- src/browser/page.ts | 5 ++- src/browser/protocol.ts | 2 + src/browser/runtime/local-cloak/actions.ts | 17 ++++++++ .../local-cloak/session-manager.test.ts | 41 +++++++++++++++++- .../runtime/local-cloak/session-manager.ts | 12 +++++- src/daemon/server.test.ts | 43 ++++++++++++++++++- src/daemon/server.ts | 6 ++- src/execution.test.ts | 6 +-- src/execution.ts | 1 + src/runtime.ts | 5 ++- src/session-lease.test.ts | 10 +++++ src/session-lease.ts | 8 +++- 15 files changed, 151 insertions(+), 19 deletions(-) diff --git a/src/browser/bridge.ts b/src/browser/bridge.ts index d97bb60a..1257309a 100644 --- a/src/browser/bridge.ts +++ b/src/browser/bridge.ts @@ -25,7 +25,7 @@ export class BrowserBridge implements IBrowserFactory { return this._state; } - async connect(opts: { timeout?: number; session?: string; idleTimeout?: number; contextId?: string; preferredContextId?: string; windowMode?: 'foreground' | 'background'; surface?: 'browser' | 'adapter'; siteSession?: 'ephemeral' | 'persistent'; freshPage?: boolean } = {}): Promise { + async connect(opts: { timeout?: number; session?: string; idleTimeout?: number; contextId?: string; preferredContextId?: string; windowMode?: 'foreground' | 'background'; surface?: 'browser' | 'adapter'; siteSession?: 'ephemeral' | 'persistent'; freshPage?: boolean; adapterSite?: string } = {}): Promise { if (this._state === 'connected' && this._page) return this._page; if (this._state === 'connecting') throw new Error('Already connecting'); if (this._state === 'closing') throw new Error('Session is closing'); @@ -40,7 +40,7 @@ export class BrowserBridge implements IBrowserFactory { await this._ensureDaemon(opts.timeout, routing.contextId); const session = opts.session?.trim(); if (!session && opts.surface !== 'adapter') throw new Error('Browser session is required'); - this._page = new Page(session, opts.idleTimeout, routing.contextId, opts.windowMode, opts.surface, opts.siteSession, routing.preferredContextId, opts.freshPage); + this._page = new Page(session, opts.idleTimeout, routing.contextId, opts.windowMode, opts.surface, opts.siteSession, routing.preferredContextId, opts.freshPage, opts.adapterSite); this._state = 'connected'; return this._page; } catch (err) { diff --git a/src/browser/cdp.ts b/src/browser/cdp.ts index 5291e2cd..1a9da044 100644 --- a/src/browser/cdp.ts +++ b/src/browser/cdp.ts @@ -54,7 +54,7 @@ export class CDPBridge implements IBrowserFactory { private _pending = new Map void; reject: (err: Error) => void; timer: ReturnType }>(); private _eventListeners = new Map void>>(); - async connect(opts?: { timeout?: number; session?: string; cdpEndpoint?: string; contextId?: string; preferredContextId?: string; idleTimeout?: number; windowMode?: 'foreground' | 'background'; surface?: 'browser' | 'adapter'; siteSession?: 'ephemeral' | 'persistent' }): Promise { + async connect(opts?: { timeout?: number; session?: string; cdpEndpoint?: string; contextId?: string; preferredContextId?: string; idleTimeout?: number; windowMode?: 'foreground' | 'background'; surface?: 'browser' | 'adapter'; siteSession?: 'ephemeral' | 'persistent'; adapterSite?: string }): Promise { if (this._ws) throw new Error('CDPBridge is already connected. Call close() before reconnecting.'); const endpoint = opts?.cdpEndpoint ?? process.env.WEBCMD_CDP_ENDPOINT; diff --git a/src/browser/page.test.ts b/src/browser/page.test.ts index 157af61d..6ae25c35 100644 --- a/src/browser/page.test.ts +++ b/src/browser/page.test.ts @@ -55,21 +55,23 @@ describe('Page.getCurrentUrl', () => { sendCommandFullMock.mockResolvedValueOnce({ page: 'page-1', data: { url: 'https://chatgpt.com/' } }); sendCommandMock.mockResolvedValueOnce(null); - const page = new Page('site:chatgpt', undefined, undefined, undefined, 'adapter', 'persistent'); + const page = new Page('session_a', undefined, undefined, undefined, 'adapter', 'persistent', undefined, false, 'chatgpt'); await page.goto('https://chatgpt.com/', { waitUntil: 'none' }); await page.evaluate('document.title'); expect(sendCommandFullMock).toHaveBeenCalledWith('navigate', expect.objectContaining({ - session: 'site:chatgpt', + session: 'session_a', surface: 'adapter', siteSession: 'persistent', + adapterSite: 'chatgpt', waitUntil: 'none', })); expect(sendCommandMock).toHaveBeenCalledWith('exec', expect.objectContaining({ - session: 'site:chatgpt', + session: 'session_a', surface: 'adapter', siteSession: 'persistent', + adapterSite: 'chatgpt', page: 'page-1', })); }); diff --git a/src/browser/page.ts b/src/browser/page.ts index 25445b09..bf546fd8 100644 --- a/src/browser/page.ts +++ b/src/browser/page.ts @@ -51,6 +51,7 @@ export class Page extends BasePage { private readonly siteSession?: 'ephemeral' | 'persistent', public readonly preferredContextId?: string, freshPage?: boolean, + private readonly adapterSite?: string, ) { super(); this._idleTimeout = idleTimeout; @@ -72,7 +73,7 @@ export class Page extends BasePage { private _networkCaptureWarned = false; /** Helper: spread session into command params */ - private _sessionOpts(): { session?: string; surface: 'browser' | 'adapter'; idleTimeout?: number; contextId?: string; preferredContextId?: string; windowMode?: 'foreground' | 'background'; siteSession?: 'ephemeral' | 'persistent' } { + private _sessionOpts(): { session?: string; surface: 'browser' | 'adapter'; idleTimeout?: number; contextId?: string; preferredContextId?: string; windowMode?: 'foreground' | 'background'; siteSession?: 'ephemeral' | 'persistent'; adapterSite?: string } { return { surface: this.surface, ...(this.session && { session: this.session }), @@ -81,6 +82,7 @@ export class Page extends BasePage { ...(this._idleTimeout != null && { idleTimeout: this._idleTimeout }), ...(this.windowMode && { windowMode: this.windowMode }), ...(this.siteSession && { siteSession: this.siteSession }), + ...(this.adapterSite && { adapterSite: this.adapterSite }), ...this._freshPageOpts(), }; } @@ -96,6 +98,7 @@ export class Page extends BasePage { ...(this._idleTimeout != null && { idleTimeout: this._idleTimeout }), ...(this.windowMode && { windowMode: this.windowMode }), ...(this.siteSession && { siteSession: this.siteSession }), + ...(this.adapterSite && { adapterSite: this.adapterSite }), ...this._freshPageOpts(), }; } diff --git a/src/browser/protocol.ts b/src/browser/protocol.ts index d47185fc..584bbae0 100644 --- a/src/browser/protocol.ts +++ b/src/browser/protocol.ts @@ -39,6 +39,8 @@ export interface BrowserRuntimeCommand { sessionKind?: 'explicit' | 'adapter-default'; surface?: BrowserSurface; siteSession?: SiteSessionMode; + /** Trusted adapter identity used for admission and tab routing. */ + adapterSite?: string; /** Close any existing leased page and start on a new one (sent on the first action of a command run). */ freshPage?: boolean; url?: string; diff --git a/src/browser/runtime/local-cloak/actions.ts b/src/browser/runtime/local-cloak/actions.ts index 698b8599..fb6c0eac 100644 --- a/src/browser/runtime/local-cloak/actions.ts +++ b/src/browser/runtime/local-cloak/actions.ts @@ -79,6 +79,9 @@ async function resolveLease(manager: CloakSessionManager, command: BrowserRuntim session: command.session, surface: command.surface, siteSession: command.siteSession, + sessionId: command.sessionId, + adapterSite: command.adapterSite, + runId: command.runId, idleTimeout: command.idleTimeout, freshPage: command.freshPage, windowMode: command.windowMode, @@ -101,6 +104,10 @@ function resolveExistingLease(manager: CloakSessionManager, command: BrowserRunt profileId, session: command.session, surface: command.surface, + siteSession: command.siteSession, + sessionId: command.sessionId, + adapterSite: command.adapterSite, + runId: command.runId, idleTimeout: command.idleTimeout, }); if (existing) return existing; @@ -340,6 +347,10 @@ export async function dispatchCloakAction(manager: CloakSessionManager, command: profileId: resolveCloakCommandProfileId(manager, command), session: command.session, surface: command.surface, + siteSession: command.siteSession, + sessionId: command.sessionId, + adapterSite: command.adapterSite, + runId: command.runId, }); return { id: command.id, ok: true, data: { closed: true, session: command.session } }; } @@ -360,6 +371,9 @@ export async function dispatchCloakAction(manager: CloakSessionManager, command: session: command.session, surface: command.surface, siteSession: command.siteSession, + sessionId: command.sessionId, + adapterSite: command.adapterSite, + runId: command.runId, idleTimeout: command.idleTimeout, url: command.url, windowMode: command.windowMode, @@ -453,6 +467,9 @@ export async function dispatchCloakAction(manager: CloakSessionManager, command: session: command.session, surface: command.surface, siteSession: command.siteSession, + sessionId: command.sessionId, + adapterSite: command.adapterSite, + runId: command.runId, idleTimeout: command.idleTimeout, windowMode: command.windowMode, pageId: command.page, diff --git a/src/browser/runtime/local-cloak/session-manager.test.ts b/src/browser/runtime/local-cloak/session-manager.test.ts index 48368719..96599ab0 100644 --- a/src/browser/runtime/local-cloak/session-manager.test.ts +++ b/src/browser/runtime/local-cloak/session-manager.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import path from 'node:path'; import type { BrowserContext, Page as PlaywrightPage } from 'playwright-core'; -import { CloakSessionManager } from './session-manager.js'; +import { CloakSessionManager, resolveLeaseKey } from './session-manager.js'; import { dispatchCloakAction } from './actions.js'; function fakeContext() { @@ -541,6 +541,45 @@ describe('CloakSessionManager', () => { expect(reused.page).toBe(fresh.page); }); + it('keeps persistent adapter pages separate by Session and site', async () => { + const launched = fakeContext(); + const manager = new CloakSessionManager({ + baseDir: '/tmp/webcmd-test', + launchPersistentContext: vi.fn().mockResolvedValue(launched.context), + }); + const base = { + profileId: 'default', + session: 'session_a', + sessionId: 'session_a', + surface: 'adapter' as const, + siteSession: 'persistent' as const, + }; + + const githubA = await manager.getPage({ ...base, adapterSite: 'github' }); + const linkedinA = await manager.getPage({ ...base, adapterSite: 'linkedin' }); + const githubB = await manager.getPage({ ...base, session: 'session_b', sessionId: 'session_b', adapterSite: 'github' }); + + expect(linkedinA.page).not.toBe(githubA.page); + expect(githubB.page).not.toBe(githubA.page); + expect((await manager.getPage({ ...base, adapterSite: 'github' })).page).toBe(githubA.page); + }); + + it('keys ephemeral adapter pages by Session, site, and run', () => { + const base = { + session: 'session_a', + sessionId: 'session_a', + surface: 'adapter' as const, + siteSession: 'ephemeral' as const, + }; + + expect(resolveLeaseKey({ ...base, adapterSite: 'github', runId: 'run_a' })) + .toBe('session_a\0ephemeral:github:run_a'); + expect(resolveLeaseKey({ ...base, adapterSite: 'linkedin', runId: 'run_a' })) + .not.toBe(resolveLeaseKey({ ...base, adapterSite: 'github', runId: 'run_a' })); + expect(resolveLeaseKey({ ...base, adapterSite: 'github', runId: 'run_b' })) + .not.toBe(resolveLeaseKey({ ...base, adapterSite: 'github', runId: 'run_a' })); + }); + it('freshPage never adopts a leftover context tab', async () => { const launched = fakeContext(); const leftover = launched.page; diff --git a/src/browser/runtime/local-cloak/session-manager.ts b/src/browser/runtime/local-cloak/session-manager.ts index bb13fb16..e8ed6b44 100644 --- a/src/browser/runtime/local-cloak/session-manager.ts +++ b/src/browser/runtime/local-cloak/session-manager.ts @@ -42,6 +42,9 @@ export interface SessionKeyInput { session?: string; surface?: BrowserSurface; siteSession?: SiteSessionMode; + sessionId?: string; + adapterSite?: string; + runId?: string; idleTimeout?: number; windowMode?: BrowserWindowMode; /** Discard the existing leased page (if any) and create a new one under the same lease. */ @@ -100,6 +103,13 @@ export function resolveLeaseKey(input: SessionKeyInput): string { const surface = input.surface === 'adapter' ? 'adapter' : 'browser'; const session = input.session?.trim(); if (!session) throw new Error('Browser session is required.'); + const sessionId = input.sessionId?.trim() || session; + if (surface === 'adapter' && input.siteSession === 'persistent' && input.adapterSite) { + return `${sessionId}\u0000site:${input.adapterSite}`; + } + if (surface === 'adapter' && input.runId) { + return `${sessionId}\u0000ephemeral:${input.adapterSite ?? 'browser'}:${input.runId}`; + } return `${surface}\u0000${encodeURIComponent(session)}`; } @@ -359,7 +369,7 @@ export class CloakSessionManager { if (!match) return null; const [sourceKey, entry] = match; - const canonicalKey = resolveLeaseKey({ profileId, session, surface }); + const canonicalKey = resolveLeaseKey(input); const currentCanonical = runtime.pages.get(canonicalKey); if (input.windowMode !== 'background') { diff --git a/src/daemon/server.test.ts b/src/daemon/server.test.ts index bb39b465..b31e6e64 100644 --- a/src/daemon/server.test.ts +++ b/src/daemon/server.test.ts @@ -71,7 +71,10 @@ class FakeProvider implements BrowserRuntimeProvider { } async resolveAdapterDefault(command: BrowserRuntimeCommand): Promise { - return this.requireSession({ ...command, session: command.session ?? 'session_default' }); + return { + ...await this.requireSession({ ...command, session: command.session ?? 'session_default' }), + kind: 'adapter-default', + }; } async dispatch(command: BrowserRuntimeCommand) { @@ -144,6 +147,19 @@ describe('createDaemonServer', () => { }; } + function adapterCommand( + id: string, + runId: string, + adapterSite: string, + session?: string, + ): BrowserRuntimeCommand { + return persistentWrite(id, runId, { + adapterSite, + command: `${adapterSite} read`, + ...(session === undefined ? { session: undefined } : { session }), + }); + } + it('returns runtime-named status fields without extension aliases', async () => { const { baseUrl } = await start(); const res = await fetch(`${baseUrl}/status`, { headers: { [DAEMON_HEADER_NAME]: '1' } }); @@ -334,6 +350,29 @@ describe('createDaemonServer', () => { expect(provider.commands.map((command) => command.id)).toEqual(['first']); }); + it('partitions adapter-default admission by site', async () => { + const { provider, baseUrl } = await start(); + + expect((await postCommand(baseUrl, adapterCommand('github-owner', 'run_100_1_1', 'github'))).status).toBe(200); + expect((await postCommand(baseUrl, adapterCommand('linkedin-owner', 'run_200_2_2', 'linkedin'))).status).toBe(200); + expect((await postCommand(baseUrl, adapterCommand('github-conflict', 'run_300_3_3', 'github'))).status).toBe(409); + expect(provider.commands.map(({ id }) => id)).toEqual(['github-owner', 'linkedin-owner']); + expect(provider.commands[0]).toMatchObject({ + sessionId: 'session_default', + sessionKind: 'adapter-default', + adapterSite: 'github', + }); + }); + + it('keeps explicit Session admission wide across sites and isolated across Sessions', async () => { + const { provider, baseUrl } = await start(); + + expect((await postCommand(baseUrl, adapterCommand('github-a', 'run_100_1_1', 'github', 'session_a'))).status).toBe(200); + expect((await postCommand(baseUrl, adapterCommand('linkedin-a', 'run_200_2_2', 'linkedin', 'session_a'))).status).toBe(409); + expect((await postCommand(baseUrl, adapterCommand('linkedin-b', 'run_300_3_3', 'linkedin', 'session_b'))).status).toBe(200); + expect(provider.commands.map(({ id }) => id)).toEqual(['github-a', 'linkedin-b']); + }); + it('lets one logical run issue multiple operations and heartbeat its lease', async () => { let now = 1_000; vi.spyOn(Date, 'now').mockImplementation(() => now); @@ -346,7 +385,7 @@ describe('createDaemonServer', () => { const status = await fetch(`${baseUrl}/status`, { headers: { [DAEMON_HEADER_NAME]: '1' } }); await expect(status.json()).resolves.toMatchObject({ sessionLeases: [{ - key: 'default␟site%3Aexample', + key: 'default␟site:example', command: 'example write', acquiredAt: 1_000, heartbeatAt: 20_000, diff --git a/src/daemon/server.ts b/src/daemon/server.ts index 8a76a83d..7aefceaf 100644 --- a/src/daemon/server.ts +++ b/src/daemon/server.ts @@ -259,7 +259,11 @@ export function createDaemonServer(provider: BrowserRuntimeProvider, opts: Daemo if (isSessionLeaseCommand(resolvedBody)) { const profileId = commandProfileId(provider, resolvedBody) ?? 'default'; - leaseKey = getSessionLeaseKey(profileId, resolvedBody.sessionId); + const admissionSite = resolvedBody.sessionKind === 'adapter-default' + && resolvedBody.surface === 'adapter' + ? resolvedBody.adapterSite + : undefined; + leaseKey = getSessionLeaseKey(profileId, resolvedBody.sessionId, admissionSite); runId = resolvedBody.runId; const acquired = leases.acquire({ key: leaseKey, diff --git a/src/execution.test.ts b/src/execution.test.ts index 33b7d1a7..b9eb95ca 100644 --- a/src/execution.test.ts +++ b/src/execution.test.ts @@ -672,7 +672,7 @@ describe('executeCommand — non-browser timeout', () => { it('reuses a persistent site browser session and keeps the tab lease open', async () => { const closeWindow = vi.fn().mockResolvedValue(undefined); const mockPage = { closeWindow } as any; - const sessionOpts: Array<{ session?: string; idleTimeout?: number; windowMode?: string; siteSession?: string }> = []; + const sessionOpts: Array<{ session?: string; idleTimeout?: number; windowMode?: string; siteSession?: string; adapterSite?: string }> = []; vi.spyOn(capRouting, 'shouldUseBrowserSession').mockReturnValue(true); vi.spyOn(runtime, 'browserSession').mockImplementation(async (_Factory, fn, opts) => { @@ -694,8 +694,8 @@ describe('executeCommand — non-browser timeout', () => { await executeCommand(cmd, {}, false, { keepTab: 'false' }); expect(sessionOpts).toHaveLength(2); - expect(sessionOpts[0]).toMatchObject({ windowMode: 'background', siteSession: 'persistent' }); - expect(sessionOpts[1]).toMatchObject({ windowMode: 'background', siteSession: 'persistent' }); + expect(sessionOpts[0]).toMatchObject({ windowMode: 'background', siteSession: 'persistent', adapterSite: 'test-execution' }); + expect(sessionOpts[1]).toMatchObject({ windowMode: 'background', siteSession: 'persistent', adapterSite: 'test-execution' }); expect(sessionOpts[0]?.session).toBeUndefined(); expect(sessionOpts[1]?.session).toBeUndefined(); expect(sessionOpts[0]?.idleTimeout).toBeUndefined(); diff --git a/src/execution.ts b/src/execution.ts index 036bd65f..de9bc1ab 100644 --- a/src/execution.ts +++ b/src/execution.ts @@ -365,6 +365,7 @@ export async function executeCommand( windowMode, surface, siteSession, + adapterSite: cmd.site, freshPage: cmd.freshPage === true && siteSession === 'persistent', }); diff --git a/src/runtime.ts b/src/runtime.ts index 8d9b0ca0..a5cbbeab 100644 --- a/src/runtime.ts +++ b/src/runtime.ts @@ -54,14 +54,14 @@ export function withTimeoutMs( /** Interface for browser factory (BrowserBridge or test mocks) */ export interface IBrowserFactory { - connect(opts?: { timeout?: number; session?: string; cdpEndpoint?: string; contextId?: string; preferredContextId?: string; idleTimeout?: number; windowMode?: BrowserWindowMode; surface?: BrowserSurface; siteSession?: 'ephemeral' | 'persistent'; freshPage?: boolean }): Promise; + connect(opts?: { timeout?: number; session?: string; cdpEndpoint?: string; contextId?: string; preferredContextId?: string; idleTimeout?: number; windowMode?: BrowserWindowMode; surface?: BrowserSurface; siteSession?: 'ephemeral' | 'persistent'; freshPage?: boolean; adapterSite?: string }): Promise; close(): Promise; } export async function browserSession( BrowserFactory: new () => IBrowserFactory, fn: (page: IPage) => Promise, - opts: { session?: string; cdpEndpoint?: string; contextId?: string; preferredContextId?: string; idleTimeout?: number; windowMode?: BrowserWindowMode; surface?: BrowserSurface; siteSession?: 'ephemeral' | 'persistent'; freshPage?: boolean } = {}, + opts: { session?: string; cdpEndpoint?: string; contextId?: string; preferredContextId?: string; idleTimeout?: number; windowMode?: BrowserWindowMode; surface?: BrowserSurface; siteSession?: 'ephemeral' | 'persistent'; freshPage?: boolean; adapterSite?: string } = {}, ): Promise { const browser = new BrowserFactory(); try { @@ -76,6 +76,7 @@ export async function browserSession( surface: opts.surface, siteSession: opts.siteSession, freshPage: opts.freshPage, + adapterSite: opts.adapterSite, }); return await fn(page); } finally { diff --git a/src/session-lease.test.ts b/src/session-lease.test.ts index 94b05848..28c6e3e9 100644 --- a/src/session-lease.test.ts +++ b/src/session-lease.test.ts @@ -109,6 +109,16 @@ describe('session lease partitions', () => { expect(workSession).not.toBe(getSessionLeaseKey('work', 'session_b')); }); + it('partitions only the adapter-default Session by trusted adapter site', () => { + const github = getSessionLeaseKey('work', 'session_default', 'github'); + const linkedin = getSessionLeaseKey('work', 'session_default', 'linkedin'); + + expect(github).toBe('work␟session_default␟github'); + expect(linkedin).toBe('work␟session_default␟linkedin'); + expect(github).not.toBe(linkedin); + expect(getSessionLeaseKey('work', 'session_a')).toBe('work␟session_a'); + }); + it('arbitrates any resolved browser-backed command with complete run identity', () => { const eligible = { action: 'exec', diff --git a/src/session-lease.ts b/src/session-lease.ts index 8d742175..1f244e9a 100644 --- a/src/session-lease.ts +++ b/src/session-lease.ts @@ -119,8 +119,12 @@ export type AcquireResult = /** * Lease key after the daemon has resolved the immutable browser Session. */ -export function getSessionLeaseKey(profileId: string, sessionId: string): string { - return `${profileId}␟${encodeURIComponent(sessionId)}`; +export function getSessionLeaseKey( + profileId: string, + sessionId: string, + admissionSite?: string, +): string { + return `${profileId}␟${sessionId}${admissionSite === undefined ? '' : `␟${admissionSite}`}`; } /** Whether a process id is safe to interpolate into local process guidance. */ From 3818d63cb31e8b9923560c69bdbbc4cd73d56d90 Mon Sep 17 00:00:00 2001 From: beubax Date: Wed, 12 Aug 2026 10:46:39 +0530 Subject: [PATCH 09/27] fix: enforce adapter admission scopes --- .../local-cloak/session-manager.test.ts | 40 ++++++++++++++++++- .../runtime/local-cloak/session-manager.ts | 14 ++++--- src/daemon/server.test.ts | 30 +++++++++++++- src/daemon/server.ts | 5 ++- src/session-lease.test.ts | 12 ++++++ src/session-lease.ts | 17 ++++---- 6 files changed, 101 insertions(+), 17 deletions(-) diff --git a/src/browser/runtime/local-cloak/session-manager.test.ts b/src/browser/runtime/local-cloak/session-manager.test.ts index 96599ab0..02e38b26 100644 --- a/src/browser/runtime/local-cloak/session-manager.test.ts +++ b/src/browser/runtime/local-cloak/session-manager.test.ts @@ -601,11 +601,47 @@ describe('CloakSessionManager', () => { baseDir: '/tmp/webcmd-test', launchPersistentContext: vi.fn().mockResolvedValue(launched.context), }); - const lease = await manager.getPage({ profileId: 'default', session: 'site:x:uuid', surface: 'adapter', siteSession: 'ephemeral' }); - await manager.release({ profileId: 'default', session: 'site:x:uuid', surface: 'adapter' }); + const key = { profileId: 'default', session: 'session_default', sessionId: 'session_default', surface: 'adapter' as const, siteSession: 'ephemeral' as const, adapterSite: 'github', runId: 'run_a' }; + const lease = await manager.getPage(key); + await manager.release(key); expect(lease.page.close).toHaveBeenCalled(); }); + it('releases only the owning ephemeral adapter site and run', async () => { + const launched = fakeContext(); + const manager = new CloakSessionManager({ + baseDir: '/tmp/webcmd-test', + launchPersistentContext: vi.fn().mockResolvedValue(launched.context), + }); + const base = { profileId: 'default', session: 'session_default', sessionId: 'session_default', surface: 'adapter' as const, siteSession: 'ephemeral' as const }; + const github = { ...base, adapterSite: 'github', runId: 'run_a' }; + const linkedin = { ...base, adapterSite: 'linkedin', runId: 'run_b' }; + const githubLease = await manager.getPage(github); + const linkedinLease = await manager.getPage(linkedin); + + await manager.release(github); + + expect(githubLease.page.close).toHaveBeenCalledOnce(); + expect(linkedinLease.page.close).not.toHaveBeenCalled(); + expect((await manager.getPage(linkedin)).page).toBe(linkedinLease.page); + }); + + it('keeps persistent adapter pages tracked when release is requested', async () => { + const launched = fakeContext(); + const manager = new CloakSessionManager({ + baseDir: '/tmp/webcmd-test', + launchPersistentContext: vi.fn().mockResolvedValue(launched.context), + }); + const key = { profileId: 'default', session: 'session_default', sessionId: 'session_default', surface: 'adapter' as const, siteSession: 'persistent' as const, adapterSite: 'github', runId: 'run_a' }; + const lease = await manager.getPage(key); + + await manager.release(key); + + expect(lease.page.close).not.toHaveBeenCalled(); + await expect(manager.listPages(key)).resolves.toHaveLength(1); + expect((await manager.getPage(key)).page).toBe(lease.page); + }); + it('closes non-persistent leases when their idle timeout expires', async () => { vi.useFakeTimers(); const launched = fakeContext(); diff --git a/src/browser/runtime/local-cloak/session-manager.ts b/src/browser/runtime/local-cloak/session-manager.ts index e8ed6b44..3577ab42 100644 --- a/src/browser/runtime/local-cloak/session-manager.ts +++ b/src/browser/runtime/local-cloak/session-manager.ts @@ -417,16 +417,18 @@ export class CloakSessionManager { const runtime = this.profiles.get(profileId); if (!runtime) return; const leaseKey = resolveLeaseKey(input); - const entries = this.openEntries(runtime) - .filter(([key, entry]) => key === leaseKey || ( - entry.session === requireSession(input.session) - && entry.surface === normalizeSurface(input.surface) - )); + const surface = normalizeSurface(input.surface); + const entries = this.openEntries(runtime).filter(([key, entry]) => ( + surface === 'adapter' + ? key === leaseKey + : entry.session === requireSession(input.session) && entry.surface === surface + )); for (const [key, entry] of entries) { + if (entry.siteSession === 'persistent') continue; runtime.pages.delete(key); this.clearIdleTimer(entry); this.clearSelectedPage(runtime, entry); - if (entry.siteSession !== 'persistent' && !pageIsClosed(entry.page)) { + if (!pageIsClosed(entry.page)) { await entry.page.close().catch(() => {}); } } diff --git a/src/daemon/server.test.ts b/src/daemon/server.test.ts index b31e6e64..2039d1c0 100644 --- a/src/daemon/server.test.ts +++ b/src/daemon/server.test.ts @@ -63,7 +63,7 @@ class FakeProvider implements BrowserRuntimeProvider { return { id: String(command.session), profileId, - kind: 'explicit', + kind: command.session === 'session_default' ? 'adapter-default' : 'explicit', createdAt: '2026-08-11T00:00:00.000Z', updatedAt: '2026-08-11T00:00:00.000Z', lastUsedAt: '2026-08-11T00:00:00.000Z', @@ -364,6 +364,34 @@ describe('createDaemonServer', () => { }); }); + it('conflicts an explicitly selected adapter-default Session with its implicit adapter work', async () => { + const { provider, baseUrl } = await start(); + + expect((await postCommand(baseUrl, adapterCommand('github-owner', 'run_100_1_1', 'github'))).status).toBe(200); + const rawConflict = await postCommand(baseUrl, { + id: 'raw-conflict', + action: 'exec', + code: '1', + surface: 'browser', + session: 'session_default', + runId: 'run_200_2_2', + command: 'browser/run', + }); + + expect(rawConflict.status).toBe(409); + expect(provider.commands.map(({ id }) => id)).toEqual(['github-owner']); + }); + + it('marks an explicitly selected adapter-default ID as explicit routing', async () => { + const { provider, baseUrl } = await start(); + + expect((await postCommand(baseUrl, adapterCommand('explicit-default', 'run_100_1_1', 'github', 'session_default'))).status).toBe(200); + expect(provider.commands[0]).toMatchObject({ + sessionId: 'session_default', + sessionKind: 'explicit', + }); + }); + it('keeps explicit Session admission wide across sites and isolated across Sessions', async () => { const { provider, baseUrl } = await start(); diff --git a/src/daemon/server.ts b/src/daemon/server.ts index 7aefceaf..2cf82b86 100644 --- a/src/daemon/server.ts +++ b/src/daemon/server.ts @@ -78,12 +78,15 @@ async function resolveBrowserSession( ): Promise { if (command.action === 'lease-release' || command.action === 'run-cancel' || SESSION_LIFECYCLE_ACTIONS.has(command.action)) return command; let session: BrowserSessionRecord | undefined; + let sessionKind: BrowserRuntimeCommand['sessionKind']; if (command.surface === 'adapter' && !command.session) { session = await provider.resolveAdapterDefault?.(command); + sessionKind = 'adapter-default'; } else { session = await provider.requireSession?.(command); + sessionKind = 'explicit'; } - return session ? { ...command, session: session.id, sessionId: session.id, sessionKind: session.kind } : command; + return session ? { ...command, session: session.id, sessionId: session.id, sessionKind } : command; } async function handleSessionLifecycle( diff --git a/src/session-lease.test.ts b/src/session-lease.test.ts index 28c6e3e9..374a6f50 100644 --- a/src/session-lease.test.ts +++ b/src/session-lease.test.ts @@ -159,6 +159,18 @@ describe('SessionLeaseRegistry', () => { .toBe(true); }); + it('conflicts Session-wide admission with any site partition while allowing sibling sites', () => { + const leases = registry(); + const session = getSessionLeaseKey('profile_work', 'session_default'); + const github = getSessionLeaseKey('profile_work', 'session_default', 'github'); + const linkedin = getSessionLeaseKey('profile_work', 'session_default', 'linkedin'); + + expect(leases.acquire({ key: github, runId: 'run_1', command: 'github/issues' }, () => true).acquired).toBe(true); + expect(leases.acquire({ key: linkedin, runId: 'run_2', command: 'linkedin/posts' }, () => true).acquired).toBe(true); + expect(leases.acquire({ key: session, runId: 'run_3', command: 'browser/run' }, () => true)) + .toMatchObject({ acquired: false }); + }); + function registry(): SessionLeaseRegistry { return new SessionLeaseRegistry(() => now, () => true); } diff --git a/src/session-lease.ts b/src/session-lease.ts index 1f244e9a..2c09a48d 100644 --- a/src/session-lease.ts +++ b/src/session-lease.ts @@ -181,17 +181,20 @@ export class SessionLeaseRegistry { ): AcquireResult { const now = this.now(); const current = this.leases.get(input.key); - const currentIsLive = current !== undefined + const conflict = [...this.leases.values()].find((lease) => ( + lease.runId !== input.runId + && (lease.key === input.key || lease.key.startsWith(`${input.key}␟`) || input.key.startsWith(`${lease.key}␟`)) && ( - hasPendingWork(current.runId) + hasPendingWork(lease.runId) || ( - now - current.heartbeatAt <= SESSION_LEASE_TTL_MS - && (!isActionablePid(current.pid) || this.pidAlive(current.pid)) + now - lease.heartbeatAt <= SESSION_LEASE_TTL_MS + && (!isActionablePid(lease.pid) || this.pidAlive(lease.pid)) ) - ); + ) + )); - if (current && currentIsLive && current.runId !== input.runId) { - return { acquired: false, holder: { ...current } }; + if (conflict) { + return { acquired: false, holder: { ...conflict } }; } const pid = input.pid === undefined From ee4685d172abb68da224858e1f1b66a4d0f1877a Mon Sep 17 00:00:00 2001 From: beubax Date: Wed, 12 Aug 2026 11:21:50 +0530 Subject: [PATCH 10/27] feat: isolate local sessions by cloak window --- src/browser/run/playwright-transport.ts | 134 +++- src/browser/run/runner.test.ts | 119 +++- src/browser/run/runner.ts | 19 +- src/browser/runtime/local-cloak/actions.ts | 48 +- .../runtime/local-cloak/browser-run.test.ts | 16 +- .../darwin-background-launch.test.ts | 1 + .../local-cloak/darwin-background-launch.ts | 1 + .../runtime/local-cloak/provider.test.ts | 315 +++------ .../local-cloak/session-manager.test.ts | 252 ++++++- .../runtime/local-cloak/session-manager.ts | 650 ++++++++++++------ 10 files changed, 1023 insertions(+), 532 deletions(-) diff --git a/src/browser/run/playwright-transport.ts b/src/browser/run/playwright-transport.ts index 713602b6..cd811439 100644 --- a/src/browser/run/playwright-transport.ts +++ b/src/browser/run/playwright-transport.ts @@ -4,6 +4,7 @@ import { BROWSER_RUN_PLAYWRIGHT_VERSION, BrowserRunError, } from './types.js'; +import type { BrowserRunSessionScope } from './runner.js'; interface DispatcherConnection { onmessage: (message: Record) => void; @@ -78,52 +79,112 @@ function implementation(object: T): unknown { return value; } -function scopedContext(context: object, pages: () => object[]): object { - const pageListeners = new WeakMap(); - const isAllowedPage = (candidate: object) => { - const allowed = pages(); - if (allowed.includes(candidate)) return true; - const opener = Reflect.get(candidate, 'opener', candidate); - if (typeof opener !== 'function') return false; - try { - return allowed.includes(Reflect.apply(opener, candidate, [])); - } catch { - return false; - } +function scopedContext( + context: object, + scope: { + pages(): object[]; + createPage(): Promise; + onPage(listener: (page: object) => void): () => void; + }, +): object { + const pageListeners = new Map void>>(); + const addPageListener = (listener: Function, once = false) => { + let dispose: () => void = () => undefined; + const registered = (page: object) => { + if (once) { + dispose(); + pageListeners.get(listener)?.delete(dispose); + } + listener(page); + }; + dispose = scope.onPage(registered); + const disposers = pageListeners.get(listener) ?? new Set(); + disposers.add(dispose); + pageListeners.set(listener, disposers); + }; + const removePageListener = (listener: Function) => { + for (const dispose of pageListeners.get(listener) ?? []) dispose(); + pageListeners.delete(listener); + }; + const removeAllPageListeners = () => { + for (const listener of pageListeners.keys()) removePageListener(listener); }; let proxy: object; proxy = new Proxy(context, { get(target, property) { - if (property === 'pages') return pages; - if (property === 'newPage') { - return async () => { - throw new BrowserRunError( - 'BROWSER_RUN_API_UNSUPPORTED', - 'context.newPage() is not supported inside browser run; create or bind a Webcmd Session tab instead.', - ); - }; - } + if (property === 'pages') return scope.pages; + if (property === 'newPage') return scope.createPage; + if (property === 'backgroundPages' || property === 'serviceWorkers') return () => []; if (property === 'on' || property === 'addListener') { return (event: string, listener: Function) => { - let registered = listener; if (event === 'page') { - registered = (candidate: object, ...args: unknown[]) => { - if (isAllowedPage(candidate)) listener(candidate, ...args); - }; - pageListeners.set(listener, registered); + addPageListener(listener); + return proxy; } - Reflect.apply(Reflect.get(target, property), target, [event, registered]); + Reflect.apply(Reflect.get(target, property), target, [event, listener]); return proxy; }; } if (property === 'off' || property === 'removeListener') { return (event: string, listener: Function) => { - const registered = pageListeners.get(listener) ?? listener; - Reflect.apply(Reflect.get(target, property), target, [event, registered]); - pageListeners.delete(listener); + if (event === 'page') { + removePageListener(listener); + return proxy; + } + Reflect.apply(Reflect.get(target, property), target, [event, listener]); + return proxy; + }; + } + if (property === 'once') { + return (event: string, listener: Function) => { + if (event === 'page') { + addPageListener(listener, true); + return proxy; + } + Reflect.apply(Reflect.get(target, property), target, [event, listener]); return proxy; }; } + if (property === 'removeAllListeners') { + return (event?: string) => { + if (event === undefined || event === 'page') removeAllPageListeners(); + return proxy; + }; + } + if (property === 'waitForEvent') { + return (event: string, optionsOrPredicate?: object | Function) => { + if (event !== 'page') { + return Reflect.apply(Reflect.get(target, property), target, [event, optionsOrPredicate]); + } + const options = typeof optionsOrPredicate === 'object' ? optionsOrPredicate as { + predicate?: (page: object) => boolean | Promise; + timeout?: number; + } : undefined; + const predicate = typeof optionsOrPredicate === 'function' ? optionsOrPredicate : options?.predicate; + return new Promise((resolve, reject) => { + let timer: ReturnType | undefined; + const listener = async (page: object) => { + try { + if (predicate && !await predicate(page)) return; + removePageListener(listener); + if (timer) clearTimeout(timer); + resolve(page); + } catch (error) { + removePageListener(listener); + if (timer) clearTimeout(timer); + reject(error); + } + }; + addPageListener(listener); + if (options?.timeout) { + timer = setTimeout(() => { + removePageListener(listener); + reject(new Error(`Timeout while waiting for event "${event}"`)); + }, options.timeout); + } + }); + }; + } const value = Reflect.get(target, property, target); return typeof value === 'function' ? value.bind(target) : value; }, @@ -176,7 +237,7 @@ export class PlaywrightTransport { #browserWaitMs = 0; constructor( - input: { browser: Browser; context: BrowserContext; page: Page; pages?: Page[] }, + input: BrowserRunSessionScope, deliver: (message: string) => void, ) { if ( @@ -190,11 +251,14 @@ export class PlaywrightTransport { } const browser = implementation(input.browser) as object; - const allowedPages = new Set(); - const context = scopedContext(implementation(input.context) as object, () => [...allowedPages]); + const context = scopedContext(implementation(input.context) as object, { + pages: () => input.pages().map(page => implementation(page) as object), + createPage: async () => implementation(await input.createPage()) as object, + onPage: listener => input.onPage(page => listener(implementation(page) as object)), + }); this.pageGuid = pageGuid(input.page); - this.#registerPageImpl = page => allowedPages.add(implementation(page) as object); - for (const page of input.pages?.length ? input.pages : [input.page]) this.registerPage(page); + this.#registerPageImpl = page => { implementation(page); }; + for (const page of input.pages()) this.registerPage(page); this.#deliver = deliver; this.#connection = new server.DispatcherConnection(); this.#connection.onmessage = message => { diff --git a/src/browser/run/runner.test.ts b/src/browser/run/runner.test.ts index d39b6b70..0d80cabb 100644 --- a/src/browser/run/runner.test.ts +++ b/src/browser/run/runner.test.ts @@ -8,6 +8,7 @@ import { describe, expect, it, + vi, } from 'vitest'; import { chromium, @@ -26,11 +27,23 @@ let browser: Browser; let context: BrowserContext; let page: Page; -function run(source: string, options = {}) { - return runBrowserProgram({ +function sessionScope(pages: () => readonly Page[] = () => context.pages()) { + return { browser, context, page, + pages, + createPage: () => context.newPage(), + onPage(listener: (page: Page) => void) { + context.on('page', listener); + return () => context.off('page', listener); + }, + }; +} + +function run(source: string, options = {}) { + return runBrowserProgram({ + ...sessionScope(), pageId: 'page-1', }, source, options); } @@ -240,13 +253,15 @@ describe('runBrowserProgram', () => { it('waits for popups and exposes context pages', async () => { const registered: Page[] = []; const output = await runBrowserProgram({ - browser, - context, - page, + ...sessionScope(), pageId: 'page-1', - registerPage: popup => { - registered.push(popup); - return 'popup-1'; + onPage(listener) { + const registeredListener = (popup: Page) => { + if (!registered.includes(popup)) registered.push(popup); + listener(popup); + }; + context.on('page', registeredListener); + return () => context.off('page', registeredListener); }, }, ` const popupPromise = page.waitForEvent('popup'); @@ -268,11 +283,8 @@ describe('runBrowserProgram', () => { await other.setContent(''); const output = await runBrowserProgram({ - browser, - context, - page, + ...sessionScope(() => [page]), pageId: 'page-1', - pages: [page], }, ` return { pages: context.pages().length, @@ -286,16 +298,91 @@ describe('runBrowserProgram', () => { }); }); - it('rejects context.newPage so browser-run cannot create unowned session pages', async () => { - await expect(runBrowserProgram({ + it.each([ + ['once', ` + const seen = new Promise(resolve => context.once('page', page => resolve(page.url()))); + await context.newPage(); + return await seen; + `], + ['waitForEvent', ` + const seen = context.waitForEvent('page'); + await context.newPage(); + return (await seen).url(); + `], + ])('scopes context.%s page events to Session-owned creation', async (_api, source) => { + const sibling = await context.newPage(); + const owned = await context.newPage(); + await owned.goto('data:text/plain,owned'); + let pageListener: ((page: Page) => void) | undefined; + const createPage = vi.fn(async () => { + queueMicrotask(() => pageListener?.(owned)); + return owned; + }); + + const output = await runBrowserProgram({ + ...sessionScope(() => [page, owned]), + pageId: 'page-1', + createPage, + onPage(listener) { + pageListener = listener; + return () => { + if (pageListener === listener) pageListener = undefined; + }; + }, + }, source); + + expect(output.result).toBe(owned.url()); + expect(output.result).not.toBe(sibling.url()); + expect(createPage).toHaveBeenCalledOnce(); + }); + + it('does not let removeAllListeners remove the Session ownership listener', async () => { + const seen: Page[] = []; + const owned = await context.newPage(); + let pageListener: ((page: Page) => void) | undefined; + const output = await runBrowserProgram({ + ...sessionScope(() => [page, owned]), + pageId: 'page-1', + createPage: async () => { + queueMicrotask(() => pageListener?.(owned)); + return owned; + }, + onPage(listener) { + pageListener = (candidate: Page) => { + seen.push(candidate); + listener(candidate); + }; + return () => { + pageListener = undefined; + }; + }, + }, ` + context.removeAllListeners('page'); + await context.newPage(); + return context.pages().length; + `); + + expect(output.result).toBe(2); + expect(seen).toEqual([owned]); + }); + + it('delegates context.newPage to the Session-owned page creator', async () => { + const createPage = vi.fn(() => context.newPage()); + const output = await runBrowserProgram({ browser, context, page, pageId: 'page-1', - pages: [page], + pages: () => [page], + createPage, + onPage: () => () => undefined, }, ` await context.newPage(); - `)).rejects.toThrow(/context\.newPage\(\) is not supported/); + return context.pages().length; + `); + + expect(output.result).toBe(1); + expect(createPage).toHaveBeenCalledOnce(); }); it('waits for requests and responses', async () => { diff --git a/src/browser/run/runner.ts b/src/browser/run/runner.ts index c9dfe6dc..c5d52657 100644 --- a/src/browser/run/runner.ts +++ b/src/browser/run/runner.ts @@ -35,14 +35,18 @@ import { type BrowserRunWarning, } from './types.js'; -export interface BrowserRunProgramHost { +export interface BrowserRunSessionScope { browser: PlaywrightBrowser; context: PlaywrightBrowserContext; page: PlaywrightPage; + pages(): readonly PlaywrightPage[]; + createPage(): Promise; + onPage(listener: (page: PlaywrightPage) => void): () => void; +} + +export interface BrowserRunProgramHost extends BrowserRunSessionScope { pageId: string; - pages?: PlaywrightPage[]; artifactSink?: BrowserRunArtifactSink; - registerPage?: (page: PlaywrightPage) => string; } const PLAYWRIGHT_CLIENT_SOURCE = fs.readFileSync( @@ -298,15 +302,14 @@ export async function runBrowserProgram( } finally { timings.quickjs_boot_ms = Math.max(0, Date.now() - quickjsBootStartedAt); } - const knownPages = new Set(input.pages?.length ? input.pages : [input.page]); + const knownPages = new Set(input.pages()); for (const page of knownPages) transport.registerPage(page); const registerNewPage = (page: PlaywrightPage) => { if (knownPages.has(page)) return; knownPages.add(page); transport.registerPage(page); - input.registerPage?.(page); }; - input.page.on('popup', registerNewPage); + const unsubscribePages = input.onPage(registerNewPage); let timeout: ReturnType | undefined; let timeoutCleanup: Promise | undefined; @@ -324,7 +327,7 @@ export async function runBrowserProgram( .finally(() => { host.dispose(); void transport.dispose(timeoutError); - input.page.off('popup', registerNewPage); + unsubscribePages(); }); }; try { @@ -597,7 +600,7 @@ export async function runBrowserProgram( ).catch(() => undefined); await transport.dispose(completionError); host.dispose(); - input.page.off('popup', registerNewPage); + unsubscribePages(); } } } diff --git a/src/browser/runtime/local-cloak/actions.ts b/src/browser/runtime/local-cloak/actions.ts index fb6c0eac..5cfaf45a 100644 --- a/src/browser/runtime/local-cloak/actions.ts +++ b/src/browser/runtime/local-cloak/actions.ts @@ -65,9 +65,10 @@ function invalidRequest(command: BrowserRuntimeCommand, error: string): BrowserR async function resolveLease(manager: CloakSessionManager, command: BrowserRuntimeCommand) { const profileId = resolveCloakCommandProfileId(manager, command); if (command.page) { - const existing = manager.findPageById(command.page, { + const existing = await manager.findPageById(command.page, { profileId, session: command.session, + sessionId: command.sessionId, surface: command.surface, idleTimeout: command.idleTimeout, }); @@ -88,19 +89,20 @@ async function resolveLease(manager: CloakSessionManager, command: BrowserRuntim }); } -function resolveExistingLease(manager: CloakSessionManager, command: BrowserRuntimeCommand) { +async function resolveExistingLease(manager: CloakSessionManager, command: BrowserRuntimeCommand) { const profileId = resolveCloakCommandProfileId(manager, command); if (command.page) { - const existing = manager.findPageById(command.page, { + const existing = await manager.findPageById(command.page, { profileId, session: command.session, + sessionId: command.sessionId, surface: command.surface, idleTimeout: command.idleTimeout, }); if (existing) return existing; throw new CloakActionError('stale_page_identity', `Page not found: ${command.page} — stale page identity`); } - const existing = manager.findPage({ + const existing = await manager.findPage({ profileId, session: command.session, surface: command.surface, @@ -227,30 +229,20 @@ export async function dispatchCloakAction(manager: CloakSessionManager, command: }; } const lease = await resolveLease(manager, command); - const browser = lease.context.browser(); - if (!browser) throw new CloakActionError( - 'BROWSER_RUN_API_UNSUPPORTED', - 'The selected browser context is not attached to a browser.', - lease.pageId, - ); + const scope = await manager.browserRunScope({ + profileId: lease.profileId, + session: command.session, + sessionId: command.sessionId, + surface: command.surface, + siteSession: command.siteSession, + adapterSite: command.adapterSite, + runId: command.runId, + idleTimeout: command.idleTimeout, + windowMode: command.windowMode, + }, lease.page); const data = await runBrowserProgram({ - browser, - context: lease.context, - page: lease.page, + ...scope, pageId: lease.pageId, - pages: manager.sessionPages({ - profileId: lease.profileId, - session: command.session, - surface: command.surface, - }), - registerPage: (page) => manager.registerPage({ - profileId: lease.profileId, - session: command.session, - surface: command.surface, - siteSession: command.siteSession, - idleTimeout: command.idleTimeout, - windowMode: command.windowMode, - }, page), }, command.source, { timeoutMs: command.timeoutMs, maxOutputChars: command.maxOutputChars, @@ -267,7 +259,7 @@ export async function dispatchCloakAction(manager: CloakSessionManager, command: }; } case 'snapshot': { - const lease = resolveExistingLease(manager, command); + const lease = await resolveExistingLease(manager, command); if (command.snapshotMode === 'read') { const readable = readableSnapshotText(await extractArticle(lease.page, { force: true })); const redacted = redactUrl(redactText(readable.text, { maxStringLength: Number.MAX_SAFE_INTEGER })); @@ -508,7 +500,7 @@ export async function dispatchCloakAction(manager: CloakSessionManager, command: err instanceof Error && 'code' in err && typeof err.code === 'string' - && err.code.startsWith('BROWSER_RUN_') + && (err.code.startsWith('BROWSER_RUN_') || err.code === 'SESSION_WINDOW_CONFLICT') ) { const hint = 'hint' in err && typeof err.hint === 'string' ? err.hint diff --git a/src/browser/runtime/local-cloak/browser-run.test.ts b/src/browser/runtime/local-cloak/browser-run.test.ts index 84f3e83f..125f5984 100644 --- a/src/browser/runtime/local-cloak/browser-run.test.ts +++ b/src/browser/runtime/local-cloak/browser-run.test.ts @@ -31,6 +31,7 @@ beforeEach(async () => { baseDir: '/tmp/webcmd-browser-run-test', launchPersistentContext, }); + initialPage = (await manager.getPage({ profileId: 'default', session: 'work', surface: 'browser' })).page; }); afterEach(async () => { @@ -219,7 +220,7 @@ describe('local Cloak browser run', () => { expect(unstartedLaunch).not.toHaveBeenCalled(); }); - it('binds a session to the requested page and releases every page in that session', async () => { + it('does not bind a page owned by another Session', async () => { const original = await dispatchCloakAction(manager, command('run-original', 'run', { source: "await page.setContent('

original

'); return 'original';", })); @@ -227,19 +228,10 @@ describe('local Cloak browser run', () => { op: 'new', session: 'manual', })); - const boundPage = context.pages().find(page => page !== initialPage)!; - await boundPage.setContent('

bound

'); const bound = await dispatchCloakAction(manager, command('bind', 'bind', { page: created.page })); - const rerun = await dispatchCloakAction(manager, command('run-bound', 'run', { - source: 'return await page.locator("p").innerText();', - })); - const closed = await dispatchCloakAction(manager, command('close', 'close-window')); - const tabs = await dispatchCloakAction(manager, command('tabs-after-close', 'tabs', { op: 'list' })); expect(original).toMatchObject({ ok: true, page: expect.any(String) }); - expect(bound).toMatchObject({ ok: true, page: created.page }); - expect(rerun).toMatchObject({ ok: true, page: created.page, data: { result: 'bound' } }); - expect(closed).toMatchObject({ ok: true, data: { closed: true } }); - expect(tabs).toMatchObject({ ok: true, data: [] }); + expect(bound).toMatchObject({ ok: false, errorCode: 'SESSION_WINDOW_CONFLICT' }); + expect(created).toMatchObject({ ok: true, page: expect.any(String) }); }); }); diff --git a/src/browser/runtime/local-cloak/darwin-background-launch.test.ts b/src/browser/runtime/local-cloak/darwin-background-launch.test.ts index ef7f2056..133c9a58 100644 --- a/src/browser/runtime/local-cloak/darwin-background-launch.test.ts +++ b/src/browser/runtime/local-cloak/darwin-background-launch.test.ts @@ -45,6 +45,7 @@ describe('launchDarwinBackgroundPersistentContext', () => { '--fingerprint=123', '--password-store=basic', '--use-mock-keychain', + '--disable-popup-blocking', '--user-data-dir=/tmp/cloak profile', '--remote-debugging-address=127.0.0.1', '--remote-debugging-port=0', diff --git a/src/browser/runtime/local-cloak/darwin-background-launch.ts b/src/browser/runtime/local-cloak/darwin-background-launch.ts index 9a9e9576..4cfa6621 100644 --- a/src/browser/runtime/local-cloak/darwin-background-launch.ts +++ b/src/browser/runtime/local-cloak/darwin-background-launch.ts @@ -92,6 +92,7 @@ export async function launchDarwinBackgroundPersistentContext( ...(launchOptions.args ?? []), '--password-store=basic', '--use-mock-keychain', + '--disable-popup-blocking', `--user-data-dir=${options.userDataDir}`, '--remote-debugging-address=127.0.0.1', '--remote-debugging-port=0', diff --git a/src/browser/runtime/local-cloak/provider.test.ts b/src/browser/runtime/local-cloak/provider.test.ts index 591d996e..52ba80d6 100644 --- a/src/browser/runtime/local-cloak/provider.test.ts +++ b/src/browser/runtime/local-cloak/provider.test.ts @@ -20,10 +20,11 @@ function runOutput(result: unknown) { }; } -function fakePage(url: string, initialViewport: { width: number; height: number } | null = { width: 1280, height: 720 }) { +function fakePage(url: string, initialViewport: { width: number; height: number } | null = { width: 1280, height: 720 }, opener: object | null = null) { let closed = false; let viewportSize = initialViewport; - return { + const listeners = new Map void>>(); + const page = { isClosed: vi.fn(() => closed), goto: vi.fn(async (nextUrl: string) => { url = nextUrl; @@ -39,11 +40,29 @@ function fakePage(url: string, initialViewport: { width: number; height: number }), locator: vi.fn(), waitForEvent: vi.fn(), + opener: vi.fn().mockResolvedValue(opener), + on(event: string, listener: (...args: unknown[]) => void) { + const bucket = listeners.get(event) ?? new Set(); + bucket.add(listener); + listeners.set(event, bucket); + }, + once(event: string, listener: (...args: unknown[]) => void) { + const once = (...args: unknown[]) => { + page.off(event, once); + listener(...args); + }; + page.on(event, once); + }, + off(event: string, listener: (...args: unknown[]) => void) { + listeners.get(event)?.delete(listener); + }, bringToFront: vi.fn().mockResolvedValue(undefined), close: vi.fn().mockImplementation(async () => { closed = true; + for (const listener of listeners.get('close') ?? []) listener(); }), }; + return page; } function makeProviderWithFakePage(initialViewport: { width: number; height: number } | null = { width: 1280, height: 720 }) { @@ -52,7 +71,19 @@ function makeProviderWithFakePage(initialViewport: { width: number; height: numb const emit = (event: string, ...args: unknown[]) => { for (const listener of listeners.get(event) ?? []) listener(...args); }; - const cdpSession = { send: vi.fn().mockResolvedValue(undefined), detach: vi.fn().mockResolvedValue(undefined) }; + const targetIds = new WeakMap(); + const windowIds = new Map(); + let targetCounter = 0; + let windowCounter = 0; + const assignTarget = (page: object) => { + const targetId = `target-${++targetCounter}`; + targetIds.set(page, targetId); + windowIds.set(targetId, ++windowCounter); + return targetId; + }; + assignTarget(pages[0]); + const cdpSession = { send: vi.fn(), detach: vi.fn().mockResolvedValue(undefined) }; + const pageCdpSessions: { send: ReturnType; detach: ReturnType }[] = []; const browser = { contexts: vi.fn(() => [context]), newBrowserCDPSession: vi.fn().mockResolvedValue(cdpSession) }; const context = { browser: vi.fn(() => browser), @@ -69,23 +100,42 @@ function makeProviderWithFakePage(initialViewport: { width: number; height: numb newPage: vi.fn(async () => { const page = fakePage('about:blank'); pages.push(page); + assignTarget(page); return page; }), - newCDPSession: vi.fn().mockResolvedValue(cdpSession), + newCDPSession: vi.fn(async (target: object) => { + const pageSession = { + send: vi.fn(async (command: string, params?: unknown) => { + if (params === undefined) cdpSession.send(command); + else cdpSession.send(command, params); + if (command === 'Target.getTargetInfo') return { targetInfo: { targetId: targetIds.get(target) } }; + return {}; + }), + detach: vi.fn().mockResolvedValue(undefined), + }; + pageCdpSessions.push(pageSession); + return pageSession; + }), cookies: vi.fn().mockResolvedValue([{ name: 'sid', value: '1', domain: 'example.com', path: '/' }]), close: vi.fn().mockResolvedValue(undefined), }; - cdpSession.send.mockImplementation(async (command: string) => { + let usedInitialPage = false; + cdpSession.send.mockImplementation(async (command: string, params?: { targetId?: string }) => { if (command === 'Target.createTarget') { - const page = await context.newPage(); + const page = usedInitialPage ? await context.newPage() : pages[0]; + usedInitialPage = true; queueMicrotask(() => emit('page', page)); + return { targetId: targetIds.get(page) }; } + if (command === 'Browser.getWindowForTarget') return { windowId: windowIds.get(params?.targetId ?? '') }; + if (command === 'Target.closeTarget') return { success: true }; + return {}; }); const provider = new LocalCloakRuntimeProvider({ baseDir: '/tmp/webcmd-test', launchPersistentContext: vi.fn().mockResolvedValue(context), }); - return { provider, browser, page: pages[0], pages, context, cdpSession }; + return { provider, browser, page: pages[0], pages, context, cdpSession, pageCdpSessions }; } describe('LocalCloakRuntimeProvider', () => { @@ -165,7 +215,7 @@ describe('LocalCloakRuntimeProvider', () => { browser, context, page, - pages: [page], + pages: expect.any(Function), }), expect.stringContaining('return page.url()'), expect.objectContaining({ snapshotDiff: undefined, })); @@ -187,8 +237,9 @@ describe('LocalCloakRuntimeProvider', () => { }); expect(runBrowserProgram).toHaveBeenCalledWith(expect.objectContaining({ - pages: [pages[0]], + pages: expect.any(Function), }), expect.any(String), expect.any(Object)); + expect(runBrowserProgram.mock.calls[0][0].pages()).toEqual([pages[0]]); }); it('preserves structured browser-run error details', async () => { @@ -328,209 +379,59 @@ describe('LocalCloakRuntimeProvider', () => { expect(page.evaluate).toHaveBeenCalledTimes(1); }); - it('keeps popup pages under the originating session queue lock', async () => { - const { provider, page, pages } = makeProviderWithFakePage(); - const popup = fakePage('https://popup.example/'); - pages.push(popup); - page.waitForEvent.mockResolvedValue(popup); + it('does not adopt a sibling Session page created during browser-run', async () => { + const { provider } = makeProviderWithFakePage(); runBrowserProgram.mockImplementationOnce(async (input) => { - const registeredPopup = await input.page.waitForEvent('popup'); - input.registerPage?.(registeredPopup); - await new Promise(resolve => setTimeout(resolve, 30)); + await provider.dispatch({ + id: 'sibling', + action: 'tabs', + op: 'new', + session: 'session_b', + surface: 'browser', + profileId: 'default', + }); + expect(input.pages().map((candidate: { url(): string }) => candidate.url())) + .toEqual(['https://example.com/']); return runOutput(null); }); - const nav = await provider.dispatch({ - id: 'nav', + await provider.dispatch({ + id: 'nav-a', action: 'navigate', - session: 'work', + session: 'session_a', surface: 'browser', url: 'https://example.com/', profileId: 'default', }); - const manager = ( - provider as unknown as { - manager: { pageIdFor(target: unknown): string | undefined }; - } - ).manager; - - const first = provider.dispatch({ - id: 'run', + await provider.dispatch({ + id: 'run-a', action: 'run', - page: nav.page, - session: 'work', + session: 'session_a', surface: 'browser', - source: ` - await page.waitForEvent("popup"); - await new Promise(resolve => setTimeout(resolve, 30)); - return null; - `, - profileId: 'default', - }); - await vi.waitFor(() => { - expect(manager.pageIdFor(popup)).toEqual(expect.any(String)); - }, { interval: 1, timeout: 100 }); - const popupPageId = manager.pageIdFor(popup)!; - popup.evaluate.mockClear(); - - const second = provider.dispatch({ - id: 'exec', - action: 'exec', - page: popupPageId, - session: 'work', - surface: 'browser', - code: 'document.title', + source: 'return null;', profileId: 'default', }); - - await new Promise((resolve) => setTimeout(resolve, 5)); - expect(popup.evaluate).not.toHaveBeenCalled(); - await Promise.all([first, second]); - expect(popup.evaluate).toHaveBeenCalledTimes(1); }); - it('holds the original page queue lock throughout a bind transition', async () => { + it('denies misleading Session metadata for an owned page', async () => { const { provider, page } = makeProviderWithFakePage(); const nav = await provider.dispatch({ id: 'nav', action: 'navigate', - session: 'before-bind', + session: 'session_a', surface: 'browser', url: 'https://example.com/', profileId: 'default', }); - let markBindStarted!: () => void; - let finishBind!: () => void; - const bindStarted = new Promise((resolve) => { - markBindStarted = resolve; - }); - page.bringToFront.mockImplementation(() => { - markBindStarted(); - return new Promise((resolve) => { - finishBind = resolve; - }); - }); - page.evaluate.mockClear(); - - const bind = provider.dispatch({ + await expect(provider.dispatch({ id: 'bind', action: 'bind', page: nav.page, - session: 'after-bind', - surface: 'browser', - profileId: 'default', - }); - await bindStarted; - const exec = provider.dispatch({ - id: 'exec', - action: 'exec', - page: nav.page, - session: 'after-bind', + session: 'session_b', surface: 'browser', - code: 'document.title', profileId: 'default', - }); - - await new Promise((resolve) => setTimeout(resolve, 5)); + })).resolves.toMatchObject({ ok: false, errorCode: 'SESSION_WINDOW_CONFLICT' }); + expect(page.bringToFront).not.toHaveBeenCalled(); expect(page.evaluate).not.toHaveBeenCalled(); - finishBind(); - await Promise.all([bind, exec]); - expect(page.evaluate).toHaveBeenCalledTimes(1); - }); - - it('keeps explicit page commands queued behind a bind transition', async () => { - const { provider, page, pages, context } = makeProviderWithFakePage(); - const nav = await provider.dispatch({ - id: 'nav', - action: 'navigate', - session: 'source', - surface: 'browser', - url: 'https://example.com/', - profileId: 'default', - }); - - const priorTargetPage = fakePage('https://prior-target.example/'); - pages.push(priorTargetPage); - context.newPage.mockResolvedValueOnce(priorTargetPage); - let releaseBlocker!: () => void; - let markBlockerStarted!: () => void; - const blockerStarted = new Promise((resolve) => { - markBlockerStarted = resolve; - }); - priorTargetPage.evaluate.mockImplementationOnce(() => { - markBlockerStarted(); - return new Promise((resolve) => { - releaseBlocker = () => resolve({ ok: true }); - }); - }); - const blocker = provider.dispatch({ - id: 'blocker', - action: 'exec', - session: 'target', - surface: 'browser', - code: 'document.title', - profileId: 'default', - }); - await blockerStarted; - - let finishBind!: () => void; - let markBindStarted!: () => void; - const bindStarted = new Promise((resolve) => { - markBindStarted = resolve; - }); - page.bringToFront.mockImplementation(() => { - markBindStarted(); - return new Promise((resolve) => { - finishBind = resolve; - }); - }); - const bind = provider.dispatch({ - id: 'bind', - action: 'bind', - page: nav.page, - session: 'target', - surface: 'browser', - profileId: 'default', - }); - const beforeMapping = provider.dispatch({ - id: 'before-mapping', - action: 'exec', - page: nav.page, - session: 'target', - surface: 'browser', - code: 'document.title', - profileId: 'default', - }); - - releaseBlocker(); - await blocker; - await bindStarted; - - let finishFirstTargetExec!: () => void; - page.evaluate.mockImplementationOnce(() => ( - new Promise((resolve) => { - finishFirstTargetExec = () => resolve({ ok: true }); - }) - )); - const afterMapping = provider.dispatch({ - id: 'after-mapping', - action: 'exec', - session: 'target', - surface: 'browser', - code: 'document.title', - profileId: 'default', - }); - - finishBind(); - await vi.waitFor(() => { - expect(page.evaluate).toHaveBeenCalledTimes(1); - }, { interval: 1, timeout: 100 }); - await new Promise((resolve) => setTimeout(resolve, 5)); - expect(page.evaluate).toHaveBeenCalledTimes(1); - - finishFirstTargetExec(); - await Promise.all([bind, beforeMapping, afterMapping]); - expect(page.evaluate).toHaveBeenCalledTimes(1); - expect(priorTargetPage.evaluate).toHaveBeenCalledTimes(2); }); it('evaluates JavaScript in the requested iframe', async () => { @@ -629,7 +530,7 @@ describe('LocalCloakRuntimeProvider', () => { }); it('reversibly overrides via CDP and never pins the viewport when the context has no fixed viewport', async () => { - const { provider, page, cdpSession } = makeProviderWithFakePage(null); + const { provider, page, cdpSession, pageCdpSessions } = makeProviderWithFakePage(null); const nav = await provider.dispatch({ id: 'nav', action: 'navigate', session: 'work', surface: 'browser', url: 'https://example.com/', profileId: 'default' }); await provider.dispatch({ id: 'shot', action: 'screenshot', session: 'work', surface: 'browser', page: nav.page, format: 'png', width: 375, height: 812, profileId: 'default' }); @@ -639,7 +540,7 @@ describe('LocalCloakRuntimeProvider', () => { expect(cdpSession.send).toHaveBeenCalledWith('Emulation.setDeviceMetricsOverride', expect.objectContaining({ width: 375, height: 812 })); // ...and it must be cleared afterward so the override is per-shot only. expect(cdpSession.send).toHaveBeenCalledWith('Emulation.clearDeviceMetricsOverride'); - expect(cdpSession.detach).toHaveBeenCalledTimes(1); + expect(pageCdpSessions.some(session => session.detach.mock.calls.length > 0)).toBe(true); expect(page.screenshot).toHaveBeenCalledTimes(1); }); @@ -664,37 +565,23 @@ describe('LocalCloakRuntimeProvider', () => { }); }); - it('binds a browser session to an existing Cloak tab by page id', async () => { + it('rejects binding a page owned by another Session', async () => { const { provider, pages } = makeProviderWithFakePage(); const created = await provider.dispatch({ id: 'new', action: 'tabs', op: 'new', session: 'manual', surface: 'browser', url: 'https://signed-in.example/', profileId: 'default' }); await expect(provider.dispatch({ id: 'bind', action: 'bind', session: 'work', surface: 'browser', page: created.page, profileId: 'default' })) - .resolves.toMatchObject({ - id: 'bind', - ok: true, - page: created.page, - data: { bound: true, session: 'work', page: created.page, url: 'https://signed-in.example/' }, - }); - - await provider.dispatch({ id: 'exec', action: 'exec', session: 'work', surface: 'browser', code: 'window.__loggedIn', profileId: 'default' }); - expect(pages[1].evaluate).toHaveBeenCalledWith('window.__loggedIn'); - expect(pages[0].evaluate).not.toHaveBeenCalledWith('window.__loggedIn'); + .resolves.toMatchObject({ id: 'bind', ok: false, errorCode: 'SESSION_WINDOW_CONFLICT' }); + expect(pages[0].bringToFront).not.toHaveBeenCalled(); }); - it('binds a browser session to an existing Cloak tab by index', async () => { + it('does not enumerate another Session page by bind index', async () => { const { provider, pages } = makeProviderWithFakePage(); await provider.dispatch({ id: 'nav', action: 'navigate', session: 'first', surface: 'browser', url: 'https://first.example/', profileId: 'default' }); await provider.dispatch({ id: 'new', action: 'tabs', op: 'new', session: 'manual', surface: 'browser', url: 'https://second.example/', profileId: 'default' }); await expect(provider.dispatch({ id: 'bind', action: 'bind', session: 'work', surface: 'browser', index: 1, profileId: 'default' })) - .resolves.toMatchObject({ - id: 'bind', - ok: true, - data: { bound: true, session: 'work', url: 'https://second.example/' }, - }); - - await provider.dispatch({ id: 'exec', action: 'exec', session: 'work', surface: 'browser', code: 'document.readyState', profileId: 'default' }); - expect(pages[1].evaluate).toHaveBeenCalledWith('document.readyState'); + .resolves.toMatchObject({ id: 'bind', ok: false, errorCode: 'SESSION_WINDOW_CONFLICT' }); + expect(pages[0].bringToFront).not.toHaveBeenCalled(); }); it('returns a typed bind error when the requested Cloak tab is missing', async () => { @@ -737,15 +624,15 @@ describe('LocalCloakRuntimeProvider', () => { const created = await provider.dispatch({ id: 'new', action: 'tabs', op: 'new', session: 'work', surface: 'browser', url: 'https://second.example/', profileId: 'default' }); expect(created).toMatchObject({ id: 'new', ok: true, page: expect.any(String), data: { url: 'https://second.example/' } }); - expect(pages[1].goto).toHaveBeenCalledWith('https://second.example/', expect.objectContaining({ waitUntil: 'load' })); + expect(pages[0].goto).toHaveBeenCalledWith('https://second.example/', expect.objectContaining({ waitUntil: 'load' })); await expect(provider.dispatch({ id: 'select', action: 'tabs', op: 'select', session: 'work', surface: 'browser', page: created.page, profileId: 'default' })) .resolves.toMatchObject({ id: 'select', ok: true, page: created.page, data: { selected: true } }); - expect(pages[1].bringToFront).toHaveBeenCalled(); + expect(pages[0].bringToFront).toHaveBeenCalled(); await expect(provider.dispatch({ id: 'close', action: 'tabs', op: 'close', session: 'work', surface: 'browser', page: created.page, profileId: 'default' })) .resolves.toMatchObject({ id: 'close', ok: true, data: { closed: created.page } }); - expect(pages[1].close).toHaveBeenCalled(); + expect(pages[0].close).toHaveBeenCalled(); }); it('does not bring selected tabs to front in background window mode', async () => { @@ -763,7 +650,7 @@ describe('LocalCloakRuntimeProvider', () => { profileId: 'default', windowMode: 'background', })).resolves.toMatchObject({ id: 'select', ok: true }); - expect(pages[1].bringToFront).not.toHaveBeenCalled(); + expect(pages[0].bringToFront).not.toHaveBeenCalled(); }); it('does not bring bound tabs to front in background window mode', async () => { @@ -778,8 +665,8 @@ describe('LocalCloakRuntimeProvider', () => { page: created.page, profileId: 'default', windowMode: 'background', - })).resolves.toMatchObject({ id: 'bind', ok: true }); - expect(pages[1].bringToFront).not.toHaveBeenCalled(); + })).resolves.toMatchObject({ id: 'bind', ok: false, errorCode: 'SESSION_WINDOW_CONFLICT' }); + expect(pages[0].bringToFront).not.toHaveBeenCalled(); }); it('brings bound tabs to front by default', async () => { @@ -787,8 +674,8 @@ describe('LocalCloakRuntimeProvider', () => { const created = await provider.dispatch({ id: 'new', action: 'tabs', op: 'new', session: 'source', surface: 'browser', url: 'https://second.example/', profileId: 'default' }); await expect(provider.dispatch({ id: 'bind', action: 'bind', session: 'target', surface: 'browser', page: created.page, profileId: 'default' })) - .resolves.toMatchObject({ id: 'bind', ok: true }); - expect(pages[1].bringToFront).toHaveBeenCalledOnce(); + .resolves.toMatchObject({ id: 'bind', ok: false, errorCode: 'SESSION_WINDOW_CONFLICT' }); + expect(pages[0].bringToFront).not.toHaveBeenCalled(); }); it('rejects a page identity from a different session', async () => { diff --git a/src/browser/runtime/local-cloak/session-manager.test.ts b/src/browser/runtime/local-cloak/session-manager.test.ts index 02e38b26..acd6566b 100644 --- a/src/browser/runtime/local-cloak/session-manager.test.ts +++ b/src/browser/runtime/local-cloak/session-manager.test.ts @@ -6,29 +6,84 @@ import { dispatchCloakAction } from './actions.js'; function fakeContext() { const listeners = new Map void>>(); - const fakePage = () => ({ - goto: vi.fn().mockResolvedValue(undefined), - evaluate: vi.fn().mockResolvedValue('ok'), - title: vi.fn().mockResolvedValue('Title'), - url: vi.fn().mockReturnValue('https://example.com/'), - screenshot: vi.fn().mockResolvedValue(Buffer.from('png')), - isClosed: vi.fn().mockReturnValue(false), - close: vi.fn().mockResolvedValue(undefined), - }); + const pageListeners = new WeakMap void>>>(); + const targetIds = new WeakMap(); + const windowIds = new Map(); + let targetCounter = 0; + let windowCounter = 0; + let context: any; + const emitPageEvent = (page: any, event: string, ...args: unknown[]) => { + for (const listener of pageListeners.get(page)?.get(event) ?? []) listener(...args); + }; + const fakePage = (opener?: any, windowId = ++windowCounter) => { + let closed = false; + const page: any = { + goto: vi.fn().mockResolvedValue(undefined), + evaluate: vi.fn(async (fn: unknown) => { + if (typeof fn !== 'function' || !String(fn).includes('window.open')) return 'ok'; + const popup = fakePage(page, windowId); + allPages.push(popup); + queueMicrotask(() => { + emitPageEvent(page, 'popup', popup); + emit('page', popup); + }); + return null; + }), + title: vi.fn().mockResolvedValue('Title'), + url: vi.fn().mockReturnValue('https://example.com/'), + screenshot: vi.fn().mockResolvedValue(Buffer.from('png')), + isClosed: vi.fn(() => closed), + close: vi.fn(async () => { + closed = true; + emitPageEvent(page, 'close'); + }), + opener: vi.fn().mockResolvedValue(opener ?? null), + on(event: string, listener: (...args: unknown[]) => void) { + const events = pageListeners.get(page) ?? new Map(); + const bucket = events.get(event) ?? new Set(); + bucket.add(listener); + events.set(event, bucket); + pageListeners.set(page, events); + }, + once(event: string, listener: (...args: unknown[]) => void) { + const once = (...args: unknown[]) => { + page.off(event, once); + listener(...args); + }; + page.on(event, once); + }, + off(event: string, listener: (...args: unknown[]) => void) { + pageListeners.get(page)?.get(event)?.delete(listener); + }, + waitForEvent(event: string) { + return new Promise((resolve, reject) => { + page.once(event, resolve); + setTimeout(() => reject(new Error(`Timeout waiting for ${event}`)), 0); + }); + }, + }; + const targetId = `target-${++targetCounter}`; + targetIds.set(page, targetId); + windowIds.set(targetId, windowId); + return page; + }; const page = fakePage(); const allPages = [page]; const backgroundPages: ReturnType[] = []; const emit = (event: string, ...args: unknown[]) => { for (const listener of listeners.get(event) ?? []) listener(...args); }; - let context: any; const cdp = { - send: vi.fn(async (command: string) => { + send: vi.fn(async (command: string, params?: { targetId?: string }) => { if (command === 'Target.createTarget') { const backgroundPage = await context.newPage(); backgroundPages.push(backgroundPage); queueMicrotask(() => emit('page', backgroundPage)); + return { targetId: targetIds.get(backgroundPage) }; } + if (command === 'Browser.getWindowForTarget') return { windowId: windowIds.get(params?.targetId ?? '') }; + if (command === 'Target.closeTarget') return { success: true }; + return {}; }), detach: vi.fn().mockResolvedValue(undefined), }; @@ -49,6 +104,13 @@ function fakeContext() { allPages.push(created); return created; }), + newCDPSession: vi.fn(async (target: object) => ({ + send: vi.fn(async (command: string) => { + if (command === 'Target.getTargetInfo') return { targetInfo: { targetId: targetIds.get(target) } }; + return {}; + }), + detach: vi.fn().mockResolvedValue(undefined), + })), browser: vi.fn().mockReturnValue({ newBrowserCDPSession: vi.fn().mockResolvedValue(cdp) }), cookies: vi.fn().mockResolvedValue([{ name: 'sid', value: '1', domain: 'example.com', path: '/' }]), close: vi.fn().mockResolvedValue(undefined), @@ -56,6 +118,11 @@ function fakeContext() { page, backgroundPages, cdp, + targetIdFor: (target: object) => targetIds.get(target), + windowIdFor: (target: object) => windowIds.get(targetIds.get(target) ?? ''), + moveToWindow: (target: object, windowId: number) => windowIds.set(targetIds.get(target)!, windowId), + emitPage: (target: object) => emit('page', target), + makePage: fakePage, }; } @@ -84,6 +151,151 @@ describe('CloakSessionManager', () => { expect(launchPersistentContext.mock.calls[0][0]).toMatchObject({ headless: false }); }); + it('correlates created targets and isolates Sessions into owned windows', async () => { + const launched = fakeContext(); + const manager = new CloakSessionManager({ + baseDir: '/tmp/webcmd-test', + launchPersistentContext: vi.fn().mockResolvedValue(launched.context), + }); + + const first = await manager.getPage({ profileId: 'default', session: 'session_a', sessionId: 'session_a', surface: 'browser' }); + const second = await manager.getPage({ profileId: 'default', session: 'session_b', sessionId: 'session_b', surface: 'browser' }); + + expect(launched.cdp.send.mock.calls.filter(([method]) => method === 'Target.createTarget')) + .toHaveLength(2); + expect(launched.windowIdFor(first.page)).not.toBe(launched.windowIdFor(second.page)); + expect((await manager.listPages({ profileId: 'default', session: 'session_a', sessionId: 'session_a' })) + .map(tab => tab.sessionId)).toEqual(['session_a']); + }); + + it('matches Target.createTarget by target id instead of adopting the next context page', async () => { + const launched = fakeContext(); + const unrelated = launched.makePage(); + const send = launched.cdp.send.getMockImplementation()!; + launched.cdp.send.mockImplementationOnce(async (method: string, params: unknown) => { + launched.emitPage(unrelated); + return send(method, params as { targetId?: string } | undefined); + }); + const manager = new CloakSessionManager({ + baseDir: '/tmp/webcmd-test', + launchPersistentContext: vi.fn().mockResolvedValue(launched.context), + }); + + const lease = await manager.getPage({ profileId: 'default', session: 'session_a', sessionId: 'session_a', surface: 'browser' }); + + expect(lease.page).not.toBe(unrelated); + expect(manager.pageIdFor(unrelated)).toBeUndefined(); + expect(launched.targetIdFor(lease.page)).toEqual(expect.stringMatching(/^target-/)); + }); + + it('uses the noopener popup even though window.open returns null and falls back when no popup appears', async () => { + const launched = fakeContext(); + const manager = new CloakSessionManager({ + baseDir: '/tmp/webcmd-test', + launchPersistentContext: vi.fn().mockResolvedValue(launched.context), + }); + const key = { profileId: 'default', session: 'session_a', sessionId: 'session_a', surface: 'browser' as const }; + const first = await manager.getPage(key); + + await manager.newPage(key); + const evaluate = vi.mocked(first.page.evaluate); + expect(String(evaluate.mock.calls[0][0])).toContain('noopener'); + expect(launched.context.newCDPSession.mock.calls.length).toBeGreaterThanOrEqual(2); + expect(launched.cdp.send.mock.calls.filter(([method]) => method === 'Target.createTarget')).toHaveLength(1); + + evaluate.mockRejectedValueOnce(new Error('Execution context was destroyed')); + const afterThrow = await manager.newPage(key); + evaluate.mockResolvedValueOnce(null); + const afterNull = await manager.newPage(key); + + expect(launched.cdp.send.mock.calls.filter(([method]) => method === 'Target.createTarget')).toHaveLength(3); + expect(launched.windowIdFor(afterThrow.page)).not.toBe(launched.windowIdFor(first.page)); + expect(launched.windowIdFor(afterNull.page)).not.toBe(launched.windowIdFor(first.page)); + expect((await manager.listPages(key)).every(tab => tab.session === 'session_a')).toBe(true); + }); + + it('times out target correlation and releases the profile creation lock', async () => { + vi.useFakeTimers(); + const launched = fakeContext(); + const send = launched.cdp.send.getMockImplementation()!; + launched.cdp.send.mockImplementationOnce(async () => ({ targetId: 'missing-target' })); + const manager = new CloakSessionManager({ + baseDir: '/tmp/webcmd-test', + launchPersistentContext: vi.fn().mockResolvedValue(launched.context), + }); + const key = { profileId: 'default', session: 'session_a', sessionId: 'session_a', surface: 'browser' as const }; + + const missing = manager.getPage(key); + const missingExpectation = expect(missing).rejects.toThrow('Timed out waiting for Cloak target missing-target'); + await vi.waitFor(() => expect(launched.cdp.send).toHaveBeenCalledWith('Target.createTarget', expect.any(Object))); + await vi.advanceTimersByTimeAsync(1_000); + await missingExpectation; + + launched.cdp.send.mockImplementation(send); + const next = manager.getPage(key); + await vi.runAllTimersAsync(); + await expect(next).resolves.toMatchObject({ pageId: expect.any(String) }); + }); + + it('registers a child-window popup under its opener Session', async () => { + const launched = fakeContext(); + const manager = new CloakSessionManager({ + baseDir: '/tmp/webcmd-test', + launchPersistentContext: vi.fn().mockResolvedValue(launched.context), + }); + const key = { profileId: 'default', session: 'session_a', sessionId: 'session_a', surface: 'browser' as const }; + const first = await manager.getPage(key); + const popup = launched.makePage(first.page, 999); + + launched.emitPage(popup); + await vi.waitFor(() => expect(manager.pageIdFor(popup)).toEqual(expect.any(String))); + + expect((await manager.listPages(key)).map(tab => tab.session)).toEqual(['session_a', 'session_a']); + expect(launched.windowIdFor(popup)).toBe(999); + }); + + it('rejects every operation after a Session page moves into another owned window', async () => { + const launched = fakeContext(); + const manager = new CloakSessionManager({ + baseDir: '/tmp/webcmd-test', + launchPersistentContext: vi.fn().mockResolvedValue(launched.context), + }); + const a = { profileId: 'default', session: 'session_a', sessionId: 'session_a', surface: 'browser' as const }; + const b = { profileId: 'default', session: 'session_b', sessionId: 'session_b', surface: 'browser' as const }; + const first = await manager.getPage(a); + const second = await manager.getPage(b); + launched.moveToWindow(first.page, launched.windowIdFor(second.page)!); + + await expect(manager.listPages(a)).rejects.toMatchObject({ code: 'SESSION_WINDOW_CONFLICT' }); + await expect(manager.selectPage({ ...a, pageId: first.pageId })).rejects.toMatchObject({ code: 'SESSION_WINDOW_CONFLICT' }); + await expect(manager.bindPage({ ...a, pageId: first.pageId })).rejects.toMatchObject({ code: 'SESSION_WINDOW_CONFLICT' }); + await expect(manager.closePage({ ...a, pageId: first.pageId })).rejects.toMatchObject({ code: 'SESSION_WINDOW_CONFLICT' }); + await expect(manager.closeSession(a.profileId, a.sessionId)).rejects.toMatchObject({ code: 'SESSION_WINDOW_CONFLICT' }); + expect(first.page.close).not.toHaveBeenCalled(); + expect(await manager.findPageById(second.pageId, a)).toBeNull(); + }); + + it('binds an unowned context page without adopting another Session page', async () => { + const launched = fakeContext(); + const manager = new CloakSessionManager({ + baseDir: '/tmp/webcmd-test', + launchPersistentContext: vi.fn().mockResolvedValue(launched.context), + }); + await manager.getPage({ profileId: 'default', session: 'session_a', surface: 'browser' }); + + const bound = await manager.bindPage({ + profileId: 'default', + session: 'session_b', + surface: 'browser', + index: 0, + }); + + expect(bound?.page).toBe(launched.page); + expect((await manager.listPages({ profileId: 'default', session: 'session_b' })).map(tab => tab.id)) + .toEqual([bound?.pageId]); + expect(await manager.listPages({ profileId: 'default', session: 'session_a' })).toHaveLength(1); + }); + it.each([ { platform: 'darwin', windowMode: 'background', backgroundCalls: 1, normalCalls: 0 }, { platform: 'darwin', windowMode: 'foreground', backgroundCalls: 0, normalCalls: 1 }, @@ -217,7 +429,7 @@ describe('CloakSessionManager', () => { const [first, second] = await Promise.all([firstRequest, secondRequest]); expect(first.page).not.toBe(second.page); - expect(launched.backgroundPages).toEqual([first.page, second.page]); + expect(launched.backgroundPages.slice(-2)).toEqual([first.page, second.page]); }); it('coalesces concurrent same-lease page acquisition', async () => { @@ -244,15 +456,13 @@ describe('CloakSessionManager', () => { expect(second.context).toBe(launched.context); expect(first.page).toBe(second.page); expect(first.pageId).toBe(second.pageId); - expect(launched.context.newPage).not.toHaveBeenCalled(); - expect(launched.cdp.send).not.toHaveBeenCalled(); + expect(launched.context.newPage).toHaveBeenCalledOnce(); + expect(launched.cdp.send).toHaveBeenCalledWith('Target.createTarget', expect.objectContaining({ newWindow: true })); }); it('evicts a closed runtime and clears every tracked page resource', async () => { vi.useFakeTimers(); const launched = fakeContext(); - const secondPage = fakeContext().page; - launched.context.newPage.mockResolvedValue(secondPage); const manager = new CloakSessionManager({ baseDir: '/tmp/webcmd-test', launchPersistentContext: vi.fn().mockResolvedValue(launched.context), @@ -468,7 +678,7 @@ describe('CloakSessionManager', () => { url: 'https://example.com/', }); await navigationStarted; - const pagesDuringNavigation = await manager.listPages({ profileId: 'default' }); + const pagesDuringNavigation = await manager.listPages({ profileId: 'default', session: 'work' }); const pageIdDuringNavigation = manager.pageIdFor(launched.page as unknown as PlaywrightPage); const timersDuringNavigation = vi.getTimerCount(); resolveNavigation(); @@ -478,7 +688,7 @@ describe('CloakSessionManager', () => { expect(pageIdDuringNavigation).toBeUndefined(); expect(timersDuringNavigation).toBe(0); expect(manager.pageIdFor(launched.page as unknown as PlaywrightPage)).toBe(lease.pageId); - expect(await manager.listPages({ profileId: 'default' })).toHaveLength(1); + expect(await manager.listPages({ profileId: 'default', session: 'work' })).toHaveLength(1); expect(vi.getTimerCount()).toBe(1); }); @@ -500,7 +710,7 @@ describe('CloakSessionManager', () => { expect(launchPersistentContext).toHaveBeenCalledTimes(1); expect(launched.page.goto).toHaveBeenCalledTimes(1); expect(launched.page.close).toHaveBeenCalledTimes(1); - expect(await manager.listPages({ profileId: 'default' })).toEqual([]); + expect(await manager.listPages({ profileId: 'default', session: 'work' })).toEqual([]); }); it('clears a stale Cloak profile owner and retries when Chromium reports an existing session', async () => { @@ -656,7 +866,7 @@ describe('CloakSessionManager', () => { await vi.advanceTimersByTimeAsync(1); expect(lease.page.close).toHaveBeenCalled(); - expect(await manager.listPages({ profileId: 'default' })).toEqual([]); + expect(await manager.listPages({ profileId: 'default', session: 'work' })).toEqual([]); }); it('refreshes an idle timeout when a lease is reused', async () => { @@ -690,7 +900,7 @@ describe('CloakSessionManager', () => { await vi.advanceTimersByTimeAsync(25); expect(lease.page.close).not.toHaveBeenCalled(); - expect(await manager.listPages({ profileId: 'default' })).toHaveLength(1); + expect(await manager.listPages({ profileId: 'default', session: 'site:x:uuid' })).toHaveLength(1); }); it('launches a preferred profile when no Cloak profile is active', async () => { diff --git a/src/browser/runtime/local-cloak/session-manager.ts b/src/browser/runtime/local-cloak/session-manager.ts index 3577ab42..0add5793 100644 --- a/src/browser/runtime/local-cloak/session-manager.ts +++ b/src/browser/runtime/local-cloak/session-manager.ts @@ -2,7 +2,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { execFile } from 'node:child_process'; -import type { BrowserContext, Page as PlaywrightPage } from 'playwright-core'; +import type { Browser, BrowserContext, CDPSession, Page as PlaywrightPage } from 'playwright-core'; import { launchPersistentContext as cloakLaunchPersistentContext } from 'cloakbrowser'; import type { BrowserSurface, BrowserWindowMode, SiteSessionMode } from '../../protocol.js'; import { activateDarwinBackgroundContext, launchDarwinBackgroundPersistentContext } from './darwin-background-launch.js'; @@ -11,6 +11,7 @@ import { CloakNetworkCapture } from './network.js'; import { findPackageRoot } from '../../../package-paths.js'; const UNRESOLVED = Symbol('unresolved'); +const TARGET_PAGE_MATCH_TIMEOUT_MS = 1_000; let cachedCloakBrowserVersion: string | undefined | typeof UNRESOLVED = UNRESOLVED; /** @@ -54,6 +55,9 @@ export interface SessionKeyInput { type PageEntry = { page: PlaywrightPage; pageId: string; + targetId: string; + leaseKey: string; + sessionId?: string; session: string; surface: BrowserSurface; siteSession?: SiteSessionMode; @@ -77,17 +81,44 @@ export interface CloakTabInfo { url: string; profileId: string; session: string; + sessionId: string; surface: BrowserSurface; selected: boolean; } interface ProfileRuntime { context: BrowserContext; - pages: Map; - selectedPageIds: Map; + cdp: CDPSession; + sessions: Map; + windowOwners: Map; + targetPages: Map; lastSeenAt: number; } +interface SessionRuntime { + id: string; + windowIds: Set; + pages: Map; + selectedPageId?: string; +} + +export interface BrowserRunSessionScope { + browser: Browser; + context: BrowserContext; + page: PlaywrightPage; + pages(): readonly PlaywrightPage[]; + createPage(): Promise; + onPage(listener: (page: PlaywrightPage) => void): () => void; +} + +export class SessionWindowConflictError extends Error { + readonly code = 'SESSION_WINDOW_CONFLICT'; + + constructor(pageId: string, sessionId: string, owner?: string) { + super(`Page ${pageId} is in a window owned by Session ${owner ?? 'unknown'}, not ${sessionId}.`); + } +} + export interface CloakSessionManagerOptions { baseDir?: string; launchPersistentContext?: LaunchPersistentContext; @@ -133,6 +164,16 @@ export class CloakSessionManager { private readonly profiles = new Map(); private readonly profileLaunches = new Map>(); private readonly pageCreationQueues = new Map>(); + private readonly pageTargetIds = new WeakMap(); + private readonly pageTargetIdPromises = new WeakMap>(); + private readonly pageCdpSessions = new WeakMap(); + private readonly pendingTargetPages = new WeakMap>(); + private readonly targetPageWaiters = new WeakMap; + }>>(); + private readonly sessionPageListeners = new WeakMap void>>(); constructor(private readonly opts: CloakSessionManagerOptions = {}) { this.launchPersistentContext = opts.launchPersistentContext ?? cloakLaunchPersistentContext; @@ -159,75 +200,68 @@ export class CloakSessionManager { async getPage(input: SessionKeyInput): Promise { const profileId = normalizeProfileId(input.profileId); const session = requireSession(input.session); + const sessionId = requireSessionId(input); const surface = normalizeSurface(input.surface); const leaseKey = resolveLeaseKey(input); const freshPage = input.freshPage === true; return this.withPageCreationLock(profileId, async () => { const runtime = await this.getProfileRuntime(profileId, input.windowMode); - const existing = runtime.pages.get(leaseKey); + const sessionRuntime = this.getSessionRuntime(runtime, sessionId); + const existing = sessionRuntime.pages.get(leaseKey); if (existing && !pageIsClosed(existing.page) && !freshPage) { + await this.assertOwnedWindow(runtime, sessionId, existing); runtime.lastSeenAt = Date.now(); existing.idleTimeout = input.idleTimeout; - this.refreshIdleTimer(runtime, leaseKey, existing); + this.refreshIdleTimer(runtime, sessionRuntime, leaseKey, existing); return { profileId, leaseKey, context: runtime.context, page: existing.page, pageId: existing.pageId }; } - if (existing && freshPage) { - runtime.pages.delete(leaseKey); - this.clearIdleTimer(existing); - this.clearSelectedPage(runtime, existing); - if (!pageIsClosed(existing.page)) await existing.page.close().catch(() => {}); - } - - return this.createPageWithRecoveryAttempt( - profileId, - input.windowMode, - (candidate) => { - const existingPages = candidate.context.pages(); - // freshPage must never adopt a leftover tab — its whole point is a clean DOM. - return !freshPage && existingPages[0] && candidate.pages.size === 0 - ? existingPages[0] - : this.createPage(candidate.context, input.windowMode); - }, - (candidate, page) => { - const pageId = nextPageId(); - const entry: PageEntry = { page, pageId, session, surface, siteSession: input.siteSession, idleTimeout: input.idleTimeout }; - candidate.pages.set(leaseKey, entry); - this.refreshIdleTimer(candidate, leaseKey, entry); - this.setSelectedPage(candidate, entry); - candidate.lastSeenAt = Date.now(); - return { profileId, leaseKey, context: candidate.context, page, pageId }; - }, - ); + const acquired = await this.acquireSessionPage(profileId, sessionId, input.windowMode); + const entry = await this.registerOwnedPage(acquired.runtime, acquired.session, acquired.page, { + leaseKey, + session, + surface, + siteSession: input.siteSession, + idleTimeout: input.idleTimeout, + }); + if (existing && freshPage && existing !== entry) await this.removeEntry(acquired.runtime, sessionRuntime, existing, true); + this.selectEntry(acquired.session, entry); + acquired.runtime.lastSeenAt = Date.now(); + return { profileId, leaseKey, context: acquired.runtime.context, page: entry.page, pageId: entry.pageId }; }); } - findPage(input: SessionKeyInput): CloakPageLease | null { + async findPage(input: SessionKeyInput): Promise { const profileId = normalizeProfileId(input.profileId); + const sessionId = requireSessionId(input); const leaseKey = resolveLeaseKey(input); const runtime = this.profiles.get(profileId); - const entry = runtime?.pages.get(leaseKey); - if (!runtime || !entry || pageIsClosed(entry.page)) return null; + const sessionRuntime = runtime?.sessions.get(sessionId); + const entry = sessionRuntime?.pages.get(leaseKey); + if (!runtime || !sessionRuntime || !entry || pageIsClosed(entry.page)) return null; + await this.assertOwnedWindow(runtime, sessionId, entry); runtime.lastSeenAt = Date.now(); entry.idleTimeout = input.idleTimeout; - this.refreshIdleTimer(runtime, leaseKey, entry); + this.refreshIdleTimer(runtime, sessionRuntime, leaseKey, entry); return { profileId, leaseKey, context: runtime.context, page: entry.page, pageId: entry.pageId }; } - findPageById(pageId: string, opts: Pick = {}): CloakPageLease | null { - const expectedProfileId = opts.profileId ? normalizeProfileId(opts.profileId) : undefined; - const expectedSession = opts.session?.trim(); + async findPageById(pageId: string, opts: Pick): Promise { + const expectedProfileId = normalizeProfileId(opts.profileId); + const sessionId = requireSessionId(opts); const expectedSurface = opts.surface ? normalizeSurface(opts.surface) : undefined; for (const [profileId, runtime] of this.profiles.entries()) { - if (expectedProfileId && expectedProfileId !== profileId) continue; - for (const [leaseKey, entry] of runtime.pages.entries()) { + if (expectedProfileId !== profileId) continue; + const sessionRuntime = runtime.sessions.get(sessionId); + if (!sessionRuntime) return null; + for (const [leaseKey, entry] of sessionRuntime.pages.entries()) { if ( entry.pageId === pageId && !pageIsClosed(entry.page) - && (!expectedSession || entry.session === expectedSession) && (!expectedSurface || entry.surface === expectedSurface) ) { + await this.assertOwnedWindow(runtime, sessionId, entry); entry.idleTimeout = opts.idleTimeout; - this.refreshIdleTimer(runtime, leaseKey, entry); + this.refreshIdleTimer(runtime, sessionRuntime, leaseKey, entry); return { profileId, leaseKey, context: runtime.context, page: entry.page, pageId: entry.pageId }; } } @@ -237,7 +271,7 @@ export class CloakSessionManager { pageOwner(pageId: string): { profileId: string; session: string; surface: BrowserSurface } | null { for (const [profileId, runtime] of this.profiles.entries()) { - for (const entry of runtime.pages.values()) { + for (const entry of runtime.targetPages.values()) { if (entry.pageId === pageId && !pageIsClosed(entry.page)) { return { profileId, session: entry.session, surface: entry.surface }; } @@ -248,48 +282,53 @@ export class CloakSessionManager { pageIdFor(page: PlaywrightPage): string | undefined { for (const runtime of this.profiles.values()) { - for (const entry of runtime.pages.values()) { + for (const entry of runtime.targetPages.values()) { if (entry.page === page) return entry.pageId; } } return undefined; } - registerPage(input: SessionKeyInput, page: PlaywrightPage): string { - const profileId = normalizeProfileId(input.profileId); - const session = requireSession(input.session); - const surface = normalizeSurface(input.surface); - const runtime = this.profiles.get(profileId); - if (!runtime) throw new Error(`Profile ${profileId} is not running`); - const pageId = nextPageId(); - const leaseKey = `${resolveLeaseKey(input)}\u0000${pageId}`; - const entry: PageEntry = { page, pageId, session, surface, siteSession: input.siteSession, idleTimeout: input.idleTimeout }; - runtime.pages.set(leaseKey, entry); - this.refreshIdleTimer(runtime, leaseKey, entry); - this.setSelectedPage(runtime, entry); - runtime.lastSeenAt = Date.now(); - return pageId; - } - - sessionPages(input: Pick): PlaywrightPage[] { + async browserRunScope(input: SessionKeyInput, page: PlaywrightPage): Promise { const profileId = normalizeProfileId(input.profileId); - const session = input.session?.trim(); - const surface = input.surface ? normalizeSurface(input.surface) : undefined; + const sessionId = requireSessionId(input); const runtime = this.profiles.get(profileId); - if (!runtime || !session) return []; - return this.openEntries(runtime) - .filter(([, entry]) => entry.session === session && (!surface || entry.surface === surface)) - .map(([, entry]) => entry.page); + const sessionRuntime = runtime?.sessions.get(sessionId); + const entry = runtime && [...runtime.targetPages.values()].find(candidate => candidate.page === page); + if (!runtime || !sessionRuntime || !entry || entry.sessionId !== sessionId) { + throw new Error('Browser-run page is outside the selected Session.'); + } + await Promise.all(this.openEntries(sessionRuntime).map(([, candidate]) => ( + this.assertOwnedWindow(runtime, sessionId, candidate) + ))); + const browser = runtime.context.browser(); + if (!browser) throw new Error('The selected browser context is not attached to a browser.'); + return { + browser, + context: runtime.context, + page, + pages: () => this.openEntries(sessionRuntime).map(([, candidate]) => candidate.page), + createPage: async () => (await this.newPage(input)).page, + onPage: (listener) => { + const listeners = this.sessionPageListeners.get(sessionRuntime) ?? new Set(); + listeners.add(listener); + this.sessionPageListeners.set(sessionRuntime, listeners); + return () => listeners.delete(listener); + }, + }; } - async listPages(input: Pick): Promise { + async listPages(input: Pick): Promise { const profileId = normalizeProfileId(input.profileId); - const session = input.session?.trim(); + const sessionId = requireSessionId(input); const surface = input.surface ? normalizeSurface(input.surface) : undefined; const runtime = this.profiles.get(profileId); if (!runtime) return []; - const entries = this.openEntries(runtime) - .filter(([, entry]) => (!session || entry.session === session) && (!surface || entry.surface === surface)); + const sessionRuntime = runtime.sessions.get(sessionId); + if (!sessionRuntime) return []; + const entries = this.openEntries(sessionRuntime) + .filter(([, entry]) => !surface || entry.surface === surface); + await Promise.all(entries.map(([, entry]) => this.assertOwnedWindow(runtime, sessionId, entry))); return Promise.all(entries.map(async ([, entry], index) => ({ id: entry.pageId, page: entry.pageId, @@ -298,21 +337,21 @@ export class CloakSessionManager { url: entry.page.url(), profileId, session: entry.session, + sessionId, surface: entry.surface, - selected: runtime.selectedPageIds.get(selectionKey(entry)) === entry.pageId, + selected: sessionRuntime.selectedPageId === entry.pageId, }))); } async newPage(input: SessionKeyInput & { url?: string }): Promise { const profileId = normalizeProfileId(input.profileId); const session = requireSession(input.session); + const sessionId = requireSessionId(input); const surface = normalizeSurface(input.surface); - const acquired = await this.createPageWithRecovery( - profileId, - input.windowMode, - (candidate) => this.createPage(candidate.context, input.windowMode), - (runtime, page) => ({ runtime, page }), - ); + const acquired = await this.withPageCreationLock(profileId, async () => { + const result = await this.acquireSessionPage(profileId, sessionId, input.windowMode); + return { runtime: result.runtime, sessionRuntime: result.session, page: result.page }; + }); if (input.url) { try { await acquired.page.goto(input.url, { waitUntil: 'load' }); @@ -325,35 +364,36 @@ export class CloakSessionManager { if (!pageIsClosed(acquired.page)) await acquired.page.close().catch(() => {}); throw new Error('Target page, context or browser has been closed'); } - const pageId = nextPageId(); - const leaseKey = `${resolveLeaseKey(input)}\u0000${pageId}`; - const entry: PageEntry = { - page: acquired.page, - pageId, + const entry = await this.registerOwnedPage(acquired.runtime, acquired.sessionRuntime, acquired.page, { session, surface, siteSession: input.siteSession, idleTimeout: input.idleTimeout, - }; - acquired.runtime.pages.set(leaseKey, entry); - this.refreshIdleTimer(acquired.runtime, leaseKey, entry); + }); + const leaseKey = entry.leaseKey; + this.refreshIdleTimer(acquired.runtime, acquired.sessionRuntime, leaseKey, entry); + this.selectEntry(acquired.sessionRuntime, entry); acquired.runtime.lastSeenAt = Date.now(); - return { profileId, leaseKey, context: acquired.runtime.context, page: acquired.page, pageId }; + return { profileId, leaseKey, context: acquired.runtime.context, page: acquired.page, pageId: entry.pageId }; } - async selectPage(input: Pick & { pageId?: string; index?: number }): Promise { + async selectPage(input: Pick & { pageId?: string; index?: number }): Promise { const profileId = normalizeProfileId(input.profileId); + const sessionId = requireSessionId(input); const runtime = this.profiles.get(profileId); if (!runtime) return null; - const candidates = this.sessionEntries(runtime, input); + const sessionRuntime = runtime.sessions.get(sessionId); + if (!sessionRuntime) return null; + const candidates = this.sessionEntries(sessionRuntime, input); const match = input.pageId ? candidates.find(([, entry]) => entry.pageId === input.pageId) : candidates[input.index ?? -1]; if (!match) return null; const [leaseKey, entry] = match; + await this.assertOwnedWindow(runtime, sessionId, entry); if (input.windowMode !== 'background') { await entry.page.bringToFront?.().catch(() => {}); await this.activateBackgroundContext(runtime.context); } - this.setSelectedPage(runtime, entry); + this.selectEntry(sessionRuntime, entry); runtime.lastSeenAt = Date.now(); return { profileId, leaseKey, context: runtime.context, page: entry.page, pageId: entry.pageId }; } @@ -361,16 +401,39 @@ export class CloakSessionManager { async bindPage(input: SessionKeyInput & { pageId?: string; index?: number }): Promise { const profileId = normalizeProfileId(input.profileId); const session = requireSession(input.session); + const sessionId = requireSessionId(input); const surface = normalizeSurface(input.surface); const runtime = this.profiles.get(profileId); if (!runtime) return null; - - const match = input.pageId ? this.findEntryByPageId(runtime, input.pageId) : this.openEntries(runtime)[input.index ?? -1]; + const sessionRuntime = this.getSessionRuntime(runtime, sessionId); + let match = input.pageId ? this.findEntryByPageId(runtime, input.pageId) : this.openEntries(sessionRuntime)[input.index ?? -1]; + if (!match && input.index !== undefined) { + const page = runtime.context.pages().filter(candidate => !pageIsClosed(candidate))[input.index]; + if (page) { + const targetId = await this.targetIdForPage(runtime, page); + const entry = runtime.targetPages.get(targetId) ?? { + page, + pageId: nextPageId(), + targetId, + leaseKey: `unowned\u0000${targetId}`, + session: '', + surface, + }; + if (!runtime.targetPages.has(targetId)) { + runtime.targetPages.set(targetId, entry); + this.attachPageLifecycle(runtime, entry); + } + match = [entry.leaseKey, entry]; + } + } if (!match) return null; - const [sourceKey, entry] = match; + const entry = match[1]; + await this.assertBindableWindow(runtime, sessionRuntime, entry); + const sourceSession = entry.sessionId ? runtime.sessions.get(entry.sessionId) : undefined; + const sourceKey = entry.leaseKey; const canonicalKey = resolveLeaseKey(input); - const currentCanonical = runtime.pages.get(canonicalKey); + const currentCanonical = sessionRuntime.pages.get(canonicalKey); if (input.windowMode !== 'background') { await entry.page.bringToFront?.().catch(() => {}); @@ -379,58 +442,61 @@ export class CloakSessionManager { if (currentCanonical && currentCanonical !== entry && !pageIsClosed(currentCanonical.page)) { const preservedKey = `${canonicalKey}\u0000${currentCanonical.pageId}`; - runtime.pages.delete(canonicalKey); - runtime.pages.set(preservedKey, currentCanonical); - this.refreshIdleTimer(runtime, preservedKey, currentCanonical); + sessionRuntime.pages.delete(canonicalKey); + currentCanonical.leaseKey = preservedKey; + sessionRuntime.pages.set(preservedKey, currentCanonical); + this.refreshIdleTimer(runtime, sessionRuntime, preservedKey, currentCanonical); } - if (sourceKey !== canonicalKey) runtime.pages.delete(sourceKey); + sourceSession?.pages.delete(sourceKey); + entry.sessionId = sessionId; + entry.leaseKey = canonicalKey; entry.session = session; entry.surface = surface; entry.siteSession = input.siteSession; entry.idleTimeout = input.idleTimeout; - runtime.pages.set(canonicalKey, entry); - this.refreshIdleTimer(runtime, canonicalKey, entry); - this.setSelectedPage(runtime, entry); + sessionRuntime.pages.set(canonicalKey, entry); + this.refreshIdleTimer(runtime, sessionRuntime, canonicalKey, entry); + this.selectEntry(sessionRuntime, entry); runtime.lastSeenAt = Date.now(); return { profileId, leaseKey: canonicalKey, context: runtime.context, page: entry.page, pageId: entry.pageId }; } - async closePage(input: Pick & { pageId?: string; index?: number }): Promise { + async closePage(input: Pick & { pageId?: string; index?: number }): Promise { const profileId = normalizeProfileId(input.profileId); + const sessionId = requireSessionId(input); const runtime = this.profiles.get(profileId); if (!runtime) return null; - const candidates = this.sessionEntries(runtime, input); + const sessionRuntime = runtime.sessions.get(sessionId); + if (!sessionRuntime) return null; + const candidates = this.sessionEntries(sessionRuntime, input); const match = input.pageId ? candidates.find(([, entry]) => entry.pageId === input.pageId) : candidates[input.index ?? -1]; if (!match) return null; - const [leaseKey, entry] = match; - runtime.pages.delete(leaseKey); - this.clearIdleTimer(entry); - this.clearSelectedPage(runtime, entry); - if (!pageIsClosed(entry.page)) await entry.page.close().catch(() => {}); + const [, entry] = match; + await this.assertOwnedWindow(runtime, sessionId, entry); + await this.removeEntry(runtime, sessionRuntime, entry, true); runtime.lastSeenAt = Date.now(); return entry.pageId; } async release(input: SessionKeyInput): Promise { const profileId = normalizeProfileId(input.profileId); + const sessionId = requireSessionId(input); const runtime = this.profiles.get(profileId); if (!runtime) return; + const sessionRuntime = runtime.sessions.get(sessionId); + if (!sessionRuntime) return; const leaseKey = resolveLeaseKey(input); const surface = normalizeSurface(input.surface); - const entries = this.openEntries(runtime).filter(([key, entry]) => ( + const entries = this.openEntries(sessionRuntime).filter(([key, entry]) => ( surface === 'adapter' ? key === leaseKey : entry.session === requireSession(input.session) && entry.surface === surface )); - for (const [key, entry] of entries) { + await Promise.all(entries.map(([, entry]) => this.assertOwnedWindow(runtime, sessionId, entry))); + for (const [, entry] of entries) { if (entry.siteSession === 'persistent') continue; - runtime.pages.delete(key); - this.clearIdleTimer(entry); - this.clearSelectedPage(runtime, entry); - if (!pageIsClosed(entry.page)) { - await entry.page.close().catch(() => {}); - } + await this.removeEntry(runtime, sessionRuntime, entry, true); } } @@ -438,7 +504,7 @@ export class CloakSessionManager { const profileId = normalizeProfileId(profileIdInput); const session = requireSession(sessionInput); const runtime = this.profiles.get(profileId); - return Boolean(runtime && this.openEntries(runtime).some(([, entry]) => entry.session === session)); + return Boolean(runtime?.sessions.get(session) && this.openEntries(runtime.sessions.get(session)!).length > 0); } async closeSession(profileIdInput: string | undefined, sessionInput: string | undefined): Promise { @@ -446,20 +512,18 @@ export class CloakSessionManager { const session = requireSession(sessionInput); const runtime = this.profiles.get(profileId); if (!runtime) return 0; - const entries = this.openEntries(runtime).filter(([, entry]) => entry.session === session); - for (const [key, entry] of entries) { - runtime.pages.delete(key); - this.clearIdleTimer(entry); - this.clearSelectedPage(runtime, entry); - if (!pageIsClosed(entry.page)) await entry.page.close().catch(() => {}); - } + const sessionRuntime = runtime.sessions.get(session); + if (!sessionRuntime) return 0; + const entries = this.openEntries(sessionRuntime); + await Promise.all(entries.map(([, entry]) => this.assertOwnedWindow(runtime, session, entry))); + for (const [, entry] of entries) await this.removeEntry(runtime, sessionRuntime, entry, true); if (entries.length > 0) runtime.lastSeenAt = Date.now(); return entries.length; } async shutdown(): Promise { for (const runtime of this.profiles.values()) { - for (const entry of runtime.pages.values()) this.clearIdleTimer(entry); + for (const entry of runtime.targetPages.values()) this.clearIdleTimer(entry); await runtime.context.close().catch(() => {}); } this.profiles.clear(); @@ -498,7 +562,18 @@ export class CloakSessionManager { if (!isProfileAlreadyInUseError(err) || !(await this.recoverLockedProfile(userDataDir))) throw err; context = await launchPersistentContext(launchOptions); } - const runtime = { context, pages: new Map(), selectedPageIds: new Map(), lastSeenAt: Date.now() }; + const browser = context.browser(); + if (!browser) throw new Error('Cloak page creation requires a Chromium browser connection.'); + const runtime: ProfileRuntime = { + context, + cdp: await browser.newBrowserCDPSession(), + sessions: new Map(), + windowOwners: new Map(), + targetPages: new Map(), + lastSeenAt: Date.now(), + }; + this.pendingTargetPages.set(runtime, new Map()); + this.targetPageWaiters.set(runtime, new Map()); this.attachRuntimeLifecycle(profileId, runtime); this.profiles.set(profileId, runtime); return runtime; @@ -507,132 +582,311 @@ export class CloakSessionManager { private invalidateProfileRuntime(profileId: string, runtime: ProfileRuntime): void { if (this.profiles.get(profileId) !== runtime) return; this.profiles.delete(profileId); - for (const entry of runtime.pages.values()) { + for (const entry of runtime.targetPages.values()) { if (entry.idleTimer) clearTimeout(entry.idleTimer); this.networkCapture.stop(entry.page); + void this.pageCdpSessions.get(entry.page)?.detach().catch(() => {}); + } + runtime.targetPages.clear(); + runtime.sessions.clear(); + runtime.windowOwners.clear(); + for (const waiter of this.targetPageWaiters.get(runtime)?.values() ?? []) { + clearTimeout(waiter.timer); + waiter.reject(new Error('Target page, context or browser has been closed')); } - runtime.pages.clear(); - runtime.selectedPageIds.clear(); + this.targetPageWaiters.get(runtime)?.clear(); + void runtime.cdp.detach().catch(() => {}); } private attachRuntimeLifecycle(profileId: string, runtime: ProfileRuntime): void { runtime.context.on('close', () => this.invalidateProfileRuntime(profileId, runtime)); + runtime.context.on('page', page => { + void this.handleContextPage(runtime, page).catch(() => {}); + }); } - private async createPageWithRecovery( - profileId: string, - windowMode: BrowserWindowMode | undefined, - createPage: (runtime: ProfileRuntime) => PlaywrightPage | Promise, - commitPage: (runtime: ProfileRuntime, page: PlaywrightPage) => T, - ): Promise { - return this.withPageCreationLock(profileId, () => this.createPageWithRecoveryAttempt( - profileId, - windowMode, - createPage, - commitPage, - )); + private async withPageCreationLock(profileId: string, operation: () => Promise): Promise { + const previous = this.pageCreationQueues.get(profileId) ?? Promise.resolve(); + let release!: () => void; + const released = new Promise((resolve) => { + release = resolve; + }); + const queue = previous.then(() => released); + this.pageCreationQueues.set(profileId, queue); + await previous; + try { + return await operation(); + } finally { + release(); + if (this.pageCreationQueues.get(profileId) === queue) this.pageCreationQueues.delete(profileId); + } + } + + private getSessionRuntime(runtime: ProfileRuntime, sessionId: string): SessionRuntime { + let session = runtime.sessions.get(sessionId); + if (!session) { + session = { id: sessionId, windowIds: new Set(), pages: new Map() }; + runtime.sessions.set(sessionId, session); + } + return session; + } + + private async createSessionPage( + runtime: ProfileRuntime, + session: SessionRuntime, + windowMode?: BrowserWindowMode, + ): Promise { + const opener = this.openEntries(session)[0]?.[1].page; + if (!opener) return this.createWindowPage(runtime, windowMode); + + const popup = opener.waitForEvent('popup', { timeout: 1_000 }).catch(() => null); + try { + await opener.evaluate(() => window.open('about:blank', '_blank', 'noopener')); + } catch {} + const page = await popup; + if (page) return page; + return this.createWindowPage(runtime, windowMode); } - private async createPageWithRecoveryAttempt( + private async acquireSessionPage( profileId: string, + sessionId: string, windowMode: BrowserWindowMode | undefined, - createPage: (runtime: ProfileRuntime) => PlaywrightPage | Promise, - commitPage: (runtime: ProfileRuntime, page: PlaywrightPage) => T, attempt = 0, - ): Promise { + ): Promise<{ runtime: ProfileRuntime; session: SessionRuntime; page: PlaywrightPage }> { const runtime = await this.getProfileRuntime(profileId, windowMode); + const session = this.getSessionRuntime(runtime, sessionId); let page: PlaywrightPage; try { - page = await createPage(runtime); + page = await this.createSessionPage(runtime, session, windowMode); } catch (error) { if (attempt !== 0 || !isClosedContextError(error)) throw error; this.invalidateProfileRuntime(profileId, runtime); - return this.createPageWithRecoveryAttempt(profileId, windowMode, createPage, commitPage, 1); + return this.acquireSessionPage(profileId, sessionId, windowMode, 1); } if (this.profiles.get(profileId) !== runtime) { if (!pageIsClosed(page)) await page.close().catch(() => {}); throw new Error('Target page, context or browser has been closed'); } - return commitPage(runtime, page); + return { runtime, session, page }; } - private async withPageCreationLock(profileId: string, operation: () => Promise): Promise { - const previous = this.pageCreationQueues.get(profileId) ?? Promise.resolve(); - let release!: () => void; - const released = new Promise((resolve) => { - release = resolve; + private async createWindowPage(runtime: ProfileRuntime, windowMode?: BrowserWindowMode): Promise { + const result = await runtime.cdp.send('Target.createTarget', { + url: 'about:blank', + newWindow: true, + background: windowMode === 'background', + focus: windowMode !== 'background', + }) as { targetId: string }; + return this.waitForTargetPage(runtime, result.targetId); + } + + private async waitForTargetPage(runtime: ProfileRuntime, targetId: string): Promise { + const pending = this.pendingTargetPages.get(runtime)!; + const page = pending.get(targetId); + if (page) { + pending.delete(targetId); + pending.clear(); + return page; + } + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.targetPageWaiters.get(runtime)?.delete(targetId); + reject(new Error(`Timed out waiting for Cloak target ${targetId}`)); + }, TARGET_PAGE_MATCH_TIMEOUT_MS); + this.targetPageWaiters.get(runtime)!.set(targetId, { resolve, reject, timer }); }); - const queue = previous.then(() => released); - this.pageCreationQueues.set(profileId, queue); - await previous; - try { - return await operation(); - } finally { - release(); - if (this.pageCreationQueues.get(profileId) === queue) this.pageCreationQueues.delete(profileId); + } + + private async handleContextPage(runtime: ProfileRuntime, page: PlaywrightPage): Promise { + const targetId = await this.targetIdForPage(runtime, page); + const waiter = this.targetPageWaiters.get(runtime)?.get(targetId); + if (waiter) { + this.targetPageWaiters.get(runtime)!.delete(targetId); + this.pendingTargetPages.get(runtime)?.clear(); + clearTimeout(waiter.timer); + waiter.resolve(page); + } else { + this.pendingTargetPages.get(runtime)?.set(targetId, page); } + + const opener = await page.opener().catch(() => null); + const openerEntry = opener && [...runtime.targetPages.values()].find(entry => entry.page === opener); + if (!openerEntry?.sessionId) return; + const session = runtime.sessions.get(openerEntry.sessionId); + if (!session) return; + this.pendingTargetPages.get(runtime)?.delete(targetId); + await this.registerOwnedPage(runtime, session, page, { + session: openerEntry.session, + surface: openerEntry.surface, + siteSession: openerEntry.siteSession, + idleTimeout: openerEntry.idleTimeout, + }); } - private async createPage(context: BrowserContext, windowMode?: BrowserWindowMode): Promise { - const browser = context.browser(); - if (!browser) throw new Error('Cloak page creation requires a Chromium browser connection.'); - const cdp = await browser.newBrowserCDPSession(); + private async registerOwnedPage( + runtime: ProfileRuntime, + session: SessionRuntime, + page: PlaywrightPage, + input: Pick & { leaseKey?: string }, + ): Promise { + const targetId = await this.targetIdForPage(runtime, page); + this.pendingTargetPages.get(runtime)?.delete(targetId); + const windowId = await this.windowIdForTarget(runtime, targetId); + const owner = runtime.windowOwners.get(windowId); + if (owner !== undefined && owner !== session.id) { + throw new SessionWindowConflictError(runtime.targetPages.get(targetId)?.pageId ?? 'unknown', session.id, owner); + } + runtime.windowOwners.set(windowId, session.id); + session.windowIds.add(windowId); + + let entry = runtime.targetPages.get(targetId); + const wasOwned = Boolean(entry?.sessionId); + if (entry?.sessionId && entry.sessionId !== session.id) { + throw new SessionWindowConflictError(entry.pageId, session.id, entry.sessionId); + } + if (!entry) { + const pageId = nextPageId(); + entry = { + page, + pageId, + targetId, + leaseKey: input.leaseKey ?? `page\u0000${pageId}`, + sessionId: session.id, + session: input.session, + surface: input.surface, + siteSession: input.siteSession, + idleTimeout: input.idleTimeout, + }; + runtime.targetPages.set(targetId, entry); + this.attachPageLifecycle(runtime, entry); + } else { + if (entry.sessionId) { + const session = runtime.sessions.get(entry.sessionId); + if (session?.pages.get(entry.leaseKey) === entry) session.pages.delete(entry.leaseKey); + } + entry.sessionId = session.id; + entry.session = input.session; + entry.surface = input.surface; + entry.siteSession = input.siteSession; + entry.idleTimeout = input.idleTimeout; + entry.leaseKey = input.leaseKey ?? (entry.leaseKey.startsWith('unowned\u0000') ? `page\u0000${entry.pageId}` : entry.leaseKey); + } + session.pages.set(entry.leaseKey, entry); + this.refreshIdleTimer(runtime, session, entry.leaseKey, entry); + if (!wasOwned) for (const listener of this.sessionPageListeners.get(session) ?? []) listener(page); + return entry; + } + + private attachPageLifecycle(runtime: ProfileRuntime, entry: PageEntry): void { + entry.page.once('close', () => { + runtime.targetPages.delete(entry.targetId); + if (entry.sessionId) { + const session = runtime.sessions.get(entry.sessionId); + if (session?.pages.get(entry.leaseKey) === entry) session.pages.delete(entry.leaseKey); + } + this.clearIdleTimer(entry); + }); + } + + private async targetIdForPage(runtime: ProfileRuntime, page: PlaywrightPage): Promise { + const cached = this.pageTargetIds.get(page); + if (cached) return cached; + const pending = this.pageTargetIdPromises.get(page); + if (pending) return pending; + const correlation = (async () => { + const session = await runtime.context.newCDPSession(page); + const { targetInfo } = await session.send('Target.getTargetInfo') as { targetInfo: { targetId: string } }; + this.pageTargetIds.set(page, targetInfo.targetId); + this.pageCdpSessions.set(page, session); + page.once('close', () => { + this.pageTargetIds.delete(page); + this.pageCdpSessions.delete(page); + void session.detach().catch(() => {}); + }); + return targetInfo.targetId; + })(); + this.pageTargetIdPromises.set(page, correlation); try { - const [page] = await Promise.all([ - context.waitForEvent('page'), - cdp.send('Target.createTarget', { - url: 'about:blank', - newWindow: true, - background: windowMode === 'background', - focus: windowMode !== 'background', - }), - ]); - return page; + return await correlation; } finally { - await cdp.detach().catch(() => {}); + this.pageTargetIdPromises.delete(page); } } - private openEntries(runtime: ProfileRuntime): [string, PageEntry][] { + private async windowIdForTarget(runtime: ProfileRuntime, targetId: string): Promise { + const { windowId } = await runtime.cdp.send('Browser.getWindowForTarget', { targetId }) as { windowId: number }; + return windowId; + } + + private async assertOwnedWindow(runtime: ProfileRuntime, sessionId: string, entry: PageEntry): Promise { + const actual = await this.windowIdForTarget(runtime, entry.targetId); + const owner = runtime.windowOwners.get(actual); + if (owner !== undefined && owner !== sessionId) { + throw new SessionWindowConflictError(entry.pageId, sessionId, owner); + } + if (!runtime.sessions.get(sessionId)?.windowIds.has(actual)) { + throw new SessionWindowConflictError(entry.pageId, sessionId, owner); + } + } + + private async assertBindableWindow(runtime: ProfileRuntime, session: SessionRuntime, entry: PageEntry): Promise { + const actual = await this.windowIdForTarget(runtime, entry.targetId); + const owner = runtime.windowOwners.get(actual); + if (owner !== undefined && owner !== session.id) { + throw new SessionWindowConflictError(entry.pageId, session.id, owner); + } + runtime.windowOwners.set(actual, session.id); + session.windowIds.add(actual); + } + + private openEntries(runtime: SessionRuntime): [string, PageEntry][] { return [...runtime.pages.entries()].filter(([, entry]) => !pageIsClosed(entry.page)); } private findEntryByPageId(runtime: ProfileRuntime, pageId: string): [string, PageEntry] | null { - return this.openEntries(runtime).find(([, entry]) => entry.pageId === pageId) ?? null; + const entry = [...runtime.targetPages.values()].find(candidate => candidate.pageId === pageId && !pageIsClosed(candidate.page)); + return entry ? [entry.leaseKey, entry] : null; } - private sessionEntries(runtime: ProfileRuntime, input: Pick): [string, PageEntry][] { + private sessionEntries(runtime: SessionRuntime, input: Pick): [string, PageEntry][] { const session = requireSession(input.session); const surface = normalizeSurface(input.surface); return this.openEntries(runtime).filter(([, entry]) => entry.session === session && entry.surface === surface); } - private setSelectedPage(runtime: ProfileRuntime, entry: PageEntry): void { - runtime.selectedPageIds.set(selectionKey(entry), entry.pageId); + private selectEntry(runtime: SessionRuntime, entry: PageEntry): void { + runtime.selectedPageId = entry.pageId; } - private clearSelectedPage(runtime: ProfileRuntime, entry: PageEntry): void { - const key = selectionKey(entry); - if (runtime.selectedPageIds.get(key) === entry.pageId) runtime.selectedPageIds.delete(key); + private clearSelectedPage(runtime: SessionRuntime, entry: PageEntry): void { + if (runtime.selectedPageId === entry.pageId) runtime.selectedPageId = undefined; } - private refreshIdleTimer(runtime: ProfileRuntime, leaseKey: string, entry: PageEntry): void { + private refreshIdleTimer(runtime: ProfileRuntime, session: SessionRuntime, leaseKey: string, entry: PageEntry): void { this.clearIdleTimer(entry); if (!entry.idleTimeout || entry.idleTimeout <= 0 || entry.siteSession === 'persistent') return; entry.idleTimer = setTimeout(() => { - void this.expireLease(runtime, leaseKey, entry); + void this.expireLease(runtime, session, leaseKey, entry); }, entry.idleTimeout); entry.idleTimer.unref?.(); } - private async expireLease(runtime: ProfileRuntime, leaseKey: string, entry: PageEntry): Promise { - if (runtime.pages.get(leaseKey) !== entry) return; - runtime.pages.delete(leaseKey); - this.clearIdleTimer(entry); - this.clearSelectedPage(runtime, entry); + private async expireLease(runtime: ProfileRuntime, session: SessionRuntime, leaseKey: string, entry: PageEntry): Promise { + if (session.pages.get(leaseKey) !== entry) return; runtime.lastSeenAt = Date.now(); - if (entry.siteSession !== 'persistent' && !pageIsClosed(entry.page)) { - await entry.page.close().catch(() => {}); + if (entry.siteSession !== 'persistent') await this.removeEntry(runtime, session, entry, true); + } + + private async removeEntry(runtime: ProfileRuntime, session: SessionRuntime, entry: PageEntry, close: boolean): Promise { + if (session.pages.get(entry.leaseKey) === entry) session.pages.delete(entry.leaseKey); + runtime.targetPages.delete(entry.targetId); + this.clearIdleTimer(entry); + this.clearSelectedPage(session, entry); + this.networkCapture.stop(entry.page); + if (close && !pageIsClosed(entry.page)) { + await runtime.cdp.send('Target.closeTarget', { targetId: entry.targetId }).catch(() => {}); + if (!pageIsClosed(entry.page)) await entry.page.close().catch(() => {}); } } @@ -650,16 +904,16 @@ function normalizeSurface(surface: BrowserSurface | undefined): BrowserSurface { return surface === 'adapter' ? 'adapter' : 'browser'; } -function selectionKey(entry: Pick): string { - return `${entry.surface}\u0000${encodeURIComponent(entry.session)}`; -} - function requireSession(session: string | undefined): string { const normalized = session?.trim(); if (!normalized) throw new Error('Browser session is required.'); return normalized; } +function requireSessionId(input: Pick): string { + return input.sessionId?.trim() || requireSession(input.session); +} + function isProfileAlreadyInUseError(err: unknown): boolean { const message = err instanceof Error ? err.message : String(err); return message.includes('Opening in existing browser session') From a62dfdc7a2e63637c16707f3b5a970b97044f9cd Mon Sep 17 00:00:00 2001 From: beubax Date: Wed, 12 Aug 2026 11:34:52 +0530 Subject: [PATCH 11/27] fix: close local session isolation escapes --- src/browser/run/playwright-transport.ts | 92 ++++++++++++++++--- src/browser/run/runner.test.ts | 19 ++++ .../local-cloak/session-manager.test.ts | 33 +++++++ .../runtime/local-cloak/session-manager.ts | 23 ++++- 4 files changed, 150 insertions(+), 17 deletions(-) diff --git a/src/browser/run/playwright-transport.ts b/src/browser/run/playwright-transport.ts index cd811439..00549783 100644 --- a/src/browser/run/playwright-transport.ts +++ b/src/browser/run/playwright-transport.ts @@ -88,6 +88,37 @@ function scopedContext( }, ): object { const pageListeners = new Map void>>(); + const contextListeners = new Map void>>>(); + const related = (value: unknown, property: string): unknown => { + if ((typeof value !== 'object' || value === null) && typeof value !== 'function') return undefined; + try { + const candidate = Reflect.get(value as object, property, value); + return typeof candidate === 'function' ? Reflect.apply(candidate, value, []) : candidate; + } catch { + return undefined; + } + }; + const requestPage = (request: unknown): unknown => { + const frame = related(request, 'frame') ?? related(request, '_frame'); + return related(frame, 'page') ?? related(frame, '_page'); + }; + const eventBelongsToScope = (event: string, args: unknown[]): boolean => { + let eventPage: unknown; + if (event === 'request' || event === 'requestfailed') { + eventPage = requestPage(args[0]); + } else if (event === 'response') { + eventPage = requestPage(related(args[0], 'request')); + } else if (event === 'requestfinished') { + eventPage = requestPage(related(args[0], 'request')); + } else if (event === 'console') { + eventPage = related(args[0], 'page'); + } else if (event === 'pageerror') { + eventPage = args[1]; + } else if (event === 'recorderevent') { + eventPage = related(args[0], 'page'); + } + return eventPage !== undefined && scope.pages().includes(eventPage as object); + }; const addPageListener = (listener: Function, once = false) => { let dispose: () => void = () => undefined; const registered = (page: object) => { @@ -109,6 +140,38 @@ function scopedContext( const removeAllPageListeners = () => { for (const listener of pageListeners.keys()) removePageListener(listener); }; + const addContextListener = (event: string, listener: Function, once = false) => { + let dispose: () => void = () => undefined; + const registered = (...args: unknown[]) => { + if (event !== 'close' && !eventBelongsToScope(event, args)) return; + if (once) { + dispose(); + contextListeners.get(event)?.get(listener)?.delete(dispose); + } + listener(...args); + }; + Reflect.apply(Reflect.get(context, 'on'), context, [event, registered]); + dispose = () => Reflect.apply(Reflect.get(context, 'off'), context, [event, registered]); + const byListener = contextListeners.get(event) ?? new Map(); + const disposers = byListener.get(listener) ?? new Set(); + disposers.add(dispose); + byListener.set(listener, disposers); + contextListeners.set(event, byListener); + }; + const removeContextListener = (event: string, listener: Function) => { + const byListener = contextListeners.get(event); + for (const dispose of byListener?.get(listener) ?? []) dispose(); + byListener?.delete(listener); + if (byListener?.size === 0) contextListeners.delete(event); + }; + const removeAllContextListeners = (event?: string) => { + const events = event === undefined ? [...contextListeners.keys()] : [event]; + for (const name of events) { + for (const listener of contextListeners.get(name)?.keys() ?? []) { + removeContextListener(name, listener); + } + } + }; let proxy: object; proxy = new Proxy(context, { get(target, property) { @@ -121,7 +184,7 @@ function scopedContext( addPageListener(listener); return proxy; } - Reflect.apply(Reflect.get(target, property), target, [event, listener]); + addContextListener(event, listener); return proxy; }; } @@ -131,7 +194,7 @@ function scopedContext( removePageListener(listener); return proxy; } - Reflect.apply(Reflect.get(target, property), target, [event, listener]); + removeContextListener(event, listener); return proxy; }; } @@ -141,21 +204,19 @@ function scopedContext( addPageListener(listener, true); return proxy; } - Reflect.apply(Reflect.get(target, property), target, [event, listener]); + addContextListener(event, listener, true); return proxy; }; } if (property === 'removeAllListeners') { return (event?: string) => { if (event === undefined || event === 'page') removeAllPageListeners(); + if (event !== 'page') removeAllContextListeners(event); return proxy; }; } if (property === 'waitForEvent') { return (event: string, optionsOrPredicate?: object | Function) => { - if (event !== 'page') { - return Reflect.apply(Reflect.get(target, property), target, [event, optionsOrPredicate]); - } const options = typeof optionsOrPredicate === 'object' ? optionsOrPredicate as { predicate?: (page: object) => boolean | Promise; timeout?: number; @@ -163,22 +224,27 @@ function scopedContext( const predicate = typeof optionsOrPredicate === 'function' ? optionsOrPredicate : options?.predicate; return new Promise((resolve, reject) => { let timer: ReturnType | undefined; - const listener = async (page: object) => { + const removeListener = (listener: Function) => { + if (event === 'page') removePageListener(listener); + else removeContextListener(event, listener); + }; + const listener = async (value: object) => { try { - if (predicate && !await predicate(page)) return; - removePageListener(listener); + if (predicate && !await predicate(value)) return; + removeListener(listener); if (timer) clearTimeout(timer); - resolve(page); + resolve(value); } catch (error) { - removePageListener(listener); + removeListener(listener); if (timer) clearTimeout(timer); reject(error); } }; - addPageListener(listener); + if (event === 'page') addPageListener(listener); + else addContextListener(event, listener); if (options?.timeout) { timer = setTimeout(() => { - removePageListener(listener); + removeListener(listener); reject(new Error(`Timeout while waiting for event "${event}"`)); }, options.timeout); } diff --git a/src/browser/run/runner.test.ts b/src/browser/run/runner.test.ts index 0d80cabb..e7458ea0 100644 --- a/src/browser/run/runner.test.ts +++ b/src/browser/run/runner.test.ts @@ -366,6 +366,25 @@ describe('runBrowserProgram', () => { expect(seen).toEqual([owned]); }); + it('filters context request events from sibling Session pages', async () => { + const sibling = await context.newPage(); + await sibling.goto('data:text/html,sibling'); + const output = runBrowserProgram({ + ...sessionScope(() => [page]), + pageId: 'page-1', + }, ` + const request = context.waitForEvent('request'); + await page.evaluate(() => document.body.dataset.contextListener = 'ready'); + return (await request).frame().page().url(); + `, { timeoutMs: 2_000 }); + + await page.waitForFunction(() => document.body.dataset.contextListener === 'ready'); + await sibling.evaluate(() => fetch('https://example.test/data').catch(() => undefined)); + await page.evaluate(() => fetch('https://example.test/data').catch(() => undefined)); + + await expect(output).resolves.toMatchObject({ result: page.url() }); + }); + it('delegates context.newPage to the Session-owned page creator', async () => { const createPage = vi.fn(() => context.newPage()); const output = await runBrowserProgram({ diff --git a/src/browser/runtime/local-cloak/session-manager.test.ts b/src/browser/runtime/local-cloak/session-manager.test.ts index acd6566b..76655d9a 100644 --- a/src/browser/runtime/local-cloak/session-manager.test.ts +++ b/src/browser/runtime/local-cloak/session-manager.test.ts @@ -275,6 +275,39 @@ describe('CloakSessionManager', () => { expect(await manager.findPageById(second.pageId, a)).toBeNull(); }); + it('does not let another Session bind an owned page moved to an unowned window', async () => { + const launched = fakeContext(); + const manager = new CloakSessionManager({ + baseDir: '/tmp/webcmd-test', + launchPersistentContext: vi.fn().mockResolvedValue(launched.context), + }); + const a = { profileId: 'default', session: 'session_a', sessionId: 'session_a', surface: 'browser' as const }; + const b = { profileId: 'default', session: 'session_b', sessionId: 'session_b', surface: 'browser' as const }; + const first = await manager.getPage(a); + launched.moveToWindow(first.page, 999); + + await expect(manager.bindPage({ ...b, pageId: first.pageId })) + .rejects.toMatchObject({ code: 'SESSION_WINDOW_CONFLICT' }); + expect(first.page.close).not.toHaveBeenCalled(); + expect(await manager.listPages(b)).toEqual([]); + await expect(manager.listPages(a)).rejects.toMatchObject({ code: 'SESSION_WINDOW_CONFLICT' }); + }); + + it('checks opener window ownership before calling window.open', async () => { + const launched = fakeContext(); + const manager = new CloakSessionManager({ + baseDir: '/tmp/webcmd-test', + launchPersistentContext: vi.fn().mockResolvedValue(launched.context), + }); + const key = { profileId: 'default', session: 'session_a', sessionId: 'session_a', surface: 'browser' as const }; + const first = await manager.getPage(key); + launched.moveToWindow(first.page, 999); + + await expect(manager.newPage(key)).rejects.toMatchObject({ code: 'SESSION_WINDOW_CONFLICT' }); + expect(first.page.evaluate).not.toHaveBeenCalled(); + expect(first.page.close).not.toHaveBeenCalled(); + }); + it('binds an unowned context page without adopting another Session page', async () => { const launched = fakeContext(); const manager = new CloakSessionManager({ diff --git a/src/browser/runtime/local-cloak/session-manager.ts b/src/browser/runtime/local-cloak/session-manager.ts index 0add5793..00f262d6 100644 --- a/src/browser/runtime/local-cloak/session-manager.ts +++ b/src/browser/runtime/local-cloak/session-manager.ts @@ -405,8 +405,10 @@ export class CloakSessionManager { const surface = normalizeSurface(input.surface); const runtime = this.profiles.get(profileId); if (!runtime) return null; - const sessionRuntime = this.getSessionRuntime(runtime, sessionId); - let match = input.pageId ? this.findEntryByPageId(runtime, input.pageId) : this.openEntries(sessionRuntime)[input.index ?? -1]; + const existingSession = runtime.sessions.get(sessionId); + let match = input.pageId + ? this.findEntryByPageId(runtime, input.pageId) + : existingSession && this.openEntries(existingSession)[input.index ?? -1]; if (!match && input.index !== undefined) { const page = runtime.context.pages().filter(candidate => !pageIsClosed(candidate))[input.index]; if (page) { @@ -429,6 +431,10 @@ export class CloakSessionManager { if (!match) return null; const entry = match[1]; + if (entry.sessionId && entry.sessionId !== sessionId) { + throw new SessionWindowConflictError(entry.pageId, sessionId, entry.sessionId); + } + const sessionRuntime = existingSession ?? this.getSessionRuntime(runtime, sessionId); await this.assertBindableWindow(runtime, sessionRuntime, entry); const sourceSession = entry.sessionId ? runtime.sessions.get(entry.sessionId) : undefined; const sourceKey = entry.leaseKey; @@ -636,8 +642,10 @@ export class CloakSessionManager { session: SessionRuntime, windowMode?: BrowserWindowMode, ): Promise { - const opener = this.openEntries(session)[0]?.[1].page; - if (!opener) return this.createWindowPage(runtime, windowMode); + const openerEntry = this.openEntries(session)[0]?.[1]; + if (!openerEntry) return this.createWindowPage(runtime, windowMode); + await this.assertOwnedWindow(runtime, session.id, openerEntry); + const opener = openerEntry.page; const popup = opener.waitForEvent('popup', { timeout: 1_000 }).catch(() => null); try { @@ -831,6 +839,13 @@ export class CloakSessionManager { } private async assertBindableWindow(runtime: ProfileRuntime, session: SessionRuntime, entry: PageEntry): Promise { + if (entry.sessionId) { + if (entry.sessionId !== session.id) { + throw new SessionWindowConflictError(entry.pageId, session.id, entry.sessionId); + } + await this.assertOwnedWindow(runtime, session.id, entry); + return; + } const actual = await this.windowIdForTarget(runtime, entry.targetId); const owner = runtime.windowOwners.get(actual); if (owner !== undefined && owner !== session.id) { From 3236a09a8cffe2e65edf1ad9ad76024b36ca550e Mon Sep 17 00:00:00 2001 From: beubax Date: Wed, 12 Aug 2026 11:41:16 +0530 Subject: [PATCH 12/27] fix: scope browser run dialogs to session --- src/browser/run/playwright-transport.ts | 28 +++++++++++++++++++++++++ src/browser/run/runner.test.ts | 22 +++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/src/browser/run/playwright-transport.ts b/src/browser/run/playwright-transport.ts index 00549783..ecb322c9 100644 --- a/src/browser/run/playwright-transport.ts +++ b/src/browser/run/playwright-transport.ts @@ -172,11 +172,39 @@ function scopedContext( } } }; + const dialogManager = Reflect.get(context, 'dialogManager', context) as object; + const dialogHandlers = new Map(); + const scopedDialogManager = new Proxy(dialogManager, { + get(target, property) { + if (property === 'addDialogHandler') { + return (handler: Function) => { + const registered = (dialog: object) => { + const page = related(dialog, 'page'); + return page !== undefined && scope.pages().includes(page as object) + ? handler(dialog) + : false; + }; + dialogHandlers.set(handler, registered); + Reflect.apply(Reflect.get(target, property), target, [registered]); + }; + } + if (property === 'removeDialogHandler') { + return (handler: Function) => { + const registered = dialogHandlers.get(handler) ?? handler; + dialogHandlers.delete(handler); + Reflect.apply(Reflect.get(target, property), target, [registered]); + }; + } + const value = Reflect.get(target, property, target); + return typeof value === 'function' ? value.bind(target) : value; + }, + }); let proxy: object; proxy = new Proxy(context, { get(target, property) { if (property === 'pages') return scope.pages; if (property === 'newPage') return scope.createPage; + if (property === 'dialogManager') return scopedDialogManager; if (property === 'backgroundPages' || property === 'serviceWorkers') return () => []; if (property === 'on' || property === 'addListener') { return (event: string, listener: Function) => { diff --git a/src/browser/run/runner.test.ts b/src/browser/run/runner.test.ts index e7458ea0..656a8d6b 100644 --- a/src/browser/run/runner.test.ts +++ b/src/browser/run/runner.test.ts @@ -385,6 +385,28 @@ describe('runBrowserProgram', () => { await expect(output).resolves.toMatchObject({ result: page.url() }); }); + it('filters context dialog events from sibling Session pages', async () => { + const sibling = await context.newPage(); + await sibling.goto('data:text/html,sibling'); + const output = runBrowserProgram({ + ...sessionScope(() => [page]), + pageId: 'page-1', + }, ` + const pendingDialog = context.waitForEvent('dialog'); + await page.evaluate(() => document.body.dataset.dialogListener = 'ready'); + const dialog = await pendingDialog; + const url = dialog.page().url(); + await dialog.dismiss(); + return url; + `, { timeoutMs: 2_000 }); + + await page.waitForFunction(() => document.body.dataset.dialogListener === 'ready'); + await sibling.evaluate(() => alert('sibling')); + await page.evaluate(() => alert('owned')); + + await expect(output).resolves.toMatchObject({ result: page.url() }); + }); + it('delegates context.newPage to the Session-owned page creator', async () => { const createPage = vi.fn(() => context.newPage()); const output = await runBrowserProgram({ From 06dafa434a70bad6a40c70aa9f030f1c5b438e15 Mon Sep 17 00:00:00 2001 From: beubax Date: Wed, 12 Aug 2026 11:55:53 +0530 Subject: [PATCH 13/27] fix: keep cloak profiles alive between sessions --- .../local-cloak/darwin-background-launch.ts | 9 +- .../local-cloak/process-matcher.test.ts | 26 ++ .../runtime/local-cloak/process-matcher.ts | 61 +++ .../runtime/local-cloak/provider.test.ts | 11 +- src/browser/runtime/local-cloak/provider.ts | 12 +- .../local-cloak/session-manager.test.ts | 190 ++++++++- .../runtime/local-cloak/session-manager.ts | 378 ++++++++++++++---- vitest.config.ts | 2 + 8 files changed, 586 insertions(+), 103 deletions(-) create mode 100644 src/browser/runtime/local-cloak/process-matcher.test.ts create mode 100644 src/browser/runtime/local-cloak/process-matcher.ts diff --git a/src/browser/runtime/local-cloak/darwin-background-launch.ts b/src/browser/runtime/local-cloak/darwin-background-launch.ts index 4cfa6621..42798eb4 100644 --- a/src/browser/runtime/local-cloak/darwin-background-launch.ts +++ b/src/browser/runtime/local-cloak/darwin-background-launch.ts @@ -7,6 +7,7 @@ import { buildLaunchOptions, humanizeBrowser } from 'cloakbrowser'; import type { LaunchPersistentContextOptions } from 'cloakbrowser'; import { chromium } from 'playwright-core'; import type { Browser, BrowserContext } from 'playwright-core'; +import { findExactCloakProfileProcesses } from './process-matcher.js'; const execFileAsync = promisify(execFile); @@ -40,13 +41,7 @@ export async function waitForDevToolsPort(portFile: string, timeoutMs = 10_000): } async function terminateProfile(userDataDir: string): Promise { - const { stdout } = await execFileAsync('/bin/ps', ['-axo', 'pid=,command=']); - const needle = `--user-data-dir=${userDataDir}`; - for (const line of stdout.split('\n')) { - if (!line.includes(needle)) continue; - const pid = Number.parseInt(line.trim().split(/\s+/, 1)[0], 10); - if (Number.isInteger(pid) && pid !== process.pid) process.kill(pid, 'SIGTERM'); - } + for (const pid of await findExactCloakProfileProcesses(userDataDir)) process.kill(pid, 'SIGTERM'); } const defaultDependencies: Dependencies = { diff --git a/src/browser/runtime/local-cloak/process-matcher.test.ts b/src/browser/runtime/local-cloak/process-matcher.test.ts new file mode 100644 index 00000000..2c13823e --- /dev/null +++ b/src/browser/runtime/local-cloak/process-matcher.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest'; +import { matchCloakProfileCommand } from './process-matcher.js'; + +describe('matchCloakProfileCommand', () => { + it('matches only exact Cloak user-data-dir arguments', () => { + const cloak = '/Users/me/.cloakbrowser/chromium --user-data-dir=/profiles/work'; + const cloakSeparate = '/Users/me/.cloakbrowser/chromium --user-data-dir /profiles/work'; + const cloakQuoted = '"/Users/me/.cloakbrowser/Cloak Chromium" "--user-data-dir=/profiles/work"'; + const cloakWork2 = '/Users/me/.cloakbrowser/chromium --user-data-dir=/profiles/work-2'; + const chromeWork = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome --user-data-dir=/profiles/work'; + + expect(matchCloakProfileCommand(cloak, '/profiles/work')).toBe(true); + expect(matchCloakProfileCommand(cloakSeparate, '/profiles/work')).toBe(true); + expect(matchCloakProfileCommand(cloakQuoted, '/profiles/work')).toBe(true); + expect(matchCloakProfileCommand(cloakWork2, '/profiles/work')).toBe(false); + expect(matchCloakProfileCommand(chromeWork, '/profiles/work')).toBe(false); + expect(matchCloakProfileCommand('node tool.js --user-data-dir=/profiles/work', '/profiles/work')).toBe(false); + expect(matchCloakProfileCommand('node /tmp/.cloakbrowser/tool.js --user-data-dir=/profiles/work', '/profiles/work')).toBe(false); + }); + + it('accepts quotes around a separate or equals-form profile value', () => { + const executable = '/Users/me/.cloakbrowser/chromium'; + expect(matchCloakProfileCommand(`${executable} --user-data-dir "/profiles/work space"`, '/profiles/work space')).toBe(true); + expect(matchCloakProfileCommand(`${executable} --user-data-dir='/profiles/work space'`, '/profiles/work space')).toBe(true); + }); +}); diff --git a/src/browser/runtime/local-cloak/process-matcher.ts b/src/browser/runtime/local-cloak/process-matcher.ts new file mode 100644 index 00000000..febc130d --- /dev/null +++ b/src/browser/runtime/local-cloak/process-matcher.ts @@ -0,0 +1,61 @@ +import fs from 'node:fs'; +import { execFile } from 'node:child_process'; + +export function matchCloakProfileCommand(command: string, userDataDir: string): boolean { + const args = splitCommand(command); + if (!args[0]?.includes('/.cloakbrowser/') && !args[0]?.includes('\\.cloakbrowser\\')) return false; + for (let index = 0; index < args.length; index += 1) { + if (args[index] === '--user-data-dir' && args[index + 1] === userDataDir) return true; + if (args[index] === `--user-data-dir=${userDataDir}`) return true; + } + return false; +} + +export async function findExactCloakProfileProcesses(userDataDir: string): Promise { + const aliases = new Set([userDataDir]); + try { + aliases.add(fs.realpathSync.native(userDataDir)); + } catch { + // The launch path is still useful when the directory does not exist yet. + } + const stdout = await psOutput(); + const pids = stdout.split('\n').flatMap((line) => { + const match = line.match(/^\s*(\d+)\s+(.+)$/); + if (!match) return []; + const pid = Number(match[1]); + if (!Number.isInteger(pid) || pid === process.pid) return []; + return [...aliases].some(dir => matchCloakProfileCommand(match[2], dir)) ? [pid] : []; + }); + return [...new Set(pids)]; +} + +function splitCommand(command: string): string[] { + const args: string[] = []; + let current = ''; + let quote = ''; + for (const char of command) { + if (quote) { + if (char === quote) quote = ''; + else current += char; + } else if (char === '"' || char === "'") { + quote = char; + } else if (/\s/u.test(char)) { + if (current) { + args.push(current); + current = ''; + } + } else { + current += char; + } + } + if (current) args.push(current); + return args; +} + +function psOutput(): Promise { + return new Promise((resolve) => { + execFile('ps', ['-axo', 'pid=,command='], { encoding: 'utf8', maxBuffer: 10 * 1024 * 1024, timeout: 2000 }, (err, stdout) => { + resolve(err ? '' : String(stdout)); + }); + }); +} diff --git a/src/browser/runtime/local-cloak/provider.test.ts b/src/browser/runtime/local-cloak/provider.test.ts index 52ba80d6..bad1b6e5 100644 --- a/src/browser/runtime/local-cloak/provider.test.ts +++ b/src/browser/runtime/local-cloak/provider.test.ts @@ -120,10 +120,15 @@ function makeProviderWithFakePage(initialViewport: { width: number; height: numb close: vi.fn().mockResolvedValue(undefined), }; let usedInitialPage = false; - cdpSession.send.mockImplementation(async (command: string, params?: { targetId?: string }) => { + cdpSession.send.mockImplementation(async (command: string, params?: { targetId?: string; hidden?: boolean }) => { if (command === 'Target.createTarget') { - const page = usedInitialPage ? await context.newPage() : pages[0]; - usedInitialPage = true; + const page = params?.hidden ? fakePage('about:blank') : usedInitialPage ? await context.newPage() : pages[0]; + if (params?.hidden) { + pages.push(page); + assignTarget(page); + } else { + usedInitialPage = true; + } queueMicrotask(() => emit('page', page)); return { targetId: targetIds.get(page) }; } diff --git a/src/browser/runtime/local-cloak/provider.ts b/src/browser/runtime/local-cloak/provider.ts index 511349d2..71388a22 100644 --- a/src/browser/runtime/local-cloak/provider.ts +++ b/src/browser/runtime/local-cloak/provider.ts @@ -19,8 +19,13 @@ export class LocalCloakRuntimeProvider implements BrowserRuntimeProvider { private readonly sessionQueues = new Map>(); constructor(private readonly opts: LocalCloakRuntimeProviderOptions = {}) { - this.manager = new CloakSessionManager(opts); this.sessions = new LocalBrowserSessionStore({ baseDir: opts.baseDir }); + this.manager = new CloakSessionManager({ + ...opts, + hasActiveHandoff: profileId => this.sessions.list(profileId).some(session => ( + Boolean(session.handoff) && Date.parse(session.handoff!.expiresAt) > Date.now() + )), + }); } async status(opts: RuntimeStatusOptions = {}): Promise { @@ -77,7 +82,10 @@ export class LocalCloakRuntimeProvider implements BrowserRuntimeProvider { await previous.catch(() => {}); try { - return await dispatchCloakAction(this.manager, command); + return await this.manager.runWithProfileActivity( + this.resolveProfileId(command), + () => dispatchCloakAction(this.manager, command), + ); } finally { release(); if (this.sessionQueues.get(key) === current) { diff --git a/src/browser/runtime/local-cloak/session-manager.test.ts b/src/browser/runtime/local-cloak/session-manager.test.ts index 76655d9a..69b9021f 100644 --- a/src/browser/runtime/local-cloak/session-manager.test.ts +++ b/src/browser/runtime/local-cloak/session-manager.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import path from 'node:path'; import type { BrowserContext, Page as PlaywrightPage } from 'playwright-core'; import { CloakSessionManager, resolveLeaseKey } from './session-manager.js'; +import { log } from '../../../logger.js'; import { dispatchCloakAction } from './actions.js'; function fakeContext() { @@ -74,9 +75,10 @@ function fakeContext() { for (const listener of listeners.get(event) ?? []) listener(...args); }; const cdp = { - send: vi.fn(async (command: string, params?: { targetId?: string }) => { + send: vi.fn(async (command: string, params?: { targetId?: string; hidden?: boolean }) => { if (command === 'Target.createTarget') { - const backgroundPage = await context.newPage(); + const backgroundPage = params?.hidden ? fakePage() : await context.newPage(); + if (params?.hidden) allPages.push(backgroundPage); backgroundPages.push(backgroundPage); queueMicrotask(() => emit('page', backgroundPage)); return { targetId: targetIds.get(backgroundPage) }; @@ -85,6 +87,7 @@ function fakeContext() { if (command === 'Target.closeTarget') return { success: true }; return {}; }), + on: vi.fn(), detach: vi.fn().mockResolvedValue(undefined), }; return { @@ -105,8 +108,9 @@ function fakeContext() { return created; }), newCDPSession: vi.fn(async (target: object) => ({ - send: vi.fn(async (command: string) => { + send: vi.fn(async (command: string, params?: { targetId?: string }) => { if (command === 'Target.getTargetInfo') return { targetInfo: { targetId: targetIds.get(target) } }; + if (command === 'Browser.getWindowForTarget') return { windowId: windowIds.get(params?.targetId ?? '') }; return {}; }), detach: vi.fn().mockResolvedValue(undefined), @@ -133,6 +137,7 @@ function expectedProfileDir(profileId: string): string { describe('CloakSessionManager', () => { afterEach(() => { vi.useRealTimers(); + vi.restoreAllMocks(); }); it('launches one persistent context per profile and reuses named sessions', async () => { @@ -161,7 +166,7 @@ describe('CloakSessionManager', () => { const first = await manager.getPage({ profileId: 'default', session: 'session_a', sessionId: 'session_a', surface: 'browser' }); const second = await manager.getPage({ profileId: 'default', session: 'session_b', sessionId: 'session_b', surface: 'browser' }); - expect(launched.cdp.send.mock.calls.filter(([method]) => method === 'Target.createTarget')) + expect(launched.cdp.send.mock.calls.filter(([method, params]) => method === 'Target.createTarget' && !(params as { hidden?: boolean })?.hidden)) .toHaveLength(2); expect(launched.windowIdFor(first.page)).not.toBe(launched.windowIdFor(second.page)); expect((await manager.listPages({ profileId: 'default', session: 'session_a', sessionId: 'session_a' })) @@ -201,14 +206,14 @@ describe('CloakSessionManager', () => { const evaluate = vi.mocked(first.page.evaluate); expect(String(evaluate.mock.calls[0][0])).toContain('noopener'); expect(launched.context.newCDPSession.mock.calls.length).toBeGreaterThanOrEqual(2); - expect(launched.cdp.send.mock.calls.filter(([method]) => method === 'Target.createTarget')).toHaveLength(1); + expect(launched.cdp.send.mock.calls.filter(([method, params]) => method === 'Target.createTarget' && !(params as { hidden?: boolean })?.hidden)).toHaveLength(1); evaluate.mockRejectedValueOnce(new Error('Execution context was destroyed')); const afterThrow = await manager.newPage(key); evaluate.mockResolvedValueOnce(null); const afterNull = await manager.newPage(key); - expect(launched.cdp.send.mock.calls.filter(([method]) => method === 'Target.createTarget')).toHaveLength(3); + expect(launched.cdp.send.mock.calls.filter(([method, params]) => method === 'Target.createTarget' && !(params as { hidden?: boolean })?.hidden)).toHaveLength(3); expect(launched.windowIdFor(afterThrow.page)).not.toBe(launched.windowIdFor(first.page)); expect(launched.windowIdFor(afterNull.page)).not.toBe(launched.windowIdFor(first.page)); expect((await manager.listPages(key)).every(tab => tab.session === 'session_a')).toBe(true); @@ -218,7 +223,11 @@ describe('CloakSessionManager', () => { vi.useFakeTimers(); const launched = fakeContext(); const send = launched.cdp.send.getMockImplementation()!; - launched.cdp.send.mockImplementationOnce(async () => ({ targetId: 'missing-target' })); + launched.cdp.send.mockImplementation(async (method: string, params?: { hidden?: boolean }) => ( + method === 'Target.createTarget' && !params?.hidden + ? { targetId: 'missing-target' } + : send(method, params) + )); const manager = new CloakSessionManager({ baseDir: '/tmp/webcmd-test', launchPersistentContext: vi.fn().mockResolvedValue(launched.context), @@ -1027,4 +1036,171 @@ describe('CloakSessionManager', () => { }); expect(launchPersistentContext).toHaveBeenCalledTimes(2); }); + + it('does not publish a runtime until its hidden keeper exists', async () => { + const launched = fakeContext(); + const send = launched.cdp.send.getMockImplementation()!; + let resolveAnchor!: () => void; + launched.cdp.send.mockImplementationOnce(() => new Promise((resolve) => { + resolveAnchor = () => resolve({ targetId: 'anchor-target' }); + })); + const manager = new CloakSessionManager({ + baseDir: '/tmp/webcmd-test', + platform: 'darwin', + launchPersistentContext: vi.fn().mockResolvedValue(launched.context), + }); + + const pending = manager.getPage({ profileId: 'work', session: 'session_a', surface: 'browser' }); + await vi.waitFor(() => expect(launched.cdp.send).toHaveBeenCalledWith('Target.createTarget', { + url: 'about:blank', + hidden: true, + background: true, + })); + expect(manager.activeProfileIds()).toEqual([]); + + resolveAnchor(); + launched.cdp.send.mockImplementation(send); + await pending; + expect(manager.activeProfileIds()).toEqual(['work']); + }); + + it('keeps an empty profile warm for sixty seconds before closing it', async () => { + vi.useFakeTimers(); + const launched = fakeContext(); + const manager = new CloakSessionManager({ + baseDir: '/tmp/webcmd-test', + platform: 'linux', + launchPersistentContext: vi.fn().mockResolvedValue(launched.context), + }); + await manager.getPage({ profileId: 'work', session: 'session_a', surface: 'browser' }); + await manager.closeSession('work', 'session_a'); + + await vi.advanceTimersByTimeAsync(59_999); + expect(manager.activeProfileIds()).toEqual(['work']); + expect(launched.context.close).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(1); + expect(manager.activeProfileIds()).toEqual([]); + expect(launched.context.close).toHaveBeenCalledOnce(); + }); + + it('fences a launch that finishes after shutdown starts', async () => { + const launched = fakeContext(); + let resolveLaunch!: (context: BrowserContext) => void; + const launchPersistentContext = vi.fn(() => new Promise((resolve) => { + resolveLaunch = resolve; + })); + const manager = new CloakSessionManager({ baseDir: '/tmp/webcmd-test', launchPersistentContext }); + + const pending = manager.getPage({ profileId: 'work', session: 'session_a', surface: 'browser' }); + await vi.waitFor(() => expect(launchPersistentContext).toHaveBeenCalledOnce()); + const shutdown = manager.shutdown(); + resolveLaunch(launched.context as unknown as BrowserContext); + + await shutdown; + await expect(pending).rejects.toMatchObject({ code: 'DAEMON_SHUTTING_DOWN' }); + expect(launched.context.close).toHaveBeenCalledOnce(); + expect(manager.activeProfileIds()).toEqual([]); + await expect(manager.getPage({ profileId: 'work', session: 'session_b', surface: 'browser' })) + .rejects.toMatchObject({ code: 'DAEMON_SHUTTING_DOWN' }); + expect(launchPersistentContext).toHaveBeenCalledOnce(); + }); + + it('falls back to a parking keeper when macOS rejects the hidden target', async () => { + const launched = fakeContext(); + const send = launched.cdp.send.getMockImplementation()!; + launched.cdp.send.mockImplementation((method: string, params?: { hidden?: boolean }) => ( + method === 'Target.createTarget' && params?.hidden + ? Promise.reject(new Error('hidden targets unsupported')) + : send(method, params) + )); + const warn = vi.spyOn(log, 'warn').mockImplementation(() => {}); + const manager = new CloakSessionManager({ + baseDir: '/tmp/webcmd-test', + platform: 'darwin', + launchPersistentContext: vi.fn().mockResolvedValue(launched.context), + }); + + const lease = await manager.getPage({ profileId: 'work', session: 'session_a', surface: 'browser' }); + await manager.closeSession('work', 'session_a'); + + expect(manager.activeProfileIds()).toEqual(['work']); + expect(lease.page.goto).toHaveBeenLastCalledWith('about:blank', { waitUntil: 'load' }); + expect(lease.page.close).not.toHaveBeenCalled(); + expect(warn).toHaveBeenCalledOnce(); + expect(launched.cdp.detach).not.toHaveBeenCalled(); + await manager.shutdown(); + expect(launched.cdp.detach).toHaveBeenCalledOnce(); + }); + + it('uses a parking keeper when the persistent context exposes no browser', async () => { + const launched = fakeContext(); + launched.context.browser.mockReturnValue(null); + const warn = vi.spyOn(log, 'warn').mockImplementation(() => {}); + const manager = new CloakSessionManager({ + baseDir: '/tmp/webcmd-test', + platform: 'darwin', + launchPersistentContext: vi.fn().mockResolvedValue(launched.context), + }); + + const lease = await manager.getPage({ profileId: 'work', session: 'session_a', surface: 'browser' }); + expect(lease.context).toBe(launched.context); + expect(manager.activeProfileIds()).toEqual(['work']); + expect(warn).toHaveBeenCalledOnce(); + }); + + it('reuses a warm profile and replaces its parking page on the next Session', async () => { + vi.useFakeTimers(); + const launched = fakeContext(); + const launchPersistentContext = vi.fn().mockResolvedValue(launched.context); + const manager = new CloakSessionManager({ + baseDir: '/tmp/webcmd-test', + platform: 'linux', + launchPersistentContext, + }); + const first = await manager.getPage({ profileId: 'work', session: 'session_a', surface: 'browser' }); + await manager.closeSession('work', 'session_a'); + await vi.advanceTimersByTimeAsync(59_999); + + const second = await manager.runWithProfileActivity('work', () => ( + manager.getPage({ profileId: 'work', session: 'session_b', surface: 'browser' }) + )); + + expect(second.context).toBe(first.context); + expect(second.page).not.toBe(first.page); + expect(first.page.close).toHaveBeenCalledOnce(); + expect(await manager.listPages({ profileId: 'work', session: 'session_a' })).toEqual([]); + expect(await manager.listPages({ profileId: 'work', session: 'session_b' })).toHaveLength(1); + expect(launchPersistentContext).toHaveBeenCalledOnce(); + }); + + it('recovers one timed-out idle close before launching one replacement', async () => { + vi.useFakeTimers(); + const first = fakeContext(); + first.context.close.mockImplementation(() => new Promise(() => {})); + const replacement = fakeContext(); + const launchPersistentContext = vi.fn() + .mockResolvedValueOnce(first.context) + .mockResolvedValueOnce(replacement.context); + const recoverLockedProfile = vi.fn().mockResolvedValue(true); + const manager = new CloakSessionManager({ + baseDir: '/tmp/webcmd-test', + platform: 'linux', + launchPersistentContext, + recoverLockedProfile, + }); + await manager.getPage({ profileId: 'work', session: 'session_a', surface: 'browser' }); + await manager.closeSession('work', 'session_a'); + await vi.advanceTimersByTimeAsync(60_000); + + const one = manager.getPage({ profileId: 'work', session: 'session_b', surface: 'browser' }); + const two = manager.getPage({ profileId: 'work', session: 'session_c', surface: 'browser' }); + await vi.advanceTimersByTimeAsync(3_000); + const leases = await Promise.all([one, two]); + + expect(recoverLockedProfile).toHaveBeenCalledOnce(); + expect(leases[0].context).toBe(replacement.context); + expect(leases[1].context).toBe(replacement.context); + expect(launchPersistentContext).toHaveBeenCalledTimes(2); + }); }); diff --git a/src/browser/runtime/local-cloak/session-manager.ts b/src/browser/runtime/local-cloak/session-manager.ts index 00f262d6..ad954794 100644 --- a/src/browser/runtime/local-cloak/session-manager.ts +++ b/src/browser/runtime/local-cloak/session-manager.ts @@ -1,7 +1,6 @@ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { execFile } from 'node:child_process'; import type { Browser, BrowserContext, CDPSession, Page as PlaywrightPage } from 'playwright-core'; import { launchPersistentContext as cloakLaunchPersistentContext } from 'cloakbrowser'; import type { BrowserSurface, BrowserWindowMode, SiteSessionMode } from '../../protocol.js'; @@ -9,9 +8,13 @@ import { activateDarwinBackgroundContext, launchDarwinBackgroundPersistentContex import { normalizeProfileId, resolveCloakProfileDir } from './profiles.js'; import { CloakNetworkCapture } from './network.js'; import { findPackageRoot } from '../../../package-paths.js'; +import { findExactCloakProfileProcesses } from './process-matcher.js'; +import { log } from '../../../logger.js'; const UNRESOLVED = Symbol('unresolved'); const TARGET_PAGE_MATCH_TIMEOUT_MS = 1_000; +export const PROFILE_IDLE_TIMEOUT_MS = 60_000; +export const PROFILE_CLOSE_TIMEOUT_MS = 3_000; let cachedCloakBrowserVersion: string | undefined | typeof UNRESOLVED = UNRESOLVED; /** @@ -87,11 +90,21 @@ export interface CloakTabInfo { } interface ProfileRuntime { + profileId: string; context: BrowserContext; - cdp: CDPSession; + cdp?: CDPSession; sessions: Map; windowOwners: Map; targetPages: Map; + userDataDir: string; + anchorTargetId?: string; + parkingPage?: PlaywrightPage; + useParkingKeeper: boolean; + keeperWarningLogged: boolean; + activeCommands: number; + idleTimer?: ReturnType; + closing: boolean; + disposed: boolean; lastSeenAt: number; } @@ -126,6 +139,7 @@ export interface CloakSessionManagerOptions { activateBackgroundContext?: typeof activateDarwinBackgroundContext; recoverLockedProfile?: RecoverLockedProfile; platform?: NodeJS.Platform; + hasActiveHandoff?: (profileId: string) => boolean; } let pageCounter = 0; @@ -153,6 +167,14 @@ function isClosedContextError(error: unknown): boolean { return /Target page, context or browser has been closed/i.test(message); } +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function daemonShuttingDownError(): Error & { code: 'DAEMON_SHUTTING_DOWN' } { + return Object.assign(new Error('The browser daemon is shutting down.'), { code: 'DAEMON_SHUTTING_DOWN' as const }); +} + export class CloakSessionManager { readonly networkCapture = new CloakNetworkCapture(); @@ -161,8 +183,11 @@ export class CloakSessionManager { private readonly activateBackgroundContext: typeof activateDarwinBackgroundContext; private readonly platform: NodeJS.Platform; private readonly recoverLockedProfile: RecoverLockedProfile; + private readonly hasActiveHandoff: (profileId: string) => boolean; private readonly profiles = new Map(); private readonly profileLaunches = new Map>(); + private readonly profileLifecycleQueues = new Map>(); + private readonly profileActivities = new Map(); private readonly pageCreationQueues = new Map>(); private readonly pageTargetIds = new WeakMap(); private readonly pageTargetIdPromises = new WeakMap>(); @@ -174,6 +199,7 @@ export class CloakSessionManager { timer: ReturnType; }>>(); private readonly sessionPageListeners = new WeakMap void>>(); + private shuttingDown = false; constructor(private readonly opts: CloakSessionManagerOptions = {}) { this.launchPersistentContext = opts.launchPersistentContext ?? cloakLaunchPersistentContext; @@ -181,6 +207,7 @@ export class CloakSessionManager { this.activateBackgroundContext = opts.activateBackgroundContext ?? activateDarwinBackgroundContext; this.platform = opts.platform ?? process.platform; this.recoverLockedProfile = opts.recoverLockedProfile ?? recoverLockedCloakProfile; + this.hasActiveHandoff = opts.hasActiveHandoff ?? (() => false); } profileStatuses() { @@ -197,6 +224,34 @@ export class CloakSessionManager { return [...this.profiles.keys()]; } + async runWithProfileActivity(profileIdInput: string | undefined, task: () => Promise): Promise { + const profileId = normalizeProfileId(profileIdInput); + await this.withProfileLifecycleLock(profileId, async () => { + this.assertRunning(); + const count = (this.profileActivities.get(profileId) ?? 0) + 1; + this.profileActivities.set(profileId, count); + const runtime = this.profiles.get(profileId); + if (runtime) { + runtime.activeCommands = count; + this.cancelProfileIdle(runtime); + } + }); + try { + return await task(); + } finally { + await this.withProfileLifecycleLock(profileId, async () => { + const count = Math.max(0, (this.profileActivities.get(profileId) ?? 1) - 1); + if (count === 0) this.profileActivities.delete(profileId); + else this.profileActivities.set(profileId, count); + const runtime = this.profiles.get(profileId); + if (runtime) { + runtime.activeCommands = count; + this.scheduleProfileIdle(profileId, runtime); + } + }); + } + } + async getPage(input: SessionKeyInput): Promise { const profileId = normalizeProfileId(input.profileId); const session = requireSession(input.session); @@ -410,7 +465,13 @@ export class CloakSessionManager { ? this.findEntryByPageId(runtime, input.pageId) : existingSession && this.openEntries(existingSession)[input.index ?? -1]; if (!match && input.index !== undefined) { - const page = runtime.context.pages().filter(candidate => !pageIsClosed(candidate))[input.index]; + const candidates: PlaywrightPage[] = []; + for (const candidate of runtime.context.pages()) { + if (pageIsClosed(candidate) || candidate === runtime.parkingPage) continue; + if (await this.targetIdForPage(runtime, candidate) === runtime.anchorTargetId) continue; + candidates.push(candidate); + } + const page = candidates[input.index]; if (page) { const targetId = await this.targetIdForPage(runtime, page); const entry = runtime.targetPages.get(targetId) ?? { @@ -528,26 +589,38 @@ export class CloakSessionManager { } async shutdown(): Promise { - for (const runtime of this.profiles.values()) { - for (const entry of runtime.targetPages.values()) this.clearIdleTimer(entry); - await runtime.context.close().catch(() => {}); + this.shuttingDown = true; + while (this.profileLaunches.size > 0) { + await Promise.allSettled([...this.profileLaunches.values()]); } + await Promise.all([...this.profiles.keys()].map(profileId => this.withProfileLifecycleLock(profileId, async () => { + const runtime = this.profiles.get(profileId); + if (!runtime) return; + this.profiles.delete(profileId); + runtime.closing = true; + await this.closeRuntime(runtime, false).catch(() => {}); + }))); this.profiles.clear(); + this.profileLaunches.clear(); + this.profileActivities.clear(); } private async getProfileRuntime(profileId: string, windowMode?: BrowserWindowMode): Promise { - const existing = this.profiles.get(profileId); - if (existing) return existing; - const pending = this.profileLaunches.get(profileId); - if (pending) return pending; - - const launch = this.launchProfileRuntime(profileId, windowMode); - this.profileLaunches.set(profileId, launch); - try { - return await launch; - } finally { - this.profileLaunches.delete(profileId); - } + return this.withProfileLifecycleLock(profileId, async () => { + this.assertRunning(); + const existing = this.profiles.get(profileId); + if (existing && !existing.closing) { + this.cancelProfileIdle(existing); + return existing; + } + const launch = this.launchProfileRuntime(profileId, windowMode); + this.profileLaunches.set(profileId, launch); + try { + return await launch; + } finally { + if (this.profileLaunches.get(profileId) === launch) this.profileLaunches.delete(profileId); + } + }); } private async launchProfileRuntime(profileId: string, windowMode?: BrowserWindowMode): Promise { @@ -569,25 +642,62 @@ export class CloakSessionManager { context = await launchPersistentContext(launchOptions); } const browser = context.browser(); - if (!browser) throw new Error('Cloak page creation requires a Chromium browser connection.'); + let cdp: CDPSession | undefined; + let keeperError: unknown; + try { + cdp = await browser?.newBrowserCDPSession(); + } catch (error) { + keeperError = error; + } const runtime: ProfileRuntime = { + profileId, context, - cdp: await browser.newBrowserCDPSession(), + cdp, sessions: new Map(), windowOwners: new Map(), targetPages: new Map(), + userDataDir, + useParkingKeeper: this.platform !== 'darwin' || !cdp, + keeperWarningLogged: false, + activeCommands: this.profileActivities.get(profileId) ?? 0, + closing: false, + disposed: false, lastSeenAt: Date.now(), }; this.pendingTargetPages.set(runtime, new Map()); this.targetPageWaiters.set(runtime, new Map()); this.attachRuntimeLifecycle(profileId, runtime); + if (cdp) { + try { + runtime.anchorTargetId = (await cdp.send('Target.createTarget', { + url: 'about:blank', + hidden: true, + background: true, + }) as { targetId: string }).targetId; + } catch (error) { + this.warnKeeperFallback(profileId, runtime, error); + } + } else { + this.warnKeeperFallback(profileId, runtime, keeperError ?? new Error('browser connection unavailable')); + } + if (this.shuttingDown) { + runtime.closing = true; + await this.closeRuntime(runtime, false).catch(() => {}); + throw daemonShuttingDownError(); + } this.profiles.set(profileId, runtime); return runtime; } private invalidateProfileRuntime(profileId: string, runtime: ProfileRuntime): void { - if (this.profiles.get(profileId) !== runtime) return; - this.profiles.delete(profileId); + if (this.profiles.get(profileId) === runtime) this.profiles.delete(profileId); + this.cleanupRuntime(runtime); + } + + private cleanupRuntime(runtime: ProfileRuntime): void { + if (runtime.disposed) return; + runtime.disposed = true; + this.cancelProfileIdle(runtime); for (const entry of runtime.targetPages.values()) { if (entry.idleTimer) clearTimeout(entry.idleTimer); this.networkCapture.stop(entry.page); @@ -601,7 +711,7 @@ export class CloakSessionManager { waiter.reject(new Error('Target page, context or browser has been closed')); } this.targetPageWaiters.get(runtime)?.clear(); - void runtime.cdp.detach().catch(() => {}); + void runtime.cdp?.detach().catch(() => {}); } private attachRuntimeLifecycle(profileId: string, runtime: ProfileRuntime): void { @@ -609,6 +719,110 @@ export class CloakSessionManager { runtime.context.on('page', page => { void this.handleContextPage(runtime, page).catch(() => {}); }); + const onCdpEvent = (runtime.cdp as (CDPSession & { + on?: (event: string, listener: (payload: { targetId: string }) => void) => void; + }) | undefined)?.on; + onCdpEvent?.call(runtime.cdp, 'Target.targetDestroyed', ({ targetId }: { targetId: string }) => { + if (targetId !== runtime.anchorTargetId) return; + runtime.anchorTargetId = undefined; + void this.withProfileLifecycleLock(profileId, async () => { + if (this.shuttingDown || runtime.closing || this.profiles.get(profileId) !== runtime) return; + await this.repairAnchor(profileId, runtime); + }); + }); + } + + private async repairAnchor(profileId: string, runtime: ProfileRuntime): Promise { + if (!runtime.cdp) return; + try { + runtime.anchorTargetId = (await runtime.cdp.send('Target.createTarget', { + url: 'about:blank', + hidden: true, + background: true, + }) as { targetId: string }).targetId; + } catch (error) { + this.warnKeeperFallback(profileId, runtime, error); + } + } + + private warnKeeperFallback(profileId: string, runtime: ProfileRuntime, error: unknown): void { + runtime.useParkingKeeper = true; + if (runtime.keeperWarningLogged) return; + runtime.keeperWarningLogged = true; + log.warn(`Cloak Profile ${profileId} hidden keeper unavailable; using a parking page: ${errorMessage(error)}`); + } + + private scheduleProfileIdle(profileId: string, runtime: ProfileRuntime): void { + if (this.profiles.get(profileId) !== runtime || runtime.closing || runtime.idleTimer) return; + if (runtime.activeCommands > 0 || this.hasActiveHandoff(profileId) || this.hasVisiblePages(runtime)) return; + runtime.idleTimer = setTimeout(() => { + runtime.idleTimer = undefined; + void this.withProfileLifecycleLock(profileId, async () => { + if (this.profiles.get(profileId) !== runtime || runtime.closing) return; + if (runtime.activeCommands > 0 || this.hasActiveHandoff(profileId) || this.hasVisiblePages(runtime)) return; + runtime.closing = true; + this.profiles.delete(profileId); + await this.closeRuntime(runtime, true); + }); + }, PROFILE_IDLE_TIMEOUT_MS); + runtime.idleTimer.unref?.(); + } + + private cancelProfileIdle(runtime: ProfileRuntime): void { + if (runtime.idleTimer) clearTimeout(runtime.idleTimer); + runtime.idleTimer = undefined; + } + + private hasVisiblePages(runtime: ProfileRuntime): boolean { + for (const session of runtime.sessions.values()) { + if (this.openEntries(session).length > 0) return true; + } + return false; + } + + private async closeRuntime(runtime: ProfileRuntime, recoverOnTimeout: boolean): Promise { + this.cancelProfileIdle(runtime); + for (const entry of runtime.targetPages.values()) this.clearIdleTimer(entry); + let timeout: ReturnType | undefined; + try { + await Promise.race([ + runtime.context.close(), + new Promise((_, reject) => { + timeout = setTimeout(() => reject(new Error('Cloak Profile close timed out')), PROFILE_CLOSE_TIMEOUT_MS); + timeout.unref?.(); + }), + ]); + } catch (error) { + if (recoverOnTimeout && error instanceof Error && error.message === 'Cloak Profile close timed out') { + await this.recoverLockedProfile(runtime.userDataDir); + } else { + throw error; + } + } finally { + if (timeout) clearTimeout(timeout); + this.cleanupRuntime(runtime); + } + } + + private async withProfileLifecycleLock(profileId: string, operation: () => Promise): Promise { + const previous = this.profileLifecycleQueues.get(profileId); + let release!: () => void; + const released = new Promise((resolve) => { + release = resolve; + }); + const queue = (previous ?? Promise.resolve()).then(() => released); + this.profileLifecycleQueues.set(profileId, queue); + if (previous) await previous.catch(() => {}); + try { + return await operation(); + } finally { + release(); + if (this.profileLifecycleQueues.get(profileId) === queue) this.profileLifecycleQueues.delete(profileId); + } + } + + private assertRunning(): void { + if (this.shuttingDown) throw daemonShuttingDownError(); } private async withPageCreationLock(profileId: string, operation: () => Promise): Promise { @@ -680,6 +894,7 @@ export class CloakSessionManager { } private async createWindowPage(runtime: ProfileRuntime, windowMode?: BrowserWindowMode): Promise { + if (!runtime.cdp) return runtime.context.newPage(); const result = await runtime.cdp.send('Target.createTarget', { url: 'about:blank', newWindow: true, @@ -708,6 +923,16 @@ export class CloakSessionManager { private async handleContextPage(runtime: ProfileRuntime, page: PlaywrightPage): Promise { const targetId = await this.targetIdForPage(runtime, page); + if (targetId === runtime.anchorTargetId) { + page.once('close', () => { + runtime.anchorTargetId = undefined; + void this.withProfileLifecycleLock(runtime.profileId, async () => { + if (this.shuttingDown || runtime.closing || this.profiles.get(runtime.profileId) !== runtime) return; + await this.repairAnchor(runtime.profileId, runtime); + }); + }); + return; + } const waiter = this.targetPageWaiters.get(runtime)?.get(targetId); if (waiter) { this.targetPageWaiters.get(runtime)!.delete(targetId); @@ -740,7 +965,7 @@ export class CloakSessionManager { ): Promise { const targetId = await this.targetIdForPage(runtime, page); this.pendingTargetPages.get(runtime)?.delete(targetId); - const windowId = await this.windowIdForTarget(runtime, targetId); + const windowId = await this.windowIdForTarget(runtime, targetId, page); const owner = runtime.windowOwners.get(windowId); if (owner !== undefined && owner !== session.id) { throw new SessionWindowConflictError(runtime.targetPages.get(targetId)?.pageId ?? 'unknown', session.id, owner); @@ -781,8 +1006,10 @@ export class CloakSessionManager { entry.leaseKey = input.leaseKey ?? (entry.leaseKey.startsWith('unowned\u0000') ? `page\u0000${entry.pageId}` : entry.leaseKey); } session.pages.set(entry.leaseKey, entry); + this.cancelProfileIdle(runtime); this.refreshIdleTimer(runtime, session, entry.leaseKey, entry); if (!wasOwned) for (const listener of this.sessionPageListeners.get(session) ?? []) listener(page); + await this.closeParkingPage(runtime); return entry; } @@ -794,6 +1021,8 @@ export class CloakSessionManager { if (session?.pages.get(entry.leaseKey) === entry) session.pages.delete(entry.leaseKey); } this.clearIdleTimer(entry); + if (runtime.parkingPage === entry.page) runtime.parkingPage = undefined; + this.scheduleProfileIdle(runtime.profileId, runtime); }); } @@ -822,13 +1051,17 @@ export class CloakSessionManager { } } - private async windowIdForTarget(runtime: ProfileRuntime, targetId: string): Promise { - const { windowId } = await runtime.cdp.send('Browser.getWindowForTarget', { targetId }) as { windowId: number }; + private async windowIdForTarget(runtime: ProfileRuntime, targetId: string, page?: PlaywrightPage): Promise { + const entry = runtime.targetPages.get(targetId); + const targetPage = page ?? entry?.page; + const cdp = runtime.cdp ?? (targetPage ? this.pageCdpSessions.get(targetPage) : undefined); + if (!cdp) throw new Error('Cloak page has no CDP session.'); + const { windowId } = await cdp.send('Browser.getWindowForTarget', { targetId }) as { windowId: number }; return windowId; } private async assertOwnedWindow(runtime: ProfileRuntime, sessionId: string, entry: PageEntry): Promise { - const actual = await this.windowIdForTarget(runtime, entry.targetId); + const actual = await this.windowIdForTarget(runtime, entry.targetId, entry.page); const owner = runtime.windowOwners.get(actual); if (owner !== undefined && owner !== sessionId) { throw new SessionWindowConflictError(entry.pageId, sessionId, owner); @@ -846,7 +1079,7 @@ export class CloakSessionManager { await this.assertOwnedWindow(runtime, session.id, entry); return; } - const actual = await this.windowIdForTarget(runtime, entry.targetId); + const actual = await this.windowIdForTarget(runtime, entry.targetId, entry.page); const owner = runtime.windowOwners.get(actual); if (owner !== undefined && owner !== session.id) { throw new SessionWindowConflictError(entry.pageId, session.id, owner); @@ -894,15 +1127,42 @@ export class CloakSessionManager { } private async removeEntry(runtime: ProfileRuntime, session: SessionRuntime, entry: PageEntry, close: boolean): Promise { + const shouldPark = close && runtime.useParkingKeeper + && [...runtime.targetPages.values()].every(candidate => candidate === entry || pageIsClosed(candidate.page)); + const parkingWindowId = shouldPark + ? await this.windowIdForTarget(runtime, entry.targetId, entry.page).catch(() => undefined) + : undefined; if (session.pages.get(entry.leaseKey) === entry) session.pages.delete(entry.leaseKey); runtime.targetPages.delete(entry.targetId); this.clearIdleTimer(entry); this.clearSelectedPage(session, entry); this.networkCapture.stop(entry.page); if (close && !pageIsClosed(entry.page)) { - await runtime.cdp.send('Target.closeTarget', { targetId: entry.targetId }).catch(() => {}); - if (!pageIsClosed(entry.page)) await entry.page.close().catch(() => {}); + if (shouldPark) { + await entry.page.goto('about:blank', { waitUntil: 'load' }).catch(() => {}); + entry.sessionId = undefined; + runtime.parkingPage = pageIsClosed(entry.page) ? undefined : entry.page; + if (parkingWindowId !== undefined) { + runtime.windowOwners.delete(parkingWindowId); + session.windowIds.delete(parkingWindowId); + await runtime.cdp?.send('Browser.setWindowBounds', { + windowId: parkingWindowId, + bounds: { windowState: 'minimized' }, + }).catch(() => {}); + } + } else { + await runtime.cdp?.send('Target.closeTarget', { targetId: entry.targetId }).catch(() => {}); + if (!pageIsClosed(entry.page)) await entry.page.close().catch(() => {}); + } } + this.scheduleProfileIdle(runtime.profileId, runtime); + } + + private async closeParkingPage(runtime: ProfileRuntime): Promise { + const parkingPage = runtime.parkingPage; + if (!parkingPage) return; + runtime.parkingPage = undefined; + if (!pageIsClosed(parkingPage)) await parkingPage.close().catch(() => {}); } private clearIdleTimer(entry: PageEntry): void { @@ -937,13 +1197,13 @@ function isProfileAlreadyInUseError(err: unknown): boolean { async function recoverLockedCloakProfile(userDataDir: string): Promise { if (process.platform === 'win32') return false; - const initial = await findCloakProfileProcesses(userDataDir); + const initial = await findExactCloakProfileProcesses(userDataDir); if (initial.length === 0) return false; signalPids(initial, 'SIGTERM'); if (await waitForProfileProcessesToExit(userDataDir, 2500)) return true; - signalPids(await findCloakProfileProcesses(userDataDir), 'SIGKILL'); + signalPids(await findExactCloakProfileProcesses(userDataDir), 'SIGKILL'); return waitForProfileProcessesToExit(userDataDir, 1500); } @@ -951,9 +1211,9 @@ async function waitForProfileProcessesToExit(userDataDir: string, timeoutMs: num const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { await new Promise((resolve) => setTimeout(resolve, 100)); - if ((await findCloakProfileProcesses(userDataDir)).length === 0) return true; + if ((await findExactCloakProfileProcesses(userDataDir)).length === 0) return true; } - return (await findCloakProfileProcesses(userDataDir)).length === 0; + return (await findExactCloakProfileProcesses(userDataDir)).length === 0; } function signalPids(pids: number[], signal: NodeJS.Signals): void { @@ -965,53 +1225,3 @@ function signalPids(pids: number[], signal: NodeJS.Signals): void { } } } - -async function findCloakProfileProcesses(userDataDir: string): Promise { - const profileDirs = profileDirAliases(userDataDir); - const stdout = await psOutput(); - const pids: number[] = []; - for (const line of stdout.split('\n')) { - const match = line.match(/^\s*(\d+)\s+(.+)$/); - if (!match) continue; - const pid = Number(match[1]); - const command = match[2]; - if (!Number.isInteger(pid) || pid === process.pid) continue; - if (!isCloakBrowserCommand(command)) continue; - if (!commandUsesProfileDir(command, profileDirs)) continue; - pids.push(pid); - } - return [...new Set(pids)]; -} - -function commandUsesProfileDir(command: string, profileDirs: string[]): boolean { - for (const dir of profileDirs) { - const marker = `--user-data-dir=${dir}`; - const index = command.indexOf(marker); - if (index < 0) continue; - const next = command[index + marker.length]; - if (next === undefined || /\s/.test(next)) return true; - } - return false; -} - -function profileDirAliases(userDataDir: string): string[] { - const aliases = new Set([userDataDir]); - try { - aliases.add(fs.realpathSync.native(userDataDir)); - } catch { - // The launch path is still useful even if realpath cannot resolve it. - } - return [...aliases]; -} - -function isCloakBrowserCommand(command: string): boolean { - return command.includes('/.cloakbrowser/') || command.includes('\\.cloakbrowser\\'); -} - -function psOutput(): Promise { - return new Promise((resolve) => { - execFile('ps', ['-axo', 'pid=,command='], { encoding: 'utf8', maxBuffer: 10 * 1024 * 1024, timeout: 2000 }, (err, stdout) => { - resolve(err ? '' : String(stdout)); - }); - }); -} diff --git a/vitest.config.ts b/vitest.config.ts index 0a09fcc3..c93712d7 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -30,7 +30,9 @@ export default defineConfig({ 'src/browser/daemon-client.test.ts', 'src/browser/run/runner.test.ts', 'src/browser/runtime/local-cloak/provider.test.ts', + 'src/browser/runtime/local-cloak/process-matcher.test.ts', 'src/browser/runtime/local-cloak/session-manager.test.ts', + 'src/browser/runtime/local-cloak/darwin-background-launch.test.ts', ], sequence: { groupOrder: 0 }, }, From 54131611bd47d9f4107442452f63bb9df4a7c2b2 Mon Sep 17 00:00:00 2001 From: beubax Date: Wed, 12 Aug 2026 12:04:48 +0530 Subject: [PATCH 14/27] fix: harden cloak profile keeper lifecycle --- .../local-cloak/process-matcher.test.ts | 12 +-- .../runtime/local-cloak/process-matcher.ts | 6 +- .../local-cloak/session-manager.test.ts | 78 ++++++++++++++++++- .../runtime/local-cloak/session-manager.ts | 31 ++++---- 4 files changed, 105 insertions(+), 22 deletions(-) diff --git a/src/browser/runtime/local-cloak/process-matcher.test.ts b/src/browser/runtime/local-cloak/process-matcher.test.ts index 2c13823e..568d8e87 100644 --- a/src/browser/runtime/local-cloak/process-matcher.test.ts +++ b/src/browser/runtime/local-cloak/process-matcher.test.ts @@ -3,23 +3,25 @@ import { matchCloakProfileCommand } from './process-matcher.js'; describe('matchCloakProfileCommand', () => { it('matches only exact Cloak user-data-dir arguments', () => { - const cloak = '/Users/me/.cloakbrowser/chromium --user-data-dir=/profiles/work'; - const cloakSeparate = '/Users/me/.cloakbrowser/chromium --user-data-dir /profiles/work'; - const cloakQuoted = '"/Users/me/.cloakbrowser/Cloak Chromium" "--user-data-dir=/profiles/work"'; - const cloakWork2 = '/Users/me/.cloakbrowser/chromium --user-data-dir=/profiles/work-2'; + const cloak = '/Users/me/.cloakbrowser/chromium-146.0.7680.177.4/chrome --user-data-dir=/profiles/work'; + const cloakSeparate = '/Users/me/.cloakbrowser/chromium-146.0.7680.177.4/chrome --user-data-dir /profiles/work'; + const cloakQuoted = '"/Users/me/.cloakbrowser/chromium-146.0.7680.177.4/Chromium.app/Contents/MacOS/Chromium" "--user-data-dir=/profiles/work"'; + const cloakWork2 = '/Users/me/.cloakbrowser/chromium-146.0.7680.177.4/chrome --user-data-dir=/profiles/work-2'; const chromeWork = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome --user-data-dir=/profiles/work'; expect(matchCloakProfileCommand(cloak, '/profiles/work')).toBe(true); expect(matchCloakProfileCommand(cloakSeparate, '/profiles/work')).toBe(true); expect(matchCloakProfileCommand(cloakQuoted, '/profiles/work')).toBe(true); + expect(matchCloakProfileCommand('C:\\Users\\me\\.cloakbrowser\\chromium-146.0.7680.177.4\\chrome.exe --user-data-dir=C:\\profiles\\work', 'C:\\profiles\\work')).toBe(true); expect(matchCloakProfileCommand(cloakWork2, '/profiles/work')).toBe(false); expect(matchCloakProfileCommand(chromeWork, '/profiles/work')).toBe(false); expect(matchCloakProfileCommand('node tool.js --user-data-dir=/profiles/work', '/profiles/work')).toBe(false); expect(matchCloakProfileCommand('node /tmp/.cloakbrowser/tool.js --user-data-dir=/profiles/work', '/profiles/work')).toBe(false); + expect(matchCloakProfileCommand('/tmp/.cloakbrowser/helper --user-data-dir=/profiles/work', '/profiles/work')).toBe(false); }); it('accepts quotes around a separate or equals-form profile value', () => { - const executable = '/Users/me/.cloakbrowser/chromium'; + const executable = '/Users/me/.cloakbrowser/chromium-146.0.7680.177.4/chrome'; expect(matchCloakProfileCommand(`${executable} --user-data-dir "/profiles/work space"`, '/profiles/work space')).toBe(true); expect(matchCloakProfileCommand(`${executable} --user-data-dir='/profiles/work space'`, '/profiles/work space')).toBe(true); }); diff --git a/src/browser/runtime/local-cloak/process-matcher.ts b/src/browser/runtime/local-cloak/process-matcher.ts index febc130d..dc015819 100644 --- a/src/browser/runtime/local-cloak/process-matcher.ts +++ b/src/browser/runtime/local-cloak/process-matcher.ts @@ -3,7 +3,11 @@ import { execFile } from 'node:child_process'; export function matchCloakProfileCommand(command: string, userDataDir: string): boolean { const args = splitCommand(command); - if (!args[0]?.includes('/.cloakbrowser/') && !args[0]?.includes('\\.cloakbrowser\\')) return false; + const executable = args[0]; + const executableParts = executable?.split(/[\\/]/u) ?? []; + const cacheIndex = executableParts.lastIndexOf('.cloakbrowser'); + if (cacheIndex < 0 || !/^chromium-\d+(?:\.\d+)*(?:-pro)?$/u.test(executableParts[cacheIndex + 1] ?? '')) return false; + if (!['chrome', 'chrome.exe', 'chromium'].includes(executableParts.at(-1)?.toLowerCase() ?? '')) return false; for (let index = 0; index < args.length; index += 1) { if (args[index] === '--user-data-dir' && args[index + 1] === userDataDir) return true; if (args[index] === `--user-data-dir=${userDataDir}`) return true; diff --git a/src/browser/runtime/local-cloak/session-manager.test.ts b/src/browser/runtime/local-cloak/session-manager.test.ts index 69b9021f..bb2dfdbe 100644 --- a/src/browser/runtime/local-cloak/session-manager.test.ts +++ b/src/browser/runtime/local-cloak/session-manager.test.ts @@ -7,6 +7,7 @@ import { dispatchCloakAction } from './actions.js'; function fakeContext() { const listeners = new Map void>>(); + const cdpListeners = new Map void>>(); const pageListeners = new WeakMap void>>>(); const targetIds = new WeakMap(); const windowIds = new Map(); @@ -87,7 +88,11 @@ function fakeContext() { if (command === 'Target.closeTarget') return { success: true }; return {}; }), - on: vi.fn(), + on: vi.fn((event: string, listener: (...args: any[]) => void) => { + const bucket = cdpListeners.get(event) ?? new Set(); + bucket.add(listener); + cdpListeners.set(event, bucket); + }), detach: vi.fn().mockResolvedValue(undefined), }; return { @@ -126,6 +131,10 @@ function fakeContext() { windowIdFor: (target: object) => windowIds.get(targetIds.get(target) ?? ''), moveToWindow: (target: object, windowId: number) => windowIds.set(targetIds.get(target)!, windowId), emitPage: (target: object) => emit('page', target), + emitCdp: (event: string, payload: unknown) => { + for (const listener of cdpListeners.get(event) ?? []) listener(payload); + }, + pageListenerCount: (target: object, event: string) => pageListeners.get(target)?.get(event)?.size ?? 0, makePage: fakePage, }; } @@ -1149,13 +1158,13 @@ describe('CloakSessionManager', () => { expect(warn).toHaveBeenCalledOnce(); }); - it('reuses a warm profile and replaces its parking page on the next Session', async () => { + it.each(['linux', 'win32'] as const)('reuses a warm %s profile and replaces its parking page on the next Session', async (platform) => { vi.useFakeTimers(); const launched = fakeContext(); const launchPersistentContext = vi.fn().mockResolvedValue(launched.context); const manager = new CloakSessionManager({ baseDir: '/tmp/webcmd-test', - platform: 'linux', + platform, launchPersistentContext, }); const first = await manager.getPage({ profileId: 'work', session: 'session_a', surface: 'browser' }); @@ -1174,6 +1183,69 @@ describe('CloakSessionManager', () => { expect(launchPersistentContext).toHaveBeenCalledOnce(); }); + it('rechecks an empty profile after its active handoff expires', async () => { + vi.useFakeTimers(); + let handoffActive = true; + const launched = fakeContext(); + const manager = new CloakSessionManager({ + baseDir: '/tmp/webcmd-test', + launchPersistentContext: vi.fn().mockResolvedValue(launched.context), + hasActiveHandoff: () => handoffActive, + }); + await manager.getPage({ profileId: 'work', session: 'session_a', surface: 'browser' }); + await manager.closeSession('work', 'session_a'); + + await vi.advanceTimersByTimeAsync(60_000); + expect(manager.activeProfileIds()).toEqual(['work']); + handoffActive = false; + await vi.advanceTimersByTimeAsync(60_000); + + expect(manager.activeProfileIds()).toEqual([]); + expect(launched.context.close).toHaveBeenCalledOnce(); + }); + + it('unrefs the profile idle timer', async () => { + const timer = setTimeout(() => {}, 0); + const unref = vi.spyOn(Object.getPrototypeOf(timer), 'unref'); + clearTimeout(timer); + const launched = fakeContext(); + const manager = new CloakSessionManager({ + baseDir: '/tmp/webcmd-test', + launchPersistentContext: vi.fn().mockResolvedValue(launched.context), + }); + await manager.getPage({ profileId: 'work', session: 'session_a', surface: 'browser' }); + + await manager.closeSession('work', 'session_a'); + + expect(unref).toHaveBeenCalled(); + await manager.shutdown(); + }); + + it('repairs one anchor for duplicate destruction and page-close notifications', async () => { + const launched = fakeContext(); + const manager = new CloakSessionManager({ + baseDir: '/tmp/webcmd-test', + platform: 'darwin', + launchPersistentContext: vi.fn().mockResolvedValue(launched.context), + }); + await manager.getPage({ profileId: 'work', session: 'session_a', surface: 'browser' }); + const anchor = launched.backgroundPages[0]; + const anchorTargetId = launched.targetIdFor(anchor)!; + launched.emitPage(anchor); + await vi.waitFor(() => expect(launched.pageListenerCount(anchor, 'close')).toBeGreaterThan(0)); + + launched.emitCdp('Target.targetDestroyed', { targetId: anchorTargetId }); + await anchor.close(); + await vi.waitFor(() => expect(launched.cdp.send.mock.calls.filter(([, params]) => ( + (params as { hidden?: boolean })?.hidden + ))).toHaveLength(2)); + await Promise.resolve(); + + expect(launched.cdp.send.mock.calls.filter(([, params]) => ( + (params as { hidden?: boolean })?.hidden + ))).toHaveLength(2); + }); + it('recovers one timed-out idle close before launching one replacement', async () => { vi.useFakeTimers(); const first = fakeContext(); diff --git a/src/browser/runtime/local-cloak/session-manager.ts b/src/browser/runtime/local-cloak/session-manager.ts index ad954794..a216d684 100644 --- a/src/browser/runtime/local-cloak/session-manager.ts +++ b/src/browser/runtime/local-cloak/session-manager.ts @@ -723,12 +723,17 @@ export class CloakSessionManager { on?: (event: string, listener: (payload: { targetId: string }) => void) => void; }) | undefined)?.on; onCdpEvent?.call(runtime.cdp, 'Target.targetDestroyed', ({ targetId }: { targetId: string }) => { - if (targetId !== runtime.anchorTargetId) return; - runtime.anchorTargetId = undefined; - void this.withProfileLifecycleLock(profileId, async () => { - if (this.shuttingDown || runtime.closing || this.profiles.get(profileId) !== runtime) return; - await this.repairAnchor(profileId, runtime); - }); + this.queueAnchorRepair(profileId, runtime, targetId); + }); + } + + private queueAnchorRepair(profileId: string, runtime: ProfileRuntime, destroyedTargetId: string): void { + if (runtime.anchorTargetId !== destroyedTargetId) return; + runtime.anchorTargetId = undefined; + void this.withProfileLifecycleLock(profileId, async () => { + if (this.shuttingDown || runtime.closing || this.profiles.get(profileId) !== runtime) return; + if (runtime.anchorTargetId !== undefined) return; + await this.repairAnchor(profileId, runtime); }); } @@ -754,12 +759,16 @@ export class CloakSessionManager { private scheduleProfileIdle(profileId: string, runtime: ProfileRuntime): void { if (this.profiles.get(profileId) !== runtime || runtime.closing || runtime.idleTimer) return; - if (runtime.activeCommands > 0 || this.hasActiveHandoff(profileId) || this.hasVisiblePages(runtime)) return; + if (runtime.activeCommands > 0 || this.hasVisiblePages(runtime)) return; runtime.idleTimer = setTimeout(() => { runtime.idleTimer = undefined; void this.withProfileLifecycleLock(profileId, async () => { if (this.profiles.get(profileId) !== runtime || runtime.closing) return; - if (runtime.activeCommands > 0 || this.hasActiveHandoff(profileId) || this.hasVisiblePages(runtime)) return; + if (runtime.activeCommands > 0 || this.hasVisiblePages(runtime)) return; + if (this.hasActiveHandoff(profileId)) { + this.scheduleProfileIdle(profileId, runtime); + return; + } runtime.closing = true; this.profiles.delete(profileId); await this.closeRuntime(runtime, true); @@ -925,11 +934,7 @@ export class CloakSessionManager { const targetId = await this.targetIdForPage(runtime, page); if (targetId === runtime.anchorTargetId) { page.once('close', () => { - runtime.anchorTargetId = undefined; - void this.withProfileLifecycleLock(runtime.profileId, async () => { - if (this.shuttingDown || runtime.closing || this.profiles.get(runtime.profileId) !== runtime) return; - await this.repairAnchor(runtime.profileId, runtime); - }); + this.queueAnchorRepair(runtime.profileId, runtime, targetId); }); return; } From 39b5489ba525bee9eac27fe8dd760ee416c01502 Mon Sep 17 00:00:00 2001 From: beubax Date: Wed, 12 Aug 2026 12:27:04 +0530 Subject: [PATCH 15/27] feat: scope local auth handoff to sessions --- src/browser/daemon-client.ts | 35 +++++- src/browser/protocol.ts | 7 +- src/browser/runtime/local-cloak/provider.ts | 15 +++ .../local-cloak/session-manager.test.ts | 48 +++++++++ .../runtime/local-cloak/session-manager.ts | 36 ++++++- src/browser/runtime/provider.ts | 2 + src/browser/sessions.test.ts | 23 ++++ src/browser/sessions.ts | 23 +++- src/daemon/server.test.ts | 100 ++++++++++++++++++ src/daemon/server.ts | 81 +++++++++++++- src/execution.test.ts | 88 ++++++++++++++- src/execution.ts | 62 ++++++++--- src/plugin-runtime.test.ts | 2 + src/plugin-runtime.ts | 7 +- 14 files changed, 498 insertions(+), 31 deletions(-) diff --git a/src/browser/daemon-client.ts b/src/browser/daemon-client.ts index 19f9820c..c3b20e12 100644 --- a/src/browser/daemon-client.ts +++ b/src/browser/daemon-client.ts @@ -23,6 +23,7 @@ import { type DaemonStatus, } from './daemon-transport.js'; import type { BrowserRuntimeCommand, BrowserRuntimeResult, BrowserWindowMode } from './protocol.js'; +import type { BrowserSessionRecord } from './sessions.js'; let _idCounter = 0; @@ -34,6 +35,7 @@ const DEFAULT_COMMAND_TIMEOUT_SECONDS = 120; const RUNTIME_OP_TIMEOUT_MARGIN_MS = 15_000; const HTTP_TIMEOUT_MARGIN_MS = 10_000; const TRANSPORT_MAX_ATTEMPTS = 4; +type DaemonCommandParams = Omit; let _userCommandTimeoutSeconds: number | null = null; @@ -41,7 +43,7 @@ export function setDaemonCommandTimeoutSeconds(seconds: number | null): void { _userCommandTimeoutSeconds = typeof seconds === 'number' && seconds > 0 ? Math.ceil(seconds) : null; } -function effectiveCommandTimeoutSeconds(params: Omit): number { +function effectiveCommandTimeoutSeconds(params: DaemonCommandParams): number { const base = _userCommandTimeoutSeconds ?? DEFAULT_COMMAND_TIMEOUT_SECONDS; if (typeof params.timeoutMs === 'number' && params.timeoutMs > 0) { return Math.max(base, Math.ceil((params.timeoutMs + RUNTIME_OP_TIMEOUT_MARGIN_MS) / 1000)); @@ -115,7 +117,7 @@ export { */ async function sendCommandRaw( action: DaemonCommand['action'], - params: Omit, + params: DaemonCommandParams, ): Promise { const timeoutSeconds = effectiveCommandTimeoutSeconds(params); const deadlineAt = Date.now() + timeoutSeconds * 1000; @@ -251,7 +253,7 @@ async function sendCommandRaw( */ export async function sendCommand( action: DaemonCommand['action'], - params: Omit = {}, + params: DaemonCommandParams = {}, ): Promise { const result = await sendCommandRaw(action, params); return result.data; @@ -263,7 +265,7 @@ export async function sendCommand( */ export async function sendCommandFull( action: DaemonCommand['action'], - params: Omit = {}, + params: DaemonCommandParams = {}, ): Promise<{ data: unknown; page?: string }> { const result = await sendCommandRaw(action, params); return { data: result.data, page: result.page }; @@ -277,6 +279,31 @@ export async function cancelDaemonRun(runId: string): Promise { await postRunControl('run-cancel', runId); } +type SessionHandoffParams = { + session?: string; + site: string; + contextId?: string; + preferredContextId?: string; +}; + +export async function startSessionHandoff( + params: SessionHandoffParams & { expiresAt: string }, +): Promise { + return sendCommand('session-handoff-start', { + ...params, + surface: 'adapter', + adapterSite: params.site, + }) as Promise; +} + +export async function clearSessionHandoff(params: SessionHandoffParams): Promise { + return sendCommand('session-handoff-clear', { + ...params, + surface: 'adapter', + adapterSite: params.site, + }) as Promise; +} + async function postRunControl(action: 'lease-release' | 'run-cancel', runId: string): Promise { const command: DaemonCommand = { id: generateId(), diff --git a/src/browser/protocol.ts b/src/browser/protocol.ts index 584bbae0..1d5b47b0 100644 --- a/src/browser/protocol.ts +++ b/src/browser/protocol.ts @@ -23,7 +23,9 @@ export type BrowserRuntimeAction = | 'run-cancel' | 'session-create' | 'session-list' - | 'session-close'; + | 'session-close' + | 'session-handoff-start' + | 'session-handoff-clear'; export type BrowserSurface = 'browser' | 'adapter'; export type SiteSessionMode = 'ephemeral' | 'persistent'; @@ -87,6 +89,9 @@ export interface BrowserRuntimeCommand { access?: 'read' | 'write'; /** Originating CLI process, used only for actionable local busy guidance. */ pid?: number; + /** Site and expiry payload for internal Session handoff controls. */ + site?: string; + expiresAt?: string; } export interface BrowserRuntimeResult { diff --git a/src/browser/runtime/local-cloak/provider.ts b/src/browser/runtime/local-cloak/provider.ts index 71388a22..a62ced12 100644 --- a/src/browser/runtime/local-cloak/provider.ts +++ b/src/browser/runtime/local-cloak/provider.ts @@ -57,6 +57,21 @@ export class LocalCloakRuntimeProvider implements BrowserRuntimeProvider { return this.sessions.resolveAdapterDefault(this.resolveProfileId(command)); } + async startSessionHandoff(command: BrowserRuntimeCommand): Promise { + const profileId = this.resolveProfileId(command); + const sessionId = command.sessionId!; + const record = this.sessions.markHandoff(profileId, sessionId, { + site: command.site!, + expiresAt: command.expiresAt!, + }); + await this.manager.foregroundSession(profileId, sessionId); + return record; + } + + async clearSessionHandoff(command: BrowserRuntimeCommand): Promise { + return this.sessions.clearHandoff(this.resolveProfileId(command), command.sessionId!); + } + async listSessions(input: { profileId?: string }): Promise { return this.sessions.list(input.profileId).map((session) => ({ ...session, diff --git a/src/browser/runtime/local-cloak/session-manager.test.ts b/src/browser/runtime/local-cloak/session-manager.test.ts index bb2dfdbe..9582ebf3 100644 --- a/src/browser/runtime/local-cloak/session-manager.test.ts +++ b/src/browser/runtime/local-cloak/session-manager.test.ts @@ -34,6 +34,7 @@ function fakeContext() { title: vi.fn().mockResolvedValue('Title'), url: vi.fn().mockReturnValue('https://example.com/'), screenshot: vi.fn().mockResolvedValue(Buffer.from('png')), + bringToFront: vi.fn().mockResolvedValue(undefined), isClosed: vi.fn(() => closed), close: vi.fn(async () => { closed = true; @@ -389,6 +390,25 @@ describe('CloakSessionManager', () => { expect(activateBackgroundContext).toHaveBeenCalledWith(launched.context); }); + it('foregrounds only the selected Session window during handoff', async () => { + const launched = fakeContext(); + const activateBackgroundContext = vi.fn().mockResolvedValue(undefined); + const manager = new CloakSessionManager({ + baseDir: '/tmp/webcmd-test', + platform: 'darwin', + launchPersistentContext: vi.fn().mockResolvedValue(launched.context), + activateBackgroundContext, + }); + const first = await manager.getPage({ profileId: 'work', session: 'session_a', sessionId: 'session_a', surface: 'adapter' }); + const sibling = await manager.getPage({ profileId: 'work', session: 'session_b', sessionId: 'session_b', surface: 'adapter' }); + + await manager.foregroundSession('work', 'session_a'); + + expect(first.page.bringToFront).toHaveBeenCalledOnce(); + expect(sibling.page.bringToFront).not.toHaveBeenCalled(); + expect(activateBackgroundContext).toHaveBeenCalledWith(launched.context); + }); + it('creates a warm background lease tab without focusing Chromium', async () => { const launched = fakeContext(); const manager = new CloakSessionManager({ @@ -1200,6 +1220,34 @@ describe('CloakSessionManager', () => { handoffActive = false; await vi.advanceTimersByTimeAsync(60_000); + expect(manager.activeProfileIds()).toEqual(['work']); + await vi.advanceTimersByTimeAsync(60_000); + + expect(manager.activeProfileIds()).toEqual([]); + expect(launched.context.close).toHaveBeenCalledOnce(); + }); + + it('starts a fresh idle grace when handoff expiry is observed near a wakeup boundary', async () => { + vi.useFakeTimers(); + let handoffActive = true; + const launched = fakeContext(); + const manager = new CloakSessionManager({ + baseDir: '/tmp/webcmd-test', + launchPersistentContext: vi.fn().mockResolvedValue(launched.context), + hasActiveHandoff: () => handoffActive, + }); + await manager.getPage({ profileId: 'work', session: 'session_a', surface: 'browser' }); + await manager.closeSession('work', 'session_a'); + + await vi.advanceTimersByTimeAsync(119_999); + handoffActive = false; + await vi.advanceTimersByTimeAsync(1); + expect(manager.activeProfileIds()).toEqual(['work']); + + await vi.advanceTimersByTimeAsync(59_999); + expect(manager.activeProfileIds()).toEqual(['work']); + await vi.advanceTimersByTimeAsync(1); + expect(manager.activeProfileIds()).toEqual([]); expect(launched.context.close).toHaveBeenCalledOnce(); }); diff --git a/src/browser/runtime/local-cloak/session-manager.ts b/src/browser/runtime/local-cloak/session-manager.ts index a216d684..da05976a 100644 --- a/src/browser/runtime/local-cloak/session-manager.ts +++ b/src/browser/runtime/local-cloak/session-manager.ts @@ -103,6 +103,7 @@ interface ProfileRuntime { keeperWarningLogged: boolean; activeCommands: number; idleTimer?: ReturnType; + handoffTimer?: ReturnType; closing: boolean; disposed: boolean; lastSeenAt: number; @@ -453,6 +454,23 @@ export class CloakSessionManager { return { profileId, leaseKey, context: runtime.context, page: entry.page, pageId: entry.pageId }; } + async foregroundSession(profileIdInput: string, sessionId: string): Promise { + const profileId = normalizeProfileId(profileIdInput); + const runtime = this.profiles.get(profileId); + const session = runtime?.sessions.get(sessionId); + if (!runtime || !session) return false; + const entries = this.openEntries(session); + const match = entries.find(([, entry]) => entry.pageId === session.selectedPageId) ?? entries[0]; + if (!match) return false; + const entry = match[1]; + await this.assertOwnedWindow(runtime, sessionId, entry); + await entry.page.bringToFront?.().catch(() => {}); + await this.activateBackgroundContext(runtime.context); + this.selectEntry(session, entry); + runtime.lastSeenAt = Date.now(); + return true; + } + async bindPage(input: SessionKeyInput & { pageId?: string; index?: number }): Promise { const profileId = normalizeProfileId(input.profileId); const session = requireSession(input.session); @@ -758,15 +776,19 @@ export class CloakSessionManager { } private scheduleProfileIdle(profileId: string, runtime: ProfileRuntime): void { - if (this.profiles.get(profileId) !== runtime || runtime.closing || runtime.idleTimer) return; + if (this.profiles.get(profileId) !== runtime || runtime.closing || runtime.idleTimer || runtime.handoffTimer) return; if (runtime.activeCommands > 0 || this.hasVisiblePages(runtime)) return; + if (this.hasActiveHandoff(profileId)) { + this.scheduleHandoffWake(profileId, runtime); + return; + } runtime.idleTimer = setTimeout(() => { runtime.idleTimer = undefined; void this.withProfileLifecycleLock(profileId, async () => { if (this.profiles.get(profileId) !== runtime || runtime.closing) return; if (runtime.activeCommands > 0 || this.hasVisiblePages(runtime)) return; if (this.hasActiveHandoff(profileId)) { - this.scheduleProfileIdle(profileId, runtime); + this.scheduleHandoffWake(profileId, runtime); return; } runtime.closing = true; @@ -777,9 +799,19 @@ export class CloakSessionManager { runtime.idleTimer.unref?.(); } + private scheduleHandoffWake(profileId: string, runtime: ProfileRuntime): void { + runtime.handoffTimer = setTimeout(() => { + runtime.handoffTimer = undefined; + this.scheduleProfileIdle(profileId, runtime); + }, PROFILE_IDLE_TIMEOUT_MS); + runtime.handoffTimer.unref?.(); + } + private cancelProfileIdle(runtime: ProfileRuntime): void { if (runtime.idleTimer) clearTimeout(runtime.idleTimer); + if (runtime.handoffTimer) clearTimeout(runtime.handoffTimer); runtime.idleTimer = undefined; + runtime.handoffTimer = undefined; } private hasVisiblePages(runtime: ProfileRuntime): boolean { diff --git a/src/browser/runtime/provider.ts b/src/browser/runtime/provider.ts index b6dc6ebe..c14d37d0 100644 --- a/src/browser/runtime/provider.ts +++ b/src/browser/runtime/provider.ts @@ -11,6 +11,8 @@ export interface BrowserRuntimeProvider { createSession?(command: BrowserRuntimeCommand): Promise; requireSession?(command: BrowserRuntimeCommand): Promise; resolveAdapterDefault?(command: BrowserRuntimeCommand): Promise; + startSessionHandoff?(command: BrowserRuntimeCommand): Promise; + clearSessionHandoff?(command: BrowserRuntimeCommand): Promise; listSessions?(input: { profileId?: string }): Promise; closeSession?(command: BrowserRuntimeCommand): Promise<{ closed: boolean; alreadyIdle: boolean; session: string }>; dispatch(command: BrowserRuntimeCommand): Promise; diff --git a/src/browser/sessions.test.ts b/src/browser/sessions.test.ts index 2a9f76fd..8abf774c 100644 --- a/src/browser/sessions.test.ts +++ b/src/browser/sessions.test.ts @@ -80,4 +80,27 @@ describe('LocalBrowserSessionStore', () => { expect(() => new LocalBrowserSessionStore({ baseDir }).list('profile_work')) .toThrowError(expect.objectContaining({ code: 'CONFIG' })); }); + + it('clears expired handoffs while resolving and listing Sessions', () => { + let now = new Date('2026-08-11T00:00:00.000Z'); + const store = new LocalBrowserSessionStore({ + baseDir: tempDir(), + now: () => now, + idFactory: () => 'session_a', + }); + const session = store.create('work'); + store.markHandoff('work', session.id, { + site: 'github', + expiresAt: '2026-08-11T00:15:00.000Z', + }); + + expect(store.require('work', session.id).handoff).toEqual({ + site: 'github', + expiresAt: '2026-08-11T00:15:00.000Z', + }); + now = new Date('2026-08-11T00:15:00.000Z'); + + expect(store.require('work', session.id).handoff).toBeUndefined(); + expect(store.list('work')[0]?.handoff).toBeUndefined(); + }); }); diff --git a/src/browser/sessions.ts b/src/browser/sessions.ts index 49671577..b425c6ec 100644 --- a/src/browser/sessions.ts +++ b/src/browser/sessions.ts @@ -172,7 +172,17 @@ export class LocalBrowserSessionStore { } catch (error) { throw new ConfigError(`Could not read browser sessions: ${error instanceof Error ? error.message : String(error)}`); } - return validateState(parsed); + const state = validateState(parsed); + const now = this.now().getTime(); + let changed = false; + for (const record of state.sessions) { + if (record.handoff && Date.parse(record.handoff.expiresAt) <= now) { + delete record.handoff; + changed = true; + } + } + if (changed) this.save(state); + return state; } private save(state: StateFile): void { @@ -224,6 +234,15 @@ function validateRecord(value: unknown, adapterDefaults: Set): BrowserSe if (adapterDefaults.has(key)) throw new ConfigError(`browser-sessions.json contains multiple adapter-default Sessions for ${key}.`); adapterDefaults.add(key); } + const handoff = row.handoff; + if (handoff && ( + typeof handoff.site !== 'string' + || !handoff.site.trim() + || typeof handoff.expiresAt !== 'string' + || Number.isNaN(Date.parse(handoff.expiresAt)) + )) { + throw new ConfigError('browser-sessions.json contains an invalid handoff.'); + } return { id: row.id, profileId: row.profileId, @@ -231,7 +250,7 @@ function validateRecord(value: unknown, adapterDefaults: Set): BrowserSe createdAt, updatedAt, lastUsedAt, - ...(row.handoff ? { handoff: row.handoff } : {}), + ...(handoff ? { handoff } : {}), }; } diff --git a/src/daemon/server.test.ts b/src/daemon/server.test.ts index 2039d1c0..a95de651 100644 --- a/src/daemon/server.test.ts +++ b/src/daemon/server.test.ts @@ -10,6 +10,7 @@ class FakeProvider implements BrowserRuntimeProvider { commands: BrowserRuntimeCommand[] = []; sessions: BrowserSessionRecord[] = []; activeSessions = new Set(); + foregroundedSessions: string[] = []; shutdownCalled = false; delayMs = 0; dispatchImpl?: (command: BrowserRuntimeCommand) => Promise; @@ -77,6 +78,22 @@ class FakeProvider implements BrowserRuntimeProvider { }; } + async startSessionHandoff(command: BrowserRuntimeCommand): Promise { + const record = await this.requireSession(command); + record.handoff = { site: String(command.site), expiresAt: String(command.expiresAt) }; + const index = this.sessions.findIndex((session) => session.profileId === record.profileId && session.id === record.id); + if (index === -1) this.sessions.push(record); + else this.sessions[index] = record; + this.foregroundedSessions.push(record.id); + return record; + } + + async clearSessionHandoff(command: BrowserRuntimeCommand): Promise { + const record = await this.requireSession(command); + delete record.handoff; + return record; + } + async dispatch(command: BrowserRuntimeCommand) { this.commands.push(command); if (this.dispatchImpl) return this.dispatchImpl(command); @@ -401,6 +418,89 @@ describe('createDaemonServer', () => { expect(provider.commands.map(({ id }) => id)).toEqual(['github-a', 'linkedin-b']); }); + it('pauses only the handoff Session and admits only its internal site verification', async () => { + const { provider, baseUrl } = await start(); + const expiresAt = new Date(Date.now() + 60_000).toISOString(); + + const started = await postCommand(baseUrl, { + id: 'handoff-start', + action: 'session-handoff-start' as BrowserRuntimeCommand['action'], + surface: 'adapter', + session: 'session_a', + adapterSite: 'github', + site: 'github', + expiresAt, + runId: 'run_100_1_1', + command: 'github/login', + }); + expect(started.status).toBe(200); + expect(provider.foregroundedSessions).toEqual(['session_a']); + + await postCommand(baseUrl, { id: 'release-login', action: 'lease-release', runId: 'run_100_1_1' }); + const paused = await postCommand(baseUrl, adapterCommand('paused', 'run_200_2_2', 'github', 'session_a')); + expect(paused.status).toBe(409); + await expect(paused.json()).resolves.toMatchObject({ + errorCode: 'SESSION_PAUSED_FOR_HUMAN_HANDOFF', + details: { sessionId: 'session_a', site: 'github', expiresAt }, + }); + + const sibling = await postCommand(baseUrl, adapterCommand('sibling', 'run_300_3_3', 'linkedin', 'session_b')); + expect(sibling.status).toBe(200); + + const rawWhoami = await postCommand(baseUrl, { + ...adapterCommand('raw-whoami', 'run_400_4_4', 'github', 'session_a'), + surface: 'browser', + command: 'github/whoami', + }); + expect(rawWhoami.status).toBe(409); + + const wrongSite = await postCommand(baseUrl, { + ...adapterCommand('wrong-site', 'run_400_4_4', 'linkedin', 'session_a'), + command: 'github/whoami', + }); + expect(wrongSite.status).toBe(409); + + const verification = await postCommand(baseUrl, { + ...adapterCommand('verify', 'run_500_5_5', 'github', 'session_a'), + command: 'github/whoami', + }); + expect(verification.status).toBe(200); + + const cleared = await postCommand(baseUrl, { + id: 'handoff-clear', + action: 'session-handoff-clear' as BrowserRuntimeCommand['action'], + surface: 'adapter', + session: 'session_a', + adapterSite: 'github', + site: 'github', + runId: 'run_500_5_5', + command: 'github/whoami', + }); + expect(cleared.status).toBe(200); + + await postCommand(baseUrl, { id: 'release-verify', action: 'lease-release', runId: 'run_500_5_5' }); + const resumed = await postCommand(baseUrl, adapterCommand('resumed', 'run_600_6_6', 'github', 'session_a')); + expect(resumed.status).toBe(200); + }); + + it('ignores expired handoffs before admission', async () => { + const provider = new FakeProvider(); + provider.sessions.push({ + id: 'session_a', + profileId: 'default', + kind: 'explicit', + createdAt: '2026-08-11T00:00:00.000Z', + updatedAt: '2026-08-11T00:00:00.000Z', + lastUsedAt: '2026-08-11T00:00:00.000Z', + handoff: { site: 'github', expiresAt: '2026-08-11T00:15:00.000Z' }, + }); + const { baseUrl } = await start(provider); + + const result = await postCommand(baseUrl, adapterCommand('after-expiry', 'run_700_7_7', 'github', 'session_a')); + + expect(result.status).toBe(200); + }); + it('lets one logical run issue multiple operations and heartbeat its lease', async () => { let now = 1_000; vi.spyOn(Date, 'now').mockImplementation(() => now); diff --git a/src/daemon/server.ts b/src/daemon/server.ts index 2cf82b86..947ea075 100644 --- a/src/daemon/server.ts +++ b/src/daemon/server.ts @@ -75,8 +75,10 @@ function commandProfileId(provider: BrowserRuntimeProvider, command: BrowserRunt async function resolveBrowserSession( provider: BrowserRuntimeProvider, command: BrowserRuntimeCommand, -): Promise { - if (command.action === 'lease-release' || command.action === 'run-cancel' || SESSION_LIFECYCLE_ACTIONS.has(command.action)) return command; +): Promise<{ command: BrowserRuntimeCommand; session?: BrowserSessionRecord }> { + if (command.action === 'lease-release' || command.action === 'run-cancel' || SESSION_LIFECYCLE_ACTIONS.has(command.action)) { + return { command }; + } let session: BrowserSessionRecord | undefined; let sessionKind: BrowserRuntimeCommand['sessionKind']; if (command.surface === 'adapter' && !command.session) { @@ -86,7 +88,62 @@ async function resolveBrowserSession( session = await provider.requireSession?.(command); sessionKind = 'explicit'; } - return session ? { ...command, session: session.id, sessionId: session.id, sessionKind } : command; + return { + command: session ? { ...command, session: session.id, sessionId: session.id, sessionKind } : command, + session, + }; +} + +const HANDOFF_ACTIONS = new Set([ + 'session-handoff-start', + 'session-handoff-clear', +]); + +function isHandoffVerification(command: BrowserRuntimeCommand, site: string): boolean { + return command.surface === 'adapter' + && command.adapterSite === site + && command.command === `${site}/whoami`; +} + +function handoffPauseResult(command: BrowserRuntimeCommand, session: BrowserSessionRecord): BrowserRuntimeResult | null { + const handoff = session.handoff; + if (!handoff || Date.parse(handoff.expiresAt) <= Date.now()) return null; + if (isHandoffVerification(command, handoff.site)) return null; + return { + id: command.id, + ok: false, + errorCode: 'SESSION_PAUSED_FOR_HUMAN_HANDOFF', + error: `Session ${session.id} is paused while a human completes ${handoff.site} authentication.`, + details: { sessionId: session.id, sessionKind: session.kind, ...handoff }, + }; +} + +async function handleSessionHandoff( + provider: BrowserRuntimeProvider, + command: BrowserRuntimeCommand, + session: BrowserSessionRecord, +): Promise { + if (!HANDOFF_ACTIONS.has(command.action)) return null; + if (!command.runId || !command.site?.trim()) { + return { id: command.id, ok: false, errorCode: 'invalid_request', error: 'Session handoff controls require runId and site.' }; + } + if (command.action === 'session-handoff-start') { + const expiresAt = command.expiresAt ? Date.parse(command.expiresAt) : Number.NaN; + if (command.command !== `${command.site}/login` || !Number.isFinite(expiresAt) || expiresAt <= Date.now()) { + return { id: command.id, ok: false, errorCode: 'invalid_request', error: 'Invalid Session handoff start control.' }; + } + const record = await provider.startSessionHandoff?.(command); + return record + ? { id: command.id, ok: true, data: record } + : { id: command.id, ok: false, errorCode: 'runtime_command_failed', error: 'Session handoff is not supported by this runtime.' }; + } + if (!isHandoffVerification(command, command.site) || (session.handoff && command.site !== session.handoff.site)) { + return { id: command.id, ok: false, errorCode: 'invalid_request', error: 'Invalid Session handoff clear control.' }; + } + const record = await provider.clearSessionHandoff?.(command); + return record + ? { id: command.id, ok: true, data: record } + : { id: command.id, ok: false, errorCode: 'runtime_command_failed', error: 'Session handoff is not supported by this runtime.' }; } async function handleSessionLifecycle( @@ -256,7 +313,15 @@ export function createDaemonServer(provider: BrowserRuntimeProvider, opts: Daemo jsonResponse(res, 200, lifecycleResult); return; } - const resolvedBody = await resolveBrowserSession(provider, body); + const resolved = await resolveBrowserSession(provider, body); + const resolvedBody = resolved.command; + if (resolved.session) { + const paused = handoffPauseResult(resolvedBody, resolved.session); + if (paused) { + jsonResponse(res, 409, paused); + return; + } + } let leaseKey: string | undefined; let runId: string | undefined; if (isSessionLeaseCommand(resolvedBody)) { @@ -280,6 +345,14 @@ export function createDaemonServer(provider: BrowserRuntimeProvider, opts: Daemo return; } } + if (resolved.session) { + const handoffResult = await handleSessionHandoff(provider, resolvedBody, resolved.session); + if (handoffResult) { + if (leaseKey && runId) leases.heartbeat(leaseKey, runId); + jsonResponse(res, handoffResult.ok ? 200 : 400, handoffResult); + return; + } + } const commandPromise = provider.dispatch(resolvedBody).finally(() => { if (leaseKey && runId) leases.heartbeat(leaseKey, runId); pending.delete(body.id); diff --git a/src/execution.test.ts b/src/execution.test.ts index b9eb95ca..4bce5b16 100644 --- a/src/execution.test.ts +++ b/src/execution.test.ts @@ -4,17 +4,26 @@ import * as os from 'node:os'; import * as path from 'node:path'; import type { CliCommand } from './registry.js'; -const { mockReleaseSiteSessionLease, mockSetDaemonCommandTimeoutSeconds } = vi.hoisted(() => ({ +const { + mockClearSessionHandoff, + mockReleaseSiteSessionLease, + mockSetDaemonCommandTimeoutSeconds, + mockStartSessionHandoff, +} = vi.hoisted(() => ({ + mockClearSessionHandoff: vi.fn().mockResolvedValue(undefined), mockReleaseSiteSessionLease: vi.fn().mockResolvedValue(undefined), mockSetDaemonCommandTimeoutSeconds: vi.fn(), + mockStartSessionHandoff: vi.fn().mockResolvedValue({ id: 'session_a' }), })); vi.mock('./browser/daemon-client.js', async () => { const actual = await vi.importActual('./browser/daemon-client.js'); return { ...actual, + clearSessionHandoff: mockClearSessionHandoff, releaseSiteSessionLease: mockReleaseSiteSessionLease, setDaemonCommandTimeoutSeconds: mockSetDaemonCommandTimeoutSeconds, + startSessionHandoff: mockStartSessionHandoff, }; }); @@ -669,6 +678,83 @@ describe('executeCommand — non-browser timeout', () => { }); }); + describe('local authentication handoff', () => { + afterEach(() => { + mockClearSessionHandoff.mockReset().mockResolvedValue(undefined); + mockStartSessionHandoff.mockReset().mockResolvedValue({ id: 'session_a' }); + vi.restoreAllMocks(); + }); + + it('starts a Session handoff and returns its immutable verification command', async () => { + const mockPage = { closeWindow: vi.fn().mockResolvedValue(undefined) } as any; + vi.spyOn(capRouting, 'shouldUseBrowserSession').mockReturnValue(true); + vi.spyOn(runtime, 'browserSession').mockImplementation(async (_Factory, fn) => fn(mockPage)); + const cmd = cli({ + site: 'github', + name: 'login', + access: 'write', + description: 'test scoped handoff', + browser: true, + strategy: Strategy.PUBLIC, + siteSession: 'persistent', + func: async () => [{ + status: 'action_required', + logged_in: false, + site: 'github', + action: 'sign in', + verify_command: 'webcmd github whoami', + }], + }); + + const result = await executeCommand(cmd, {}, false, { + profile: 'work', + session: 'session_a', + }); + + expect(result).toEqual([{ + status: 'action_required', + logged_in: false, + site: 'github', + action: 'sign in', + verify_command: "webcmd --profile 'work' --session session_a github whoami", + }]); + expect(mockStartSessionHandoff).toHaveBeenCalledWith(expect.objectContaining({ + session: 'session_a', + site: 'github', + expiresAt: expect.any(String), + })); + }); + + it('clears the handoff only after successful internal auth verification', async () => { + const mockPage = { closeWindow: vi.fn().mockResolvedValue(undefined) } as any; + vi.spyOn(capRouting, 'shouldUseBrowserSession').mockReturnValue(true); + vi.spyOn(runtime, 'browserSession').mockImplementation(async (_Factory, fn) => fn(mockPage)); + const verified = cli({ + site: 'handoff-test', + name: 'whoami', + access: 'read', + description: 'test handoff verification', + browser: true, + strategy: Strategy.PUBLIC, + siteSession: 'persistent', + func: async () => [{ logged_in: true, site: 'handoff-test' }], + }) as CliCommand & { _authVerification?: true }; + verified._authVerification = true; + + await executeCommand(verified, {}, false, { profile: 'work', session: 'session_a' }); + + expect(mockClearSessionHandoff).toHaveBeenCalledWith(expect.objectContaining({ + session: 'session_a', + site: 'handoff-test', + })); + + mockClearSessionHandoff.mockClear(); + verified.func = async () => [{ logged_in: false, site: 'handoff-test' }]; + await executeCommand(verified, {}, false, { profile: 'work', session: 'session_a' }); + expect(mockClearSessionHandoff).not.toHaveBeenCalled(); + }); + }); + it('reuses a persistent site browser session and keeps the tab lease open', async () => { const closeWindow = vi.fn().mockResolvedValue(undefined); const mockPage = { closeWindow } as any; diff --git a/src/execution.ts b/src/execution.ts index de9bc1ab..e03e6607 100644 --- a/src/execution.ts +++ b/src/execution.ts @@ -28,7 +28,12 @@ import { adapterLoadError, ArgumentError, CommandExecutionError, TimeoutError, a import { shouldUseBrowserSession } from './capabilityRouting.js'; import { getBrowserFactory, browserSession, runWithTimeout, DEFAULT_BROWSER_COMMAND_TIMEOUT, type BrowserWindowMode } from './runtime.js'; import { profileRouteParams, resolveProfileSelection } from './browser/profile.js'; -import { releaseSiteSessionLease, setDaemonCommandTimeoutSeconds } from './browser/daemon-client.js'; +import { + clearSessionHandoff, + releaseSiteSessionLease, + setDaemonCommandTimeoutSeconds, + startSessionHandoff, +} from './browser/daemon-client.js'; import { emitHook, type HookContext } from './hooks.js'; import { log } from './logger.js'; import { isElectronApp } from './electron-apps.js'; @@ -42,6 +47,14 @@ const _loadedModules = new Map>(); /** Track mtime of loaded user adapter files for hot-reload in daemon mode. */ const _moduleMtimes = new Map(); const _userClisDir = `${os.homedir()}/.webcmd/clis/`; +const AUTH_HANDOFF_TTL_MS = 15 * 60 * 1000; + +const quoteCliArg = (value: string): string => `'${value.replaceAll("'", "'\\''")}'`; + +function firstResultRow(result: unknown): Record | undefined { + const row = Array.isArray(result) ? result[0] : undefined; + return row && typeof row === 'object' ? row as Record : undefined; +} async function finalizeRun(runId: string, release: boolean): Promise { clearDaemonRunContext(runId); @@ -54,12 +67,7 @@ function normalizeTraceMode(raw: unknown): TraceMode { throw new ArgumentError(`--trace must be one of: ${TRACE_MODES.join(', ')}. Received: "${String(raw)}"`); } -async function runCommand( - cmd: CliCommand, - page: IPage | null, - kwargs: CommandArgs, - debug: boolean, -): Promise { +async function loadCommand(cmd: CliCommand): Promise { const internal = cmd as InternalCliCommand; if (internal._lazy && internal._modulePath) { const modulePath = internal._modulePath; @@ -94,13 +102,18 @@ async function runCommand( } await _loadedModules.get(modulePath); - const updated = getRegistry().get(fullName(cmd)); - if (updated?.func) { - return runCommandFunc(updated, page, kwargs, debug); - } - if (updated?.pipeline) return executePipeline(page, updated.pipeline, { args: kwargs, debug }); + return getRegistry().get(fullName(cmd)) ?? cmd; } + return cmd; +} +async function runCommand( + cmd: CliCommand, + page: IPage | null, + kwargs: CommandArgs, + debug: boolean, +): Promise { + cmd = await loadCommand(cmd); if (cmd.func) return runCommandFunc(cmd, page, kwargs, debug); if (cmd.pipeline) return executePipeline(page, cmd.pipeline, { args: kwargs, debug }); throw new CommandExecutionError( @@ -189,6 +202,7 @@ export async function executeCommand( let result: unknown; try { + cmd = await loadCommand(cmd); if (shouldUseBrowserSession(cmd)) { const electron = isElectronApp(cmd.site); let cdpEndpoint: string | undefined; @@ -214,13 +228,14 @@ export async function executeCommand( const profileSelection = resolveProfileSelection(opts.profile); const profileRouting = profileRouteParams(profileSelection); const contextId = profileSelection?.contextId; - const internal = cmd as InternalCliCommand; + const internal = cmd as InternalCliCommand & { _authVerification?: true }; const siteSession = resolveSiteSession(cmd, opts.siteSession); const session = opts.session?.trim() || undefined; const keepTab = resolveKeepTab(siteSession, opts.keepTab); const windowMode = resolveBrowserWindowMode(opts.windowMode); const surface = 'adapter' as const; const canonicalCommand = fullName(cmd); + const authVerification = internal._authVerification === true; const runId = generateRunId(); let releaseRun = true; let deferRunFinalization = false; @@ -304,10 +319,29 @@ export async function executeCommand( const browserTimeout = userTimeoutSec !== null ? userTimeoutSec + RUNTIME_TIMEOUT_PADDING_SECONDS : DEFAULT_BROWSER_COMMAND_TIMEOUT; - const result = await runWithTimeout(adapterPromise, { + let result = await runWithTimeout(adapterPromise, { timeout: browserTimeout, label: canonicalCommand, }); + const firstRow = firstResultRow(result); + const handoffParams = { + session, + site: cmd.site, + ...profileRouting, + }; + if (cmd.name === 'login' && firstRow?.status === 'action_required') { + const handoff = await startSessionHandoff({ + ...handoffParams, + expiresAt: new Date(Date.now() + AUTH_HANDOFF_TTL_MS).toISOString(), + }); + const profileId = handoff.profileId ?? profileSelection?.contextId ?? 'default'; + result = [ + { ...firstRow, verify_command: `webcmd --profile ${quoteCliArg(profileId)} --session ${handoff.id} ${cmd.site} whoami` }, + ...(result as unknown[]).slice(1), + ]; + } else if (authVerification && firstRow?.logged_in === true) { + await clearSessionHandoff(handoffParams); + } observation?.record({ stream: 'action', name: 'command', diff --git a/src/plugin-runtime.test.ts b/src/plugin-runtime.test.ts index 6e8fcb1b..1472e7ef 100644 --- a/src/plugin-runtime.test.ts +++ b/src/plugin-runtime.test.ts @@ -174,6 +174,8 @@ describe('site auth command helper', () => { }); expect(getRegistry().get('auth-helper-registration/auth-status')) .toBe(getRegistry().get('auth-helper-registration/whoami')); + expect(getRegistry().get('auth-helper-registration/whoami')) + .toMatchObject({ _authVerification: true }); const login = getRegistry().get('auth-helper-registration/login')!; expect(login).toMatchObject({ access: 'write', diff --git a/src/plugin-runtime.ts b/src/plugin-runtime.ts index 27f0edd3..8d888578 100644 --- a/src/plugin-runtime.ts +++ b/src/plugin-runtime.ts @@ -1,7 +1,7 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from './errors.js'; -import { cli, Strategy, type CommandArgs, type CliOptions } from './registry-api.js'; +import { cli, Strategy, type CliCommand, type CommandArgs, type CliOptions } from './registry-api.js'; import type { IPage } from './types.js'; export function clampInt(raw: unknown, fallback: number, min: number, max: number): number { @@ -248,7 +248,7 @@ export function registerSiteAuthCommands(config: SiteAuthConfig): void { const refresh = config.refresh; if (config.registerWhoami !== false) { - cli({ + const whoami = cli({ site: config.site, name: 'whoami', access: 'read', @@ -270,7 +270,8 @@ export function registerSiteAuthCommands(config: SiteAuthConfig): void { : {}), }, func: async (page) => [await tryProbe(page)], - }); + }) as CliCommand & { _authVerification?: true }; + whoami._authVerification = true; } cli({ From b0ff3a2e65de5efbf713920361e76120193ce44b Mon Sep 17 00:00:00 2001 From: beubax Date: Wed, 12 Aug 2026 12:33:16 +0530 Subject: [PATCH 16/27] fix: block session close during auth handoff --- src/daemon/server.test.ts | 32 ++++++++++++++++++++++++++++++++ src/daemon/server.ts | 22 +++++++++++++++------- 2 files changed, 47 insertions(+), 7 deletions(-) diff --git a/src/daemon/server.test.ts b/src/daemon/server.test.ts index a95de651..2b60fcd6 100644 --- a/src/daemon/server.test.ts +++ b/src/daemon/server.test.ts @@ -243,6 +243,38 @@ describe('createDaemonServer', () => { expect(provider.commands).toEqual([]); }); + it('rejects Session close while its live human handoff owns the window', async () => { + const provider = new FakeProvider(); + provider.sessions.push({ + id: 'session_a', + profileId: 'profile_work', + kind: 'explicit', + createdAt: '2026-08-11T00:00:00.000Z', + updatedAt: '2026-08-11T00:00:00.000Z', + lastUsedAt: '2026-08-11T00:00:00.000Z', + handoff: { + site: 'github', + expiresAt: new Date(Date.now() + 60_000).toISOString(), + }, + }); + provider.activeSessions.add('session_a'); + const { baseUrl } = await start(provider); + + const close = await postCommand(baseUrl, { + id: 'close-handoff-session', + action: 'session-close', + contextId: 'profile_work', + session: 'session_a', + }); + + expect(close.status).toBe(409); + await expect(close.json()).resolves.toMatchObject({ + errorCode: 'SESSION_PAUSED_FOR_HUMAN_HANDOFF', + details: { sessionId: 'session_a', site: 'github' }, + }); + expect(provider.activeSessions).toContain('session_a'); + }); + it('accepts the maximum browser-run source envelope', async () => { const { provider, baseUrl } = await start(); const source = 'x'.repeat(256 * 1024); diff --git a/src/daemon/server.ts b/src/daemon/server.ts index 947ea075..2b9b8e53 100644 --- a/src/daemon/server.ts +++ b/src/daemon/server.ts @@ -59,10 +59,9 @@ function waitForCommandResult( }); } -const SESSION_LIFECYCLE_ACTIONS = new Set([ +const UNRESOLVED_SESSION_LIFECYCLE_ACTIONS = new Set([ 'session-create', 'session-list', - 'session-close', ]); function commandProfileId(provider: BrowserRuntimeProvider, command: BrowserRuntimeCommand): string | undefined { @@ -76,7 +75,7 @@ async function resolveBrowserSession( provider: BrowserRuntimeProvider, command: BrowserRuntimeCommand, ): Promise<{ command: BrowserRuntimeCommand; session?: BrowserSessionRecord }> { - if (command.action === 'lease-release' || command.action === 'run-cancel' || SESSION_LIFECYCLE_ACTIONS.has(command.action)) { + if (command.action === 'lease-release' || command.action === 'run-cancel' || UNRESOLVED_SESSION_LIFECYCLE_ACTIONS.has(command.action)) { return { command }; } let session: BrowserSessionRecord | undefined; @@ -308,10 +307,12 @@ export function createDaemonServer(provider: BrowserRuntimeProvider, opts: Daemo jsonResponse(res, 200, { id: body.id, ok: true, data: { released } }); return; } - const lifecycleResult = await handleSessionLifecycle(provider, body); - if (lifecycleResult) { - jsonResponse(res, 200, lifecycleResult); - return; + if (body.action !== 'session-close') { + const lifecycleResult = await handleSessionLifecycle(provider, body); + if (lifecycleResult) { + jsonResponse(res, 200, lifecycleResult); + return; + } } const resolved = await resolveBrowserSession(provider, body); const resolvedBody = resolved.command; @@ -322,6 +323,13 @@ export function createDaemonServer(provider: BrowserRuntimeProvider, opts: Daemo return; } } + if (resolvedBody.action === 'session-close') { + const lifecycleResult = await handleSessionLifecycle(provider, resolvedBody); + if (lifecycleResult) { + jsonResponse(res, 200, lifecycleResult); + return; + } + } let leaseKey: string | undefined; let runId: string | undefined; if (isSessionLeaseCommand(resolvedBody)) { From 4a27ff8ea9d73c30df8a40783cb08c9c978331c3 Mon Sep 17 00:00:00 2001 From: beubax Date: Wed, 12 Aug 2026 14:18:55 +0530 Subject: [PATCH 17/27] docs: explain session-based browser work --- README.md | 7 ++++++- docs/authentication-and-profiles.mdx | 13 ++++++++++--- docs/cli-reference.mdx | 11 +++++++++-- skills/webcmd-adapter-author/SKILL.md | 2 +- skills/webcmd-autofix/SKILL.md | 4 ++-- skills/webcmd-browser/SKILL.md | 6 +++++- skills/webcmd-sitemap-author/SKILL.md | 2 +- .../references/sitemap-schema.md | 4 ++-- skills/webcmd-usage/SKILL.md | 7 ++++++- src/skills.test.ts | 18 ++++++++++++++++++ 10 files changed, 60 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 4920f456..8acdf3df 100644 --- a/README.md +++ b/README.md @@ -40,11 +40,16 @@ Playwright-style program to an explicit browser session: ```bash webcmd session create -f json webcmd --session session_abc browser run --file explore.js -printf 'const page = await browser.currentPage(); return await page.title();' \ +printf 'return await page.title();' \ | webcmd --session session_abc browser run --stdin webcmd session close session_abc ``` +Profiles are cookie jars; Sessions are independent browser windows within a +profile, so parallel agents should create separate Sessions. Adapter commands +use an adapter-default Session unless `--session` intentionally routes them to +an explicit one. + ## Demo https://github.com/user-attachments/assets/04eceadc-d398-4303-984d-ae3197bfa664 diff --git a/docs/authentication-and-profiles.mdx b/docs/authentication-and-profiles.mdx index 5f32eb8a..48893be1 100644 --- a/docs/authentication-and-profiles.mdx +++ b/docs/authentication-and-profiles.mdx @@ -15,7 +15,10 @@ Use Webcmd with my `work` profile to complete this task on Acme Billing. If the ## Named Profiles -Profiles separate browser identities and their login state. Use a named profile when work and personal accounts, customers, or environments must stay separate. +Profiles are cookie jars and authentication scope. Sessions are browser +windows/workspaces within a profile; separate Sessions let multiple agents use +the same profile in parallel. Use a named profile when work and personal +accounts, customers, or environments must stay separate. ## Hosted Profiles @@ -58,7 +61,11 @@ Deletion permanently removes that hosted browser state and its saved sign-in sta ## When You Need to Sign In -When a profile needs interactive sign-in, complete it directly in the browser; never share credentials through chat. +When a profile needs interactive sign-in, complete it directly in the browser; +never share credentials through chat. The handoff belongs to the Session that +started it. After signing in, run the returned verification command verbatim; +it includes the Session selector when applicable. Webcmd blocks `session close` +while that Session has a live handoff. ## Credential Safety @@ -66,6 +73,6 @@ Credentials never belong in prompts or adapter code. Ask the agent to use an aut ## Local and Hosted Authentication -In local mode, `webcmd login` opens the foreground Webcmd browser and returns `action_required` immediately. Complete sign-in in that browser, tell the agent when you are done, and let it run `webcmd whoami` before retrying the original task. Never send credentials, OTPs, cookies, or recovery codes through chat. +In local mode, `webcmd login` opens the foreground Webcmd browser and returns `action_required` immediately. Complete sign-in in that browser, tell the agent when you are done, and let it run the returned verification command before retrying the original task. Never send credentials, OTPs, cookies, or recovery codes through chat. Hosted mode returns a Webcmd-owned live authentication view when interactive sign-in is required. Complete the sign-in there before the agent continues. diff --git a/docs/cli-reference.mdx b/docs/cli-reference.mdx index 86e5f795..d03c3525 100644 --- a/docs/cli-reference.mdx +++ b/docs/cli-reference.mdx @@ -59,8 +59,10 @@ The old `web read` command has been renamed to `web fetch-browser`. Create an opaque session before raw browser work. Profiles hold cookie/auth state; sessions are browser workspaces within that profile. Adapter commands -may omit `--session` and use their profile's default session, but raw browser -commands must always pass it: +may omit `--session` and use their profile's adapter-default session; pass +`--session ` only when intentionally routing an adapter into an +explicit session. Raw browser commands must always pass it. Retired positional +syntax such as `webcmd browser ...` is invalid: ```bash webcmd session create -f json @@ -72,6 +74,11 @@ webcmd session list webcmd session close session_abc ``` +Agents sharing a profile can work in parallel by creating separate Sessions. +An authentication handoff is scoped to the Session that started it: run the +returned verification command verbatim because it includes `--session` when +needed. `session close` is blocked while that Session has a live handoff. + Use `snapshot` for explicit page inspection. `act` is the default action-first mode, `tree` preserves fuller page structure, and `read` extracts readable article/content text. Exactly one of `--file ` or `--stdin` is required for `run`. The CLI reads files locally and diff --git a/skills/webcmd-adapter-author/SKILL.md b/skills/webcmd-adapter-author/SKILL.md index c6343d55..1b3eeecf 100644 --- a/skills/webcmd-adapter-author/SKILL.md +++ b/skills/webcmd-adapter-author/SKILL.md @@ -13,7 +13,7 @@ plus `webcmd doctor`, `webcmd browser init`, and `webcmd browser verify`. Browser-run programs are discovery evidence, not adapter source. -Browser-profile auth commands must reuse `registerSiteAuthCommands`. Keep only site-specific `verify` and `openLogin` logic in the adapter. The login row must return `action_required` and `verify_command` (normally `webcmd whoami`); after the user reports done, agents run that returned command and verification must succeed before retrying the original workflow. Credentials, MFA, and CAPTCHA always use human handoff: CAPTCHA stops automation until the user reports done and verification succeeds, and adapter code must not collect or type passwords or secrets. +Browser-profile auth commands must reuse `registerSiteAuthCommands`. Keep only site-specific `verify` and `openLogin` logic in the adapter. The login row must return `action_required` and `verify_command`; after the user reports done, agents run that returned command verbatim (it includes `--session` when applicable), and verification must succeed before retrying the original workflow. Credentials, MFA, and CAPTCHA always use human handoff: CAPTCHA stops automation until the user reports done and verification succeeds, and adapter code must not collect or type passwords or secrets. Commands whose primary operation searches or discovers matching items from a corpus must set `tags: ['search']`. Add short `keywords` only for non-obvious intent synonyms; do not infer tags from command names alone when authoring new adapters. diff --git a/skills/webcmd-autofix/SKILL.md b/skills/webcmd-autofix/SKILL.md index c1e10bb9..50d84b75 100644 --- a/skills/webcmd-autofix/SKILL.md +++ b/skills/webcmd-autofix/SKILL.md @@ -12,8 +12,8 @@ When a `webcmd` command fails because a website changed its DOM, API, or respons Hard stops before any code change: -- **Human-action handoff:** if a failure returns `handoff.status === action_required`, stop before trace collection or AutoFix. Give the user `handoff.action` and any `Webcmd browser:` or `handoff.viewUrl` link, then wait. Never request or enter credentials, passwords, or CAPTCHA answers. After the user reports done, run `handoff.verifyCommand` when present; verification must succeed before retrying. Without a verifier, inspect fresh browser state and verify the intended post-action state before any retry, especially for write commands. -- **`AUTH_REQUIRED`** (exit code 77): if a site login command exists, run `webcmd login`, give its `action_required` instructions and any returned `action_url` or `view_url` to the user, and wait. Run the returned `verify_command` (normally `webcmd whoami`); verification must succeed before retrying the original command. If no site login command exists, stop browser writes, hand the visible browser to the user, and wait. After they report done, take fresh browser state and use an available identity check or verify the intended post-action state before retrying. Their report alone is not verification. Never request, type, echo, store, or automate passwords, OTPs, recovery codes, cookies, or session secrets. +- **Human-action handoff:** if a failure returns `handoff.status === action_required`, stop before trace collection or AutoFix. The handoff is scoped to its Session, which cannot be closed while the handoff is live. Give the user `handoff.action` and any `Webcmd browser:` or `handoff.viewUrl` link, then wait. Never request or enter credentials, passwords, or CAPTCHA answers. After the user reports done, run the returned `handoff.verifyCommand` verbatim; it includes `--session` when applicable, and verification must succeed before retrying. Without a verifier, inspect fresh browser state and verify the intended post-action state before any retry, especially for write commands. +- **`AUTH_REQUIRED`** (exit code 77): if a site login command exists, run `webcmd login`, give its `action_required` instructions and any returned `action_url` or `view_url` to the user, and wait. Run the returned `verify_command` verbatim; it includes `--session` when applicable, and verification must succeed before retrying the original command. If no site login command exists, stop browser writes, hand the visible browser to the user, and wait. After they report done, take fresh browser state and use an available identity check or verify the intended post-action state before retrying. Their report alone is not verification. Never request, type, echo, store, or automate passwords, OTPs, recovery codes, cookies, or session secrets. - **`BROWSER_CONNECT`** (exit code 69): stop. Tell the user to run `webcmd doctor`. - **CAPTCHA / raw-browser user takeover:** stop automation. Follow the human-action handoff above when one is returned; otherwise let the user act in the visible browser. Verification must succeed before retrying. With no verifier, take fresh browser state and verify the intended post-action state before any retry. The user's report alone is not verification. CAPTCHA is not an adapter issue. - **Rate limiting / IP block:** stop. This is not an adapter issue. diff --git a/skills/webcmd-browser/SKILL.md b/skills/webcmd-browser/SKILL.md index 600f3048..59085830 100644 --- a/skills/webcmd-browser/SKILL.md +++ b/skills/webcmd-browser/SKILL.md @@ -33,7 +33,7 @@ Until `doctor` is green, browser commands may fail. Registry and plugin discover - Create an opaque browser session before raw browser work: `webcmd session create -f json`. - Raw `webcmd browser *` commands require that ID at the root: `webcmd --session browser ...`; positional `webcmd browser ...` is retired. - Profiles are cookie jars and auth scope; sessions are browser workspaces/windows within a profile. Parallel agents use separate sessions. -- `webcmd session list` shows sessions and their handoff/runtime state; close finished work with `webcmd session close `. +- `webcmd session list` shows sessions and their handoff/runtime state; close finished work with `webcmd session close `. Close is blocked while that Session has a live handoff. - Browser state in the bound page persists between calls, but each `run` gets a fresh JavaScript scope. - `webcmd --session browser tabs` lists existing pages without creating a new one. - `webcmd --session browser bind --page ` explicitly attaches the session to an existing page. @@ -120,6 +120,10 @@ If Webcmd reports sitemap context, load `webcmd-browser-sitemap` before continui If a failure returns `handoff.status === action_required`, stop browser writes. Give the user `handoff.action` and any `Webcmd browser:` or `handoff.viewUrl` link, then wait. After the user reports done, run `handoff.verifyCommand` when present; verification must succeed before retrying. +The handoff is scoped to the Session that started it. Run the returned +`verify_command` or `handoff.verifyCommand` verbatim; it includes `--session` +when applicable. Do not close that Session during the live handoff. + 1. On a clear login redirect or auth wall, stop browser writes. 2. If the site exposes a login command, run `webcmd login`. 3. `already_logged_in` is verified; continue. diff --git a/skills/webcmd-sitemap-author/SKILL.md b/skills/webcmd-sitemap-author/SKILL.md index 838a030a..c5e4e892 100644 --- a/skills/webcmd-sitemap-author/SKILL.md +++ b/skills/webcmd-sitemap-author/SKILL.md @@ -63,7 +63,7 @@ do: post: fail: | recover: ; adapter_health_update: -> suspect -evidence: webcmd browser or trace: +evidence: webcmd --session browser or trace: ``` Use this compact form by default. Use the longer Markdown form from `references/sitemap-schema.md` only when an action genuinely needs longer explanation. `verified_at` and `source` are inherited from file front matter; do not repeat them per action. diff --git a/skills/webcmd-sitemap-author/references/sitemap-schema.md b/skills/webcmd-sitemap-author/references/sitemap-schema.md index 4ce438ae..e6e474c0 100644 --- a/skills/webcmd-sitemap-author/references/sitemap-schema.md +++ b/skills/webcmd-sitemap-author/references/sitemap-schema.md @@ -175,7 +175,7 @@ do: post: fail: | recover: ; adapter_health_update: -> suspect -evidence: webcmd browser or trace: +evidence: webcmd --session browser or trace: ## Linked APIs @@ -390,7 +390,7 @@ do: post: fail: | recover: ; adapter_health_update: -> suspect -evidence: webcmd browser or trace: +evidence: webcmd --session browser or trace: ``` Field rules: diff --git a/skills/webcmd-usage/SKILL.md b/skills/webcmd-usage/SKILL.md index ee66485c..c567bebe 100644 --- a/skills/webcmd-usage/SKILL.md +++ b/skills/webcmd-usage/SKILL.md @@ -69,6 +69,7 @@ webcmd session list webcmd session close session_abc ``` +`webcmd session close ` is blocked while that Session has a live human handoff. Adapter commands may omit `--session` and use the selected profile's adapter-default session. Pass `--session ` to route one into an explicit session. Raw `webcmd browser` commands never omit it; retired `webcmd browser ...` syntax is invalid. ## Prerequisites By Strategy @@ -161,9 +162,13 @@ The error envelope includes a `trace` block pointing at `summary.md`. Patch only If a failure returns `handoff.status === action_required`, stop before AutoFix. Give the user `handoff.action` and any `Webcmd browser:` or `handoff.viewUrl` link, then wait. After the user reports done, run `handoff.verifyCommand` when present; verification must succeed before retrying. +Human handoff is scoped to the Session that started it. Run the returned +`verify_command` or `handoff.verifyCommand` verbatim; it includes `--session` +when applicable. Do not close that Session during the live handoff. + `AUTH_REQUIRED` is not an adapter failure. Run `webcmd login`: `already_logged_in` is verified; `in_progress` means no current user action, so do not ask the user or wait for confirmation, and do not poll; `action_required` is a hard stop. For `action_required`, give the user its instructions and any returned `action_url` or `view_url`, then wait. If Webcmd returned no URL, use the current visible browser. -Run the returned `verify_command` (normally `webcmd whoami`) or `handoff.verifyCommand` only after the user reports done; verification must succeed before retrying. Without a verifier, take fresh browser state and verify the intended post-action state before any retry, especially for write commands. Use `webcmd auth refresh` only when an explicit auth-state refresh is needed. Their report alone is not verification. Never request, type, echo, store, or automate passwords, OTPs, recovery codes, cookies, session secrets, or CAPTCHA answers; CAPTCHA stops automation and follows the same verification rule. +Run the returned `verify_command` or `handoff.verifyCommand` only after the user reports done; verification must succeed before retrying. Without a verifier, take fresh browser state and verify the intended post-action state before any retry, especially for write commands. Use `webcmd auth refresh` only when an explicit auth-state refresh is needed. Their report alone is not verification. Never request, type, echo, store, or automate passwords, OTPs, recovery codes, cookies, session secrets, or CAPTCHA answers; CAPTCHA stops automation and follows the same verification rule. ## Report A Webcmd Defect diff --git a/src/skills.test.ts b/src/skills.test.ts index 3beb4bee..103734f6 100644 --- a/src/skills.test.ts +++ b/src/skills.test.ts @@ -219,6 +219,24 @@ describe('webcmd skills content', () => { expect(siteReconReference).not.toMatch(/webcmd browser \S+ (?:open|state|click|type|select|find|extract|network|wait|eval)/i); }); + it('keeps raw browser and handoff work scoped to explicit Sessions', () => { + const usage = bundledSkill('webcmd-usage'); + const browser = bundledSkill('webcmd-browser'); + const autofix = bundledSkill('webcmd-autofix'); + + expect(usage).toContain('webcmd session create -f json'); + expect(usage).toContain('webcmd --session session_abc browser'); + expect(usage).toMatch(/Adapter commands may omit `--session`[\s\S]{0,200}adapter-default session/i); + expect(usage).toMatch(/retired `webcmd browser \.\.\.` syntax is invalid/i); + expect(browser).toMatch(/Profiles are cookie jars[\s\S]{0,180}sessions are browser workspaces\/windows/i); + expect(browser).toMatch(/Parallel agents use separate sessions/i); + for (const skill of [usage, browser, autofix]) { + expect(skill).toMatch(/handoff is scoped to (?:its|the) Session/i); + expect(skill).toMatch(/(?:verify_command|handoff\.verifyCommand)[\s\S]{0,200}verbatim[\s\S]{0,120}`--session`/i); + expect(skill).toMatch(/(?:cannot be closed|close is blocked|do not close)[\s\S]{0,100}handoff|handoff[\s\S]{0,100}(?:cannot be closed|close is blocked|do not close)/i); + } + }); + it('keeps browser behavioral policy while pruning removed command instructions', () => { const browser = bundledSkill('webcmd-browser'); From 06923eeb684f9d24e69a5c07a7320890e17e2ab1 Mon Sep 17 00:00:00 2001 From: beubax Date: Wed, 12 Aug 2026 14:23:42 +0530 Subject: [PATCH 18/27] fix: protect session leases during local concurrency --- src/browser/runtime/local-cloak/actions.ts | 5 ++ .../runtime/local-cloak/provider.test.ts | 89 +++++++++++++++++++ src/browser/runtime/local-cloak/provider.ts | 12 ++- .../runtime/local-cloak/session-manager.ts | 25 +++++- src/cli.test.ts | 10 +++ src/cli.ts | 6 +- src/daemon/server.test.ts | 39 ++++++++ src/daemon/server.ts | 16 ++++ 8 files changed, 195 insertions(+), 7 deletions(-) diff --git a/src/browser/runtime/local-cloak/actions.ts b/src/browser/runtime/local-cloak/actions.ts index 5cfaf45a..b7abc8e2 100644 --- a/src/browser/runtime/local-cloak/actions.ts +++ b/src/browser/runtime/local-cloak/actions.ts @@ -80,6 +80,7 @@ async function resolveLease(manager: CloakSessionManager, command: BrowserRuntim session: command.session, surface: command.surface, siteSession: command.siteSession, + sessionKind: command.sessionKind, sessionId: command.sessionId, adapterSite: command.adapterSite, runId: command.runId, @@ -235,6 +236,7 @@ export async function dispatchCloakAction(manager: CloakSessionManager, command: sessionId: command.sessionId, surface: command.surface, siteSession: command.siteSession, + sessionKind: command.sessionKind, adapterSite: command.adapterSite, runId: command.runId, idleTimeout: command.idleTimeout, @@ -340,6 +342,7 @@ export async function dispatchCloakAction(manager: CloakSessionManager, command: session: command.session, surface: command.surface, siteSession: command.siteSession, + sessionKind: command.sessionKind, sessionId: command.sessionId, adapterSite: command.adapterSite, runId: command.runId, @@ -363,6 +366,7 @@ export async function dispatchCloakAction(manager: CloakSessionManager, command: session: command.session, surface: command.surface, siteSession: command.siteSession, + sessionKind: command.sessionKind, sessionId: command.sessionId, adapterSite: command.adapterSite, runId: command.runId, @@ -459,6 +463,7 @@ export async function dispatchCloakAction(manager: CloakSessionManager, command: session: command.session, surface: command.surface, siteSession: command.siteSession, + sessionKind: command.sessionKind, sessionId: command.sessionId, adapterSite: command.adapterSite, runId: command.runId, diff --git a/src/browser/runtime/local-cloak/provider.test.ts b/src/browser/runtime/local-cloak/provider.test.ts index bad1b6e5..aed6eb48 100644 --- a/src/browser/runtime/local-cloak/provider.test.ts +++ b/src/browser/runtime/local-cloak/provider.test.ts @@ -343,6 +343,95 @@ describe('LocalCloakRuntimeProvider', () => { expect(page.evaluate).toHaveBeenCalledTimes(1); }); + it('partitions the local queue by adapter site only for adapter-default Sessions', () => { + const { provider } = makeProviderWithFakePage(); + const queueKey = (provider as unknown as { + commandQueueKey(command: Parameters[0]): string; + }).commandQueueKey.bind(provider); + + expect(queueKey({ + id: 'github', + action: 'exec', + surface: 'adapter', + session: 'session_default', + sessionKind: 'adapter-default', + adapterSite: 'github', + profileId: 'default', + })).not.toBe(queueKey({ + id: 'linkedin', + action: 'exec', + surface: 'adapter', + session: 'session_default', + sessionKind: 'adapter-default', + adapterSite: 'linkedin', + profileId: 'default', + })); + expect(queueKey({ + id: 'github-explicit', + action: 'exec', + surface: 'adapter', + session: 'session_default', + sessionKind: 'explicit', + adapterSite: 'github', + profileId: 'default', + })).toBe(queueKey({ + id: 'linkedin-explicit', + action: 'exec', + surface: 'adapter', + session: 'session_default', + sessionKind: 'explicit', + adapterSite: 'linkedin', + profileId: 'default', + })); + }); + + it('keeps adapter-default page-scoped queue keys partitioned by site', async () => { + const { provider } = makeProviderWithFakePage(); + const queueKey = (provider as unknown as { + commandQueueKey(command: Parameters[0]): string; + }).commandQueueKey.bind(provider); + const github = await provider.dispatch({ + id: 'github-nav', + action: 'navigate', + surface: 'adapter', + session: 'session_default', + sessionId: 'session_default', + sessionKind: 'adapter-default', + siteSession: 'persistent', + adapterSite: 'github', + profileId: 'default', + url: 'https://github.example/', + }); + const linkedin = await provider.dispatch({ + id: 'linkedin-nav', + action: 'navigate', + surface: 'adapter', + session: 'session_default', + sessionId: 'session_default', + sessionKind: 'adapter-default', + siteSession: 'persistent', + adapterSite: 'linkedin', + profileId: 'default', + url: 'https://linkedin.example/', + }); + + expect(queueKey({ + id: 'github-followup', + action: 'exec', + surface: 'adapter', + session: 'session_default', + page: github.page, + profileId: 'default', + })).not.toBe(queueKey({ + id: 'linkedin-followup', + action: 'exec', + surface: 'adapter', + session: 'session_default', + page: linkedin.page, + profileId: 'default', + })); + }); + it('serializes commands by the resolved page lease when explicit page metadata differs', async () => { const { provider, page } = makeProviderWithFakePage(); runBrowserProgram.mockImplementationOnce(async () => { diff --git a/src/browser/runtime/local-cloak/provider.ts b/src/browser/runtime/local-cloak/provider.ts index a62ced12..d716f89a 100644 --- a/src/browser/runtime/local-cloak/provider.ts +++ b/src/browser/runtime/local-cloak/provider.ts @@ -116,7 +116,12 @@ export class LocalCloakRuntimeProvider implements BrowserRuntimeProvider { private commandQueueKey(command: BrowserRuntimeCommand): string { if (command.page) { const owner = this.manager.pageOwner(command.page); - if (owner) return `session\u0000${owner.profileId}\u0000${owner.surface}\u0000${owner.session}`; + if (owner) { + const adapterDefaultSite = owner.surface === 'adapter' && owner.sessionKind === 'adapter-default' + ? owner.adapterSite?.trim() + : undefined; + return `session\u0000${owner.profileId}\u0000${owner.surface}\u0000${owner.session}${adapterDefaultSite ? `\u0000${adapterDefaultSite}` : ''}`; + } } let profileId: string; @@ -128,6 +133,9 @@ export class LocalCloakRuntimeProvider implements BrowserRuntimeProvider { ?? command.preferredContextId ?? 'default'; } - return `session\u0000${profileId.trim() || 'default'}\u0000${command.surface ?? 'browser'}\u0000${command.session ?? ''}`; + const adapterDefaultSite = command.surface === 'adapter' && command.sessionKind === 'adapter-default' + ? command.adapterSite?.trim() + : undefined; + return `session\u0000${profileId.trim() || 'default'}\u0000${command.surface ?? 'browser'}\u0000${command.session ?? ''}${adapterDefaultSite ? `\u0000${adapterDefaultSite}` : ''}`; } } diff --git a/src/browser/runtime/local-cloak/session-manager.ts b/src/browser/runtime/local-cloak/session-manager.ts index da05976a..0897f2ce 100644 --- a/src/browser/runtime/local-cloak/session-manager.ts +++ b/src/browser/runtime/local-cloak/session-manager.ts @@ -46,6 +46,7 @@ export interface SessionKeyInput { session?: string; surface?: BrowserSurface; siteSession?: SiteSessionMode; + sessionKind?: 'explicit' | 'adapter-default'; sessionId?: string; adapterSite?: string; runId?: string; @@ -64,6 +65,8 @@ type PageEntry = { session: string; surface: BrowserSurface; siteSession?: SiteSessionMode; + sessionKind?: 'explicit' | 'adapter-default'; + adapterSite?: string; idleTimeout?: number; idleTimer?: ReturnType; }; @@ -277,6 +280,8 @@ export class CloakSessionManager { session, surface, siteSession: input.siteSession, + sessionKind: input.sessionKind, + adapterSite: input.adapterSite, idleTimeout: input.idleTimeout, }); if (existing && freshPage && existing !== entry) await this.removeEntry(acquired.runtime, sessionRuntime, existing, true); @@ -325,11 +330,17 @@ export class CloakSessionManager { return null; } - pageOwner(pageId: string): { profileId: string; session: string; surface: BrowserSurface } | null { + pageOwner(pageId: string): { profileId: string; session: string; surface: BrowserSurface; sessionKind?: 'explicit' | 'adapter-default'; adapterSite?: string } | null { for (const [profileId, runtime] of this.profiles.entries()) { for (const entry of runtime.targetPages.values()) { if (entry.pageId === pageId && !pageIsClosed(entry.page)) { - return { profileId, session: entry.session, surface: entry.surface }; + return { + profileId, + session: entry.session, + surface: entry.surface, + sessionKind: entry.sessionKind, + adapterSite: entry.adapterSite, + }; } } } @@ -424,6 +435,8 @@ export class CloakSessionManager { session, surface, siteSession: input.siteSession, + sessionKind: input.sessionKind, + adapterSite: input.adapterSite, idleTimeout: input.idleTimeout, }); const leaseKey = entry.leaseKey; @@ -990,6 +1003,8 @@ export class CloakSessionManager { session: openerEntry.session, surface: openerEntry.surface, siteSession: openerEntry.siteSession, + sessionKind: openerEntry.sessionKind, + adapterSite: openerEntry.adapterSite, idleTimeout: openerEntry.idleTimeout, }); } @@ -998,7 +1013,7 @@ export class CloakSessionManager { runtime: ProfileRuntime, session: SessionRuntime, page: PlaywrightPage, - input: Pick & { leaseKey?: string }, + input: Pick & { leaseKey?: string }, ): Promise { const targetId = await this.targetIdForPage(runtime, page); this.pendingTargetPages.get(runtime)?.delete(targetId); @@ -1026,6 +1041,8 @@ export class CloakSessionManager { session: input.session, surface: input.surface, siteSession: input.siteSession, + sessionKind: input.sessionKind, + adapterSite: input.adapterSite, idleTimeout: input.idleTimeout, }; runtime.targetPages.set(targetId, entry); @@ -1039,6 +1056,8 @@ export class CloakSessionManager { entry.session = input.session; entry.surface = input.surface; entry.siteSession = input.siteSession; + entry.sessionKind = input.sessionKind; + entry.adapterSite = input.adapterSite; entry.idleTimeout = input.idleTimeout; entry.leaseKey = input.leaseKey ?? (entry.leaseKey.startsWith('unowned\u0000') ? `page\u0000${entry.pageId}` : entry.leaseKey); } diff --git a/src/cli.test.ts b/src/cli.test.ts index c37b5026..8b921916 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -1674,6 +1674,16 @@ describe('browser raw session commands', () => { expect(getDaemonRunContext()).toBeUndefined(); }); + it('keeps the raw browser lease when the daemon outcome is unknown', async () => { + mockSendCommand.mockRejectedValue(new BrowserCommandError('Result unknown', 'command_result_unknown')); + const program = createProgram('', ''); + + await program.parseAsync(['node', 'webcmd', '--session', 'session_test', 'browser', 'snapshot']); + + expect(process.exitCode).toBe(1); + expect(mockReleaseSiteSessionLease).not.toHaveBeenCalled(); + }); + it('reads program files for run and rejects mutually exclusive input', async () => { const sourcePath = path.join(os.tmpdir(), `webcmd-run-${Date.now()}.js`); fs.writeFileSync(sourcePath, 'return 42;', 'utf8'); diff --git a/src/cli.ts b/src/cli.ts index e5acc30d..eb30923a 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -54,7 +54,7 @@ import { loadBrowserRunSource } from './browser/run/input.js'; import { BrowserRunError } from './browser/run/types.js'; import { classifyCommandOrigin, formatCommandOrigin } from './command-origin.js'; import { readOverrideRecords, removeOverrideRecords } from './override-provenance.js'; -import { clearDaemonRunContext, generateRunId, runWithDaemonRunContext } from './session-lease.js'; +import { clearDaemonRunContext, generateRunId, isUnknownOutcomeError, runWithDaemonRunContext } from './session-lease.js'; const CLI_FILE = fileURLToPath(import.meta.url); const FOLLOW_POLL_MS = 1_000; @@ -1087,12 +1087,14 @@ cli({ return async (opts: Record, command: Command) => { const runId = generateRunId(); const commandName = `browser/${command.name()}`; + let releaseRun = true; try { const session = getBrowserSession(command); const routing = profileRouteParams(getBrowserProfileSelection(command)); const result = await runWithDaemonRunContext({ runId, command: commandName }, () => fn(session, routing, opts)); console.log(JSON.stringify(result, null, 2)); } catch (error) { + if (isUnknownOutcomeError(error)) releaseRun = false; if (error instanceof BrowserCommandError && error.code) { console.log(JSON.stringify({ error: { @@ -1108,7 +1110,7 @@ cli({ process.exitCode = error instanceof CliError ? error.exitCode : EXIT_CODES.GENERIC_ERROR; } finally { clearDaemonRunContext(runId); - await releaseSiteSessionLease(runId); + if (releaseRun) await releaseSiteSessionLease(runId); } }; } diff --git a/src/daemon/server.test.ts b/src/daemon/server.test.ts index 2b60fcd6..61cc9519 100644 --- a/src/daemon/server.test.ts +++ b/src/daemon/server.test.ts @@ -275,6 +275,45 @@ describe('createDaemonServer', () => { expect(provider.activeSessions).toContain('session_a'); }); + it('rejects Session close while work is active in that Session', async () => { + let settle!: () => void; + const provider = new FakeProvider(); + provider.activeSessions.add('session_a'); + provider.dispatchImpl = (command) => new Promise((resolve) => { + settle = () => resolve({ id: command.id, ok: true, data: 'done' }); + }); + const { baseUrl } = await start(provider); + + const active = postCommand(baseUrl, { + id: 'active-work', + action: 'exec', + surface: 'browser', + session: 'session_a', + runId: 'run_100_1_1', + command: 'browser/run', + }); + try { + await vi.waitFor(() => expect(provider.commands).toHaveLength(1)); + const close = await postCommand(baseUrl, { + id: 'close-active-session', + action: 'session-close', + contextId: 'default', + session: 'session_a', + }); + + expect(close.status).toBe(409); + await expect(close.json()).resolves.toMatchObject({ + ok: false, + code: 'session_busy', + holder: { command: 'browser/run' }, + }); + expect(provider.activeSessions).toContain('session_a'); + } finally { + settle(); + await active.catch(() => undefined); + } + }); + it('accepts the maximum browser-run source envelope', async () => { const { provider, baseUrl } = await start(); const source = 'x'.repeat(256 * 1024); diff --git a/src/daemon/server.ts b/src/daemon/server.ts index 2b9b8e53..ad6c025e 100644 --- a/src/daemon/server.ts +++ b/src/daemon/server.ts @@ -316,6 +316,16 @@ export function createDaemonServer(provider: BrowserRuntimeProvider, opts: Daemo } const resolved = await resolveBrowserSession(provider, body); const resolvedBody = resolved.command; + const activeSessionHolder = (sessionKey: string) => { + const holder = leases.list(hasPendingWork).find((lease) => ( + lease.key === sessionKey + || lease.key.startsWith(`${sessionKey}␟`) + || sessionKey.startsWith(`${lease.key}␟`) + )); + if (!holder) return null; + const { key: _key, runId: _runId, ...publicHolder } = holder; + return publicHolder; + }; if (resolved.session) { const paused = handoffPauseResult(resolvedBody, resolved.session); if (paused) { @@ -324,6 +334,12 @@ export function createDaemonServer(provider: BrowserRuntimeProvider, opts: Daemo } } if (resolvedBody.action === 'session-close') { + const profileId = commandProfileId(provider, resolvedBody) ?? 'default'; + const holder = activeSessionHolder(getSessionLeaseKey(profileId, resolvedBody.sessionId!)); + if (holder) { + jsonResponse(res, 409, { ok: false, code: 'session_busy', holder }); + return; + } const lifecycleResult = await handleSessionLifecycle(provider, resolvedBody); if (lifecycleResult) { jsonResponse(res, 200, lifecycleResult); From 285475b54754eff87e440fae90a3a7987f25dc5d Mon Sep 17 00:00:00 2001 From: beubax Date: Wed, 12 Aug 2026 14:27:28 +0530 Subject: [PATCH 19/27] chore: release webcmd v0.6.1 --- .claude-plugin/plugin.json | 2 +- .codex-plugin/plugin.json | 2 +- .github/workflows/release.yml | 4 ++++ .release-please-manifest.json | 2 +- package-lock.json | 4 ++-- package.json | 2 +- plugins/linkedin/package.json | 2 +- plugins/linkedin/webcmd-plugin.json | 2 +- src/browser/runtime/local-cloak/provider.test.ts | 2 +- src/browser/runtime/local-cloak/session-manager.test.ts | 8 ++++++++ webcmd-plugin.json | 2 +- 11 files changed, 22 insertions(+), 10 deletions(-) diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index abc91a4e..a38ffc1f 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "webcmd", - "version": "0.6.0", + "version": "0.6.1", "description": "Turn websites, browser sessions, desktop apps, and local tools into deterministic CLI surfaces for humans and AI agents.", "author": { "name": "AgentRHQ", diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 2933a858..2dcf0b8d 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "webcmd", - "version": "0.6.0", + "version": "0.6.1", "description": "Turn websites, browser sessions, desktop apps, and local tools into deterministic CLI surfaces for humans and AI agents.", "author": { "name": "AgentRHQ", diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index de2e6af9..25138b49 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -56,6 +56,10 @@ jobs: if: ${{ steps.release.outputs.release_created || inputs.publish_tag != '' }} run: npm ci + - name: Install Playwright Chromium + if: ${{ steps.release.outputs.release_created || inputs.publish_tag != '' }} + run: npx playwright-core install chromium --with-deps + - name: Verify existing release tag if: ${{ inputs.publish_tag != '' }} run: test "${{ inputs.publish_tag }}" = "webcmd-v$(node -p "require('./package.json').version")" diff --git a/.release-please-manifest.json b/.release-please-manifest.json index b5b966fb..f4a3a7f2 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.6.0" + ".": "0.6.1" } diff --git a/package-lock.json b/package-lock.json index 4e08a3b2..16a0b75d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@agentrhq/webcmd", - "version": "0.6.0", + "version": "0.6.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@agentrhq/webcmd", - "version": "0.6.0", + "version": "0.6.1", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { diff --git a/package.json b/package.json index f9ea6fb8..5274b2e0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@agentrhq/webcmd", - "version": "0.6.0", + "version": "0.6.1", "description": "Turn websites, browser sessions, desktop apps, and local tools into deterministic CLI surfaces for humans and AI agents.", "engines": { "node": ">=20.6.0" diff --git a/plugins/linkedin/package.json b/plugins/linkedin/package.json index 49c55c9a..a29edc3b 100644 --- a/plugins/linkedin/package.json +++ b/plugins/linkedin/package.json @@ -4,6 +4,6 @@ "type": "module", "description": "LinkedIn profile, network, messaging, job, and Sales Navigator commands for WebCMD", "peerDependencies": { - "@agentrhq/webcmd": ">=0.6.0" + "@agentrhq/webcmd": ">=0.6.1" } } diff --git a/plugins/linkedin/webcmd-plugin.json b/plugins/linkedin/webcmd-plugin.json index 22cc0dbc..6ac56033 100644 --- a/plugins/linkedin/webcmd-plugin.json +++ b/plugins/linkedin/webcmd-plugin.json @@ -2,7 +2,7 @@ "name": "linkedin", "version": "0.1.0", "description": "LinkedIn profile, network, messaging, job, and Sales Navigator commands for WebCMD", - "webcmd": ">=0.6.0", + "webcmd": ">=0.6.1", "author": { "name": "WebCMD Agent", "handle": "agentrhq" diff --git a/src/browser/runtime/local-cloak/provider.test.ts b/src/browser/runtime/local-cloak/provider.test.ts index aed6eb48..d99c5292 100644 --- a/src/browser/runtime/local-cloak/provider.test.ts +++ b/src/browser/runtime/local-cloak/provider.test.ts @@ -726,7 +726,7 @@ describe('LocalCloakRuntimeProvider', () => { await expect(provider.dispatch({ id: 'close', action: 'tabs', op: 'close', session: 'work', surface: 'browser', page: created.page, profileId: 'default' })) .resolves.toMatchObject({ id: 'close', ok: true, data: { closed: created.page } }); - expect(pages[0].close).toHaveBeenCalled(); + expect(pages[0].close.mock.calls.length + pages[0].goto.mock.calls.filter(([url]) => url === 'about:blank').length).toBeGreaterThan(0); }); it('does not bring selected tabs to front in background window mode', async () => { diff --git a/src/browser/runtime/local-cloak/session-manager.test.ts b/src/browser/runtime/local-cloak/session-manager.test.ts index 9582ebf3..193907d5 100644 --- a/src/browser/runtime/local-cloak/session-manager.test.ts +++ b/src/browser/runtime/local-cloak/session-manager.test.ts @@ -170,6 +170,7 @@ describe('CloakSessionManager', () => { const launched = fakeContext(); const manager = new CloakSessionManager({ baseDir: '/tmp/webcmd-test', + platform: 'darwin', launchPersistentContext: vi.fn().mockResolvedValue(launched.context), }); @@ -193,6 +194,7 @@ describe('CloakSessionManager', () => { }); const manager = new CloakSessionManager({ baseDir: '/tmp/webcmd-test', + platform: 'darwin', launchPersistentContext: vi.fn().mockResolvedValue(launched.context), }); @@ -207,6 +209,7 @@ describe('CloakSessionManager', () => { const launched = fakeContext(); const manager = new CloakSessionManager({ baseDir: '/tmp/webcmd-test', + platform: 'darwin', launchPersistentContext: vi.fn().mockResolvedValue(launched.context), }); const key = { profileId: 'default', session: 'session_a', sessionId: 'session_a', surface: 'browser' as const }; @@ -240,6 +243,7 @@ describe('CloakSessionManager', () => { )); const manager = new CloakSessionManager({ baseDir: '/tmp/webcmd-test', + platform: 'darwin', launchPersistentContext: vi.fn().mockResolvedValue(launched.context), }); const key = { profileId: 'default', session: 'session_a', sessionId: 'session_a', surface: 'browser' as const }; @@ -880,6 +884,7 @@ describe('CloakSessionManager', () => { const launched = fakeContext(); const manager = new CloakSessionManager({ baseDir: '/tmp/webcmd-test', + platform: 'darwin', launchPersistentContext: vi.fn().mockResolvedValue(launched.context), }); const key = { profileId: 'default', session: 'session_default', sessionId: 'session_default', surface: 'adapter' as const, siteSession: 'ephemeral' as const, adapterSite: 'github', runId: 'run_a' }; @@ -892,6 +897,7 @@ describe('CloakSessionManager', () => { const launched = fakeContext(); const manager = new CloakSessionManager({ baseDir: '/tmp/webcmd-test', + platform: 'darwin', launchPersistentContext: vi.fn().mockResolvedValue(launched.context), }); const base = { profileId: 'default', session: 'session_default', sessionId: 'session_default', surface: 'adapter' as const, siteSession: 'ephemeral' as const }; @@ -928,6 +934,7 @@ describe('CloakSessionManager', () => { const launched = fakeContext(); const manager = new CloakSessionManager({ baseDir: '/tmp/webcmd-test', + platform: 'darwin', launchPersistentContext: vi.fn().mockResolvedValue(launched.context), }); const lease = await manager.getPage({ profileId: 'default', session: 'work', surface: 'browser', idleTimeout: 25 }); @@ -945,6 +952,7 @@ describe('CloakSessionManager', () => { const launched = fakeContext(); const manager = new CloakSessionManager({ baseDir: '/tmp/webcmd-test', + platform: 'darwin', launchPersistentContext: vi.fn().mockResolvedValue(launched.context), }); const first = await manager.getPage({ profileId: 'default', session: 'work', surface: 'browser', idleTimeout: 25 }); diff --git a/webcmd-plugin.json b/webcmd-plugin.json index 33ed848c..e3b08a3c 100644 --- a/webcmd-plugin.json +++ b/webcmd-plugin.json @@ -638,7 +638,7 @@ "path": "plugins/linkedin", "version": "0.1.0", "description": "LinkedIn profile, network, messaging, job, and Sales Navigator commands for WebCMD", - "webcmd": ">=0.6.0", + "webcmd": ">=0.6.1", "author": { "name": "WebCMD Agent", "handle": "agentrhq" From 2771f101d532630a277da11ecb84185d0ec22dc0 Mon Sep 17 00:00:00 2001 From: beubax Date: Wed, 12 Aug 2026 17:51:47 +0530 Subject: [PATCH 20/27] fix: finish session concurrency safety --- .github/workflows/ci.yml | 2 +- docs/concepts.mdx | 4 +- docs/skills.mdx | 1 + package.json | 1 + skills/webcmd-adapter-author/SKILL.md | 2 +- .../references/adapter-template.md | 2 +- src/browser/protocol.ts | 4 + src/browser/run/playwright-transport.ts | 4 +- src/browser/run/runner.test.ts | 38 ++++ src/browser/run/runner.ts | 17 +- src/browser/run/types.ts | 1 + src/browser/runtime/local-cloak/actions.ts | 23 ++- .../runtime/local-cloak/provider.test.ts | 77 ++++++++ src/browser/runtime/local-cloak/provider.ts | 31 +++- .../local-cloak/session-manager.test.ts | 167 +++++++++++++++--- .../runtime/local-cloak/session-manager.ts | 42 ++++- src/browser/runtime/provider.ts | 4 +- src/browser/sessions.test.ts | 55 ++++++ src/browser/sessions.ts | 22 ++- src/cli-argv-preprocess.test.ts | 14 +- src/cli-argv-preprocess.ts | 15 ++ src/cli.test.ts | 7 +- src/cli.ts | 33 +++- src/daemon/server.test.ts | 83 ++++++++- src/daemon/server.ts | 62 +++++-- src/hosted/client.test.ts | 41 ++++- src/hosted/client.ts | 39 +++- src/hosted/contract.test.ts | 2 + src/hosted/contract.ts | 6 + src/hosted/main-lifecycle.test.ts | 1 + src/hosted/manifest.test.ts | 3 +- src/hosted/output-parity.test.ts | 3 +- src/hosted/root-command-surface.test.ts | 3 +- src/hosted/runner.test.ts | 53 +++++- src/hosted/runner.ts | 18 +- src/hosted/types.ts | 3 + src/main.ts | 4 +- src/plugin.test.ts | 18 +- src/root-command-surface.ts | 1 + src/session-docs-sync.test.ts | 36 ++++ tests/e2e/browser-tabs.test.ts | 10 +- tests/e2e/cloak-runtime.test.ts | 35 +++- tests/e2e/cloak-session-concurrency.test.ts | 63 +++++++ vitest.config.ts | 1 + 44 files changed, 933 insertions(+), 118 deletions(-) create mode 100644 src/session-docs-sync.test.ts create mode 100644 tests/e2e/cloak-session-concurrency.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bf32baa5..b4704d6c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -194,7 +194,7 @@ jobs: run: ./scripts/collect-ci-diagnostics.ps1 -SelfTest - name: Run real Cloak lifecycle smoke - run: npx vitest run --project e2e tests/e2e/cloak-runtime.test.ts + run: npx vitest run --project e2e tests/e2e/cloak-runtime.test.ts tests/e2e/cloak-session-concurrency.test.ts - name: Collect sanitized diagnostics if: failure() diff --git a/docs/concepts.mdx b/docs/concepts.mdx index b545f986..a056d7f7 100644 --- a/docs/concepts.mdx +++ b/docs/concepts.mdx @@ -41,9 +41,9 @@ The agent chooses the strategy; the human describes the outcome and constraints. | `UI` | Drives the live page UI. | | `LOCAL` | Talks to a local app, service, or CLI. | -## Sessions and State +## Site Sessions and State -Browser commands can use an `ephemeral` session for an isolated tab or a `persistent` session for a longer workflow. A command can request `freshPage: true` when it needs a clean tab while keeping its session state. +Adapter browser commands can use `siteSession: 'ephemeral'` for an isolated tab or `siteSession: 'persistent'` for a longer same-site workflow. Raw browser work uses an explicit opaque Session created with `webcmd session create` and selected at the root with `--session `. ## What the Human Needs to Decide diff --git a/docs/skills.mdx b/docs/skills.mdx index 1ddd97b9..52b8bd38 100644 --- a/docs/skills.mdx +++ b/docs/skills.mdx @@ -32,3 +32,4 @@ Do not also add the skills with `webcmd skills add` in Codex. ## Other Agents or Plugin-Free Setup Run `webcmd skills add` to install or refresh the bundled Webcmd skills for your agent. The agent can then start with `webcmd-usage` and load the specialized skill that matches the outcome. +For raw browser work, agents should create a Session with `webcmd session create -f json` and pass it as a root selector: `webcmd --session browser ...`. diff --git a/package.json b/package.json index 5274b2e0..31d713db 100644 --- a/package.json +++ b/package.json @@ -73,6 +73,7 @@ "test:plugin": "vitest run --project plugin", "test:all": "vitest run", "test:e2e": "vitest run --project e2e-fixed-port --project e2e", + "gate:cloak-sessions": "vitest run --project e2e tests/e2e/cloak-session-concurrency.test.ts", "check-community-plugins": "tsx scripts/sync-community-plugins.ts --check", "advise:listing-id-pairing": "node scripts/check-listing-id-pairing.mjs", "check:package-bin": "node scripts/check-package-bin.mjs", diff --git a/skills/webcmd-adapter-author/SKILL.md b/skills/webcmd-adapter-author/SKILL.md index 1b3eeecf..2f45c362 100644 --- a/skills/webcmd-adapter-author/SKILL.md +++ b/skills/webcmd-adapter-author/SKILL.md @@ -262,7 +262,7 @@ Check these off step by step: - **Intermediate parsing object keys must not overlap any `columns` entry.** Otherwise silent-column-drop audits can misread the adapter. Use dedicated internal names and destructure with aliases when pushing rows. - **The `browser:` field determines the `func` signature:** `browser:false -> (args)`, `browser:true -> (page, args)`. If this is reversed, `args` may actually be a debug flag and all external parameters can silently fall back to defaults. - Throw the correct typed error for known failures according to [`references/typed-errors.md`](./references/typed-errors.md). **Do not** silently `return []`, **do not** silently `return [{sentinel}]`, and **do not** silently clamp external parameters with `Math.max/min`. -- **Persistent sessions keep stale DOM between commands.** `siteSession: 'persistent'` shares one tab per site; leftover modals/drawers from the previous command leak into the next one. State-sensitive write commands (checkout flows) should add `freshPage: true` (new tab, same lease — cookies/login/location survive). Verify session-scoped context (login, selected city/date) *before* side effects, and embed such context in URLs/IDs your command emits for sibling commands. See `references/adapter-template.md` and "Persistent Sessions and State Hygiene" in `docs/authoring.mdx`. +- **Persistent site sessions keep stale DOM between commands.** `siteSession: 'persistent'` shares one tab per site; leftover modals/drawers from the previous command leak into the next one. State-sensitive write commands (checkout flows) should add `freshPage: true` (new tab, same lease — cookies/login/location survive). Verify session-scoped context (login, selected city/date) *before* side effects, and embed such context in URLs/IDs your command emits for sibling commands. See `references/adapter-template.md` and "Persistent Site Sessions and State Hygiene" in `docs/authoring.mdx`. - For private iteration, write `~/.webcmd/clis//.js` to avoid a build. Use `webcmd adapter override /` to fork an existing plugin command. When the user says to promote a CLI, keep the `webcmd plugin create --dir plugins/` step for packaging a new plugin, then register it in root `webcmd-plugin.json`, install the plugin, and run `webcmd validate ` and smoke commands. See `references/adapter-template.md` for details. - After `webcmd plugin update`, check the reported overrides needing reconciliation and merge `yours` with `upstream`, using `base` as the common ancestor for a three-way merge. Only overrides are reported; a user-authored adapter has no upstream. - Write site memory every round: no memory -> use skill -> produce memory -> next time becomes a five-minute task. diff --git a/skills/webcmd-adapter-author/references/adapter-template.md b/skills/webcmd-adapter-author/references/adapter-template.md index 20871dac..0933a606 100644 --- a/skills/webcmd-adapter-author/references/adapter-template.md +++ b/skills/webcmd-adapter-author/references/adapter-template.md @@ -133,7 +133,7 @@ Rules: | `args` | Include type, default, and help for every external parameter. | | `columns` | Must exactly match row keys, including order. | | `pipeline` or `func` | Use the style already established by nearby adapters. | -| `siteSession` | `'persistent'` shares one tab per site across commands (multi-step flows); `'ephemeral'` gets a fresh isolated tab per run. Persistent tabs keep leftover DOM (modals, drawers) between commands — see "Persistent Sessions and State Hygiene" in docs/authoring.mdx. | +| `siteSession` | `'persistent'` shares one tab per site across commands (multi-step flows); `'ephemeral'` gets a fresh isolated tab per run. Persistent site-session tabs keep leftover DOM (modals, drawers) between commands — see "Persistent Site Sessions and State Hygiene" in docs/authoring.mdx. | | `freshPage` | With `siteSession: 'persistent'`, set `true` to start the command on a newly created tab under the same lease: profile state (cookies, login, location) survives, stale DOM does not. Recommended for state-sensitive write commands such as checkout flows. | ## Strategy Enum Examples diff --git a/src/browser/protocol.ts b/src/browser/protocol.ts index 1d5b47b0..9ac5501e 100644 --- a/src/browser/protocol.ts +++ b/src/browser/protocol.ts @@ -65,6 +65,10 @@ export interface BrowserRuntimeCommand { timeout?: number; /** Absolute command deadline in epoch milliseconds. Preferred by newer daemons. */ deadlineAt?: number; + /** Force Session lifecycle actions such as close past active work/handoff guards. */ + force?: boolean; + /** Maximum Session rows returned by session-list. */ + limit?: number; cdpMethod?: string; cdpParams?: Record; windowMode?: BrowserWindowMode; diff --git a/src/browser/run/playwright-transport.ts b/src/browser/run/playwright-transport.ts index ecb322c9..ec843895 100644 --- a/src/browser/run/playwright-transport.ts +++ b/src/browser/run/playwright-transport.ts @@ -291,7 +291,9 @@ function scopedBrowser(browser: object, context: object): object { let proxy: object; proxy = new Proxy(browser, { get(target, property) { - if (property === 'contexts') return () => [context]; + if (property === 'contexts') return () => ( + Reflect.get(target, '_defaultContext', target) ? [] : [context] + ); if (property === 'on' || property === 'addListener') { return (event: string, listener: Function) => { let registered = listener; diff --git a/src/browser/run/runner.test.ts b/src/browser/run/runner.test.ts index 656a8d6b..da3e585e 100644 --- a/src/browser/run/runner.test.ts +++ b/src/browser/run/runner.test.ts @@ -426,6 +426,32 @@ describe('runBrowserProgram', () => { expect(createPage).toHaveBeenCalledOnce(); }); + it('initializes against a pre-launched persistent context without registering it twice', async () => { + const userDataDir = fs.mkdtempSync('/tmp/webcmd-persistent-browser-run-'); + const persistent = await chromium.launchPersistentContext(userDataDir, { headless: true }); + try { + const persistentPage = persistent.pages()[0] ?? await persistent.newPage(); + const persistentBrowser = persistent.browser(); + if (!persistentBrowser) throw new Error('persistent browser missing'); + + await expect(runBrowserProgram({ + browser: persistentBrowser, + context: persistent, + page: persistentPage, + pageId: 'persistent-page', + pages: () => persistent.pages(), + createPage: () => persistent.newPage(), + onPage(listener) { + persistent.on('page', listener); + return () => persistent.off('page', listener); + }, + }, 'return 1;')).resolves.toMatchObject({ result: 1 }); + } finally { + await persistent.close(); + fs.rmSync(userDataDir, { recursive: true, force: true }); + } + }); + it('waits for requests and responses', async () => { const output = await run(` const requestPromise = page.waitForRequest('**/data'); @@ -535,6 +561,18 @@ describe('runBrowserProgram', () => { expect(page.isClosed()).toBe(false); }); + it('cancels an in-flight run through its abort signal', async () => { + const controller = new AbortController(); + const pending = run(`await page.waitForEvent('popup');`, { + timeoutMs: 2_000, + signal: controller.signal, + }); + + setTimeout(() => controller.abort(), 10); + + await expect(pending).rejects.toMatchObject({ code: 'BROWSER_RUN_CANCELLED' }); + }); + it('cancels an in-flight popup operation without closing the popup', async () => { const popupPromise = page.waitForEvent('popup'); await page.getByRole('link', { name: 'Popup' }).click(); diff --git a/src/browser/run/runner.ts b/src/browser/run/runner.ts index c5d52657..fbbb5aa0 100644 --- a/src/browser/run/runner.ts +++ b/src/browser/run/runner.ts @@ -314,6 +314,7 @@ export async function runBrowserProgram( let timeout: ReturnType | undefined; let timeoutCleanup: Promise | undefined; let execution: Promise | undefined; + let interrupted = false; const disposeTimedOutRun = (timeoutError: BrowserRunError): void => { if (timeoutCleanup) return; host.cancelPending(timeoutError); @@ -490,10 +491,22 @@ export async function runBrowserProgram( reject(error); }, remainingMs); }); + const cancelled = new Promise((_resolve, reject) => { + const signal = options.signal; + if (!signal) return; + const abort = () => { + interrupted = true; + const error = new BrowserRunError('BROWSER_RUN_CANCELLED', 'Browser-run execution was cancelled.'); + disposeTimedOutRun(error); + reject(error); + }; + if (signal.aborted) abort(); + else signal.addEventListener('abort', abort, { once: true }); + }); execution.catch(() => {}); let serialized: unknown; try { - serialized = await Promise.race([execution, deadline]); + serialized = await Promise.race([execution, deadline, cancelled]); } finally { timings.program_ms = Math.max(0, Date.now() - programStartedAt); timings.browser_wait_ms = transport.browserWaitMs; @@ -585,7 +598,7 @@ export async function runBrowserProgram( throw await failure(normalized); } finally { if (timeout) clearTimeout(timeout); - if (timedOut) { + if (timedOut || interrupted) { void timeoutCleanup; } else { const completionError = new BrowserRunError( diff --git a/src/browser/run/types.ts b/src/browser/run/types.ts index d7c8c7cc..fb1dd686 100644 --- a/src/browser/run/types.ts +++ b/src/browser/run/types.ts @@ -85,6 +85,7 @@ export interface BrowserRunOptions { snapshotDiff?: boolean; snapshotMode?: SnapshotTreeMode; snapshotBaselineStore?: SnapshotBaselineStore; + signal?: AbortSignal; } export interface BrowserRunLogEntry { diff --git a/src/browser/runtime/local-cloak/actions.ts b/src/browser/runtime/local-cloak/actions.ts index b7abc8e2..c12b35a5 100644 --- a/src/browser/runtime/local-cloak/actions.ts +++ b/src/browser/runtime/local-cloak/actions.ts @@ -198,16 +198,32 @@ async function captureScreenshot(page: PlaywrightPage, context: BrowserContext, } } -export async function dispatchCloakAction(manager: CloakSessionManager, command: BrowserRuntimeCommand): Promise { +export async function dispatchCloakAction(manager: CloakSessionManager, command: BrowserRuntimeCommand, signal?: AbortSignal): Promise { try { switch (command.action) { case 'navigate': { if (!command.url) return invalidRequest(command, 'Missing url'); - const lease = await resolveLease(manager, command); + const profileId = resolveCloakCommandProfileId(manager, command); // 'none' maps to Playwright's 'commit': sites that stream analytics forever // never fire the load event, so adapters gating readiness on their own // selector waits must be able to skip it. - await lease.page.goto(command.url, { waitUntil: command.waitUntil === 'none' ? 'commit' : 'load' }); + const lease = await manager.navigatePage( + { + profileId, + session: command.session, + surface: command.surface, + siteSession: command.siteSession, + sessionKind: command.sessionKind, + sessionId: command.sessionId, + adapterSite: command.adapterSite, + runId: command.runId, + idleTimeout: command.idleTimeout, + freshPage: command.freshPage, + windowMode: command.windowMode, + }, + command.url, + command.waitUntil === 'none' ? 'commit' : 'load', + ); return { id: command.id, ok: true, data: { title: await lease.page.title(), url: lease.page.url(), timedOut: false }, page: lease.pageId }; } case 'exec': { @@ -252,6 +268,7 @@ export async function dispatchCloakAction(manager: CloakSessionManager, command: snapshotDiff: command.noSnapshotDiff ? false : command.snapshotDiff, snapshotMode: command.snapshotMode === 'tree' ? 'tree' : 'act', snapshotBaselineStore: snapshotBaselineStore(manager), + ...(signal ? { signal } : {}), }); return { id: command.id, diff --git a/src/browser/runtime/local-cloak/provider.test.ts b/src/browser/runtime/local-cloak/provider.test.ts index d99c5292..2eb87c6f 100644 --- a/src/browser/runtime/local-cloak/provider.test.ts +++ b/src/browser/runtime/local-cloak/provider.test.ts @@ -187,6 +187,38 @@ describe('LocalCloakRuntimeProvider', () => { expect(page.goto).toHaveBeenCalledWith('https://example.com/', expect.objectContaining({ waitUntil: 'commit' })); }); + it('does not execute a queued command after its daemon deadline expires', async () => { + const { provider, page } = makeProviderWithFakePage(); + let releaseFirst!: () => void; + page.goto.mockImplementationOnce(() => new Promise((resolve) => { + releaseFirst = resolve; + })); + const first = provider.dispatch({ + id: 'first', + action: 'navigate', + session: 'work', + surface: 'browser', + url: 'https://first.example/', + profileId: 'default', + }); + await vi.waitFor(() => expect(page.goto).toHaveBeenCalledTimes(1)); + const second = provider.dispatch({ + id: 'second', + action: 'navigate', + session: 'work', + surface: 'browser', + url: 'https://late.example/', + profileId: 'default', + deadlineAt: Date.now() - 1, + }); + + releaseFirst(); + + await expect(first).resolves.toMatchObject({ ok: true }); + await expect(second).resolves.toMatchObject({ ok: false, errorCode: 'command_result_unknown' }); + expect(page.goto).toHaveBeenCalledTimes(1); + }); + it('evaluates JavaScript in the resolved page', async () => { const { provider } = makeProviderWithFakePage(); const nav = await provider.dispatch({ id: 'nav', action: 'navigate', session: 'work', surface: 'browser', url: 'https://example.com/', profileId: 'default' }); @@ -343,6 +375,44 @@ describe('LocalCloakRuntimeProvider', () => { expect(page.evaluate).toHaveBeenCalledTimes(1); }); + it('serializes raw and adapter commands in the same explicit Session', async () => { + const { provider } = makeProviderWithFakePage(); + const manager = (provider as unknown as { manager: { + runWithProfileActivity(profileId: string, operation: () => Promise): Promise; + } }).manager; + const runWithProfileActivity = manager.runWithProfileActivity.bind(manager); + let active = 0; + let maxActive = 0; + vi.spyOn(manager, 'runWithProfileActivity').mockImplementation(async (profileId, operation) => { + active += 1; + maxActive = Math.max(maxActive, active); + try { + return await runWithProfileActivity(profileId, operation); + } finally { + active -= 1; + } + }); + let finishRun!: () => void; + runBrowserProgram.mockImplementationOnce(() => new Promise((resolve) => { + finishRun = () => resolve(runOutput(1)); + })); + const raw = provider.dispatch({ + id: 'raw-run', action: 'run', session: 'session_a', sessionKind: 'explicit', + surface: 'browser', source: 'return 1;', profileId: 'default', + }); + await vi.waitFor(() => expect(runBrowserProgram).toHaveBeenCalledTimes(1)); + const adapter = provider.dispatch({ + id: 'adapter-exec', action: 'exec', session: 'session_a', sessionKind: 'explicit', + surface: 'adapter', adapterSite: 'github', code: 'document.title', profileId: 'default', + }); + + await new Promise((resolve) => setTimeout(resolve, 5)); + expect(runBrowserProgram).toHaveBeenCalledTimes(1); + finishRun(); + await Promise.all([raw, adapter]); + expect(maxActive).toBe(1); + }); + it('partitions the local queue by adapter site only for adapter-default Sessions', () => { const { provider } = makeProviderWithFakePage(); const queueKey = (provider as unknown as { @@ -383,6 +453,13 @@ describe('LocalCloakRuntimeProvider', () => { adapterSite: 'linkedin', profileId: 'default', })); + expect(queueKey({ + id: 'github-explicit', action: 'exec', surface: 'adapter', session: 'session_a', + sessionKind: 'explicit', adapterSite: 'github', profileId: 'default', + })).toBe(queueKey({ + id: 'raw-explicit', action: 'exec', surface: 'browser', session: 'session_a', + sessionKind: 'explicit', profileId: 'default', + })); }); it('keeps adapter-default page-scoped queue keys partitioned by site', async () => { diff --git a/src/browser/runtime/local-cloak/provider.ts b/src/browser/runtime/local-cloak/provider.ts index d716f89a..86dfa1da 100644 --- a/src/browser/runtime/local-cloak/provider.ts +++ b/src/browser/runtime/local-cloak/provider.ts @@ -19,10 +19,13 @@ export class LocalCloakRuntimeProvider implements BrowserRuntimeProvider { private readonly sessionQueues = new Map>(); constructor(private readonly opts: LocalCloakRuntimeProviderOptions = {}) { - this.sessions = new LocalBrowserSessionStore({ baseDir: opts.baseDir }); + this.sessions = new LocalBrowserSessionStore({ + baseDir: opts.baseDir, + isActive: session => this.manager?.hasSession(session.profileId, session.id) ?? false, + }); this.manager = new CloakSessionManager({ ...opts, - hasActiveHandoff: profileId => this.sessions.list(profileId).some(session => ( + hasActiveHandoff: profileId => this.sessions.list(profileId, 100).some(session => ( Boolean(session.handoff) && Date.parse(session.handoff!.expiresAt) > Date.now() )), }); @@ -72,8 +75,8 @@ export class LocalCloakRuntimeProvider implements BrowserRuntimeProvider { return this.sessions.clearHandoff(this.resolveProfileId(command), command.sessionId!); } - async listSessions(input: { profileId?: string }): Promise { - return this.sessions.list(input.profileId).map((session) => ({ + async listSessions(input: { profileId?: string; limit?: number }): Promise { + return this.sessions.list(input.profileId, input.limit).map((session) => ({ ...session, runtimeState: this.manager.hasSession(session.profileId, session.id) ? 'active' : 'idle', })); @@ -82,11 +85,12 @@ export class LocalCloakRuntimeProvider implements BrowserRuntimeProvider { async closeSession(command: BrowserRuntimeCommand): Promise<{ closed: boolean; alreadyIdle: boolean; session: string }> { const record = this.sessions.require(this.resolveProfileId(command), command.session); const closedCount = await this.manager.closeSession(record.profileId, record.id); - this.sessions.touch(record.profileId, record.id); + if (command.force && record.handoff) this.sessions.clearHandoff(record.profileId, record.id); + else this.sessions.touch(record.profileId, record.id); return { closed: closedCount > 0, alreadyIdle: closedCount === 0, session: record.id }; } - async dispatch(command: BrowserRuntimeCommand): Promise { + async dispatch(command: BrowserRuntimeCommand, signal?: AbortSignal): Promise { const key = this.commandQueueKey(command); const previous = this.sessionQueues.get(key) ?? Promise.resolve(); let release!: () => void; @@ -97,9 +101,18 @@ export class LocalCloakRuntimeProvider implements BrowserRuntimeProvider { await previous.catch(() => {}); try { + signal?.throwIfAborted(); + if (typeof command.deadlineAt === 'number' && command.deadlineAt > 0 && Date.now() >= command.deadlineAt) { + return { + id: command.id, + ok: false, + errorCode: 'command_result_unknown', + error: 'Command deadline expired before browser work started.', + }; + } return await this.manager.runWithProfileActivity( this.resolveProfileId(command), - () => dispatchCloakAction(this.manager, command), + () => dispatchCloakAction(this.manager, command, signal), ); } finally { release(); @@ -120,7 +133,7 @@ export class LocalCloakRuntimeProvider implements BrowserRuntimeProvider { const adapterDefaultSite = owner.surface === 'adapter' && owner.sessionKind === 'adapter-default' ? owner.adapterSite?.trim() : undefined; - return `session\u0000${owner.profileId}\u0000${owner.surface}\u0000${owner.session}${adapterDefaultSite ? `\u0000${adapterDefaultSite}` : ''}`; + return `session\u0000${owner.profileId}\u0000${owner.session}${adapterDefaultSite ? `\u0000${adapterDefaultSite}` : ''}`; } } @@ -136,6 +149,6 @@ export class LocalCloakRuntimeProvider implements BrowserRuntimeProvider { const adapterDefaultSite = command.surface === 'adapter' && command.sessionKind === 'adapter-default' ? command.adapterSite?.trim() : undefined; - return `session\u0000${profileId.trim() || 'default'}\u0000${command.surface ?? 'browser'}\u0000${command.session ?? ''}${adapterDefaultSite ? `\u0000${adapterDefaultSite}` : ''}`; + return `session\u0000${profileId.trim() || 'default'}\u0000${command.session ?? ''}${adapterDefaultSite ? `\u0000${adapterDefaultSite}` : ''}`; } } diff --git a/src/browser/runtime/local-cloak/session-manager.test.ts b/src/browser/runtime/local-cloak/session-manager.test.ts index 193907d5..d07e20a0 100644 --- a/src/browser/runtime/local-cloak/session-manager.test.ts +++ b/src/browser/runtime/local-cloak/session-manager.test.ts @@ -77,10 +77,25 @@ function fakeContext() { for (const listener of listeners.get(event) ?? []) listener(...args); }; const cdp = { - send: vi.fn(async (command: string, params?: { targetId?: string; hidden?: boolean }) => { + send: vi.fn(async (command: string, params?: { targetId?: string; hidden?: boolean; newWindow?: boolean }) => { if (command === 'Target.createTarget') { - const backgroundPage = params?.hidden ? fakePage() : await context.newPage(); - if (params?.hidden) allPages.push(backgroundPage); + let backgroundPage; + if (params?.hidden) { + backgroundPage = fakePage(); + allPages.push(backgroundPage); + } else if (params?.newWindow === false) { + let opener; + for (let index = allPages.length - 1; index >= 0; index -= 1) { + if (!allPages[index]!.isClosed()) { + opener = allPages[index]; + break; + } + } + backgroundPage = fakePage(undefined, opener ? windowIds.get(targetIds.get(opener)!) : undefined); + allPages.push(backgroundPage); + } else { + backgroundPage = await context.newPage(); + } backgroundPages.push(backgroundPage); queueMicrotask(() => emit('page', backgroundPage)); return { targetId: targetIds.get(backgroundPage) }; @@ -132,6 +147,7 @@ function fakeContext() { windowIdFor: (target: object) => windowIds.get(targetIds.get(target) ?? ''), moveToWindow: (target: object, windowId: number) => windowIds.set(targetIds.get(target)!, windowId), emitPage: (target: object) => emit('page', target), + emitPageEvent, emitCdp: (event: string, payload: unknown) => { for (const listener of cdpListeners.get(event) ?? []) listener(payload); }, @@ -205,7 +221,7 @@ describe('CloakSessionManager', () => { expect(launched.targetIdFor(lease.page)).toEqual(expect.stringMatching(/^target-/)); }); - it('uses the noopener popup even though window.open returns null and falls back when no popup appears', async () => { + it('creates later Session pages through the opener in the same window', async () => { const launched = fakeContext(); const manager = new CloakSessionManager({ baseDir: '/tmp/webcmd-test', @@ -215,21 +231,71 @@ describe('CloakSessionManager', () => { const key = { profileId: 'default', session: 'session_a', sessionId: 'session_a', surface: 'browser' as const }; const first = await manager.getPage(key); - await manager.newPage(key); + const second = await manager.newPage(key); const evaluate = vi.mocked(first.page.evaluate); - expect(String(evaluate.mock.calls[0][0])).toContain('noopener'); + expect(evaluate).toHaveBeenCalledTimes(1); expect(launched.context.newCDPSession.mock.calls.length).toBeGreaterThanOrEqual(2); - expect(launched.cdp.send.mock.calls.filter(([method, params]) => method === 'Target.createTarget' && !(params as { hidden?: boolean })?.hidden)).toHaveLength(1); + expect(launched.cdp.send.mock.calls.filter(([method, params]) => ( + method === 'Target.createTarget' && !(params as { hidden?: boolean })?.hidden + ))).toHaveLength(1); + expect(launched.windowIdFor(second.page)).toBe(launched.windowIdFor(first.page)); + expect((await manager.listPages(key)).every(tab => tab.session === 'session_a')).toBe(true); + }); + + it('falls back to another owned window when Chromium does not create the requested tab', async () => { + const launched = fakeContext(); + const manager = new CloakSessionManager({ + baseDir: '/tmp/webcmd-test', + platform: 'darwin', + launchPersistentContext: vi.fn().mockResolvedValue(launched.context), + }); + const key = { profileId: 'default', session: 'session_a', sessionId: 'session_a', surface: 'browser' as const }; + const first = await manager.getPage(key); + vi.mocked(first.page.evaluate).mockResolvedValueOnce(null); - evaluate.mockRejectedValueOnce(new Error('Execution context was destroyed')); - const afterThrow = await manager.newPage(key); - evaluate.mockResolvedValueOnce(null); - const afterNull = await manager.newPage(key); + const second = await manager.newPage(key); - expect(launched.cdp.send.mock.calls.filter(([method, params]) => method === 'Target.createTarget' && !(params as { hidden?: boolean })?.hidden)).toHaveLength(3); - expect(launched.windowIdFor(afterThrow.page)).not.toBe(launched.windowIdFor(first.page)); - expect(launched.windowIdFor(afterNull.page)).not.toBe(launched.windowIdFor(first.page)); - expect((await manager.listPages(key)).every(tab => tab.session === 'session_a')).toBe(true); + expect(launched.windowIdFor(second.page)).not.toBe(launched.windowIdFor(first.page)); + expect((await manager.listPages(key)).map(tab => tab.sessionId)).toEqual(['session_a', 'session_a']); + }); + + it('adopts an opener popup when Chromium creates it in an unowned window', async () => { + const launched = fakeContext(); + const manager = new CloakSessionManager({ + baseDir: '/tmp/webcmd-test', + platform: 'darwin', + launchPersistentContext: vi.fn().mockResolvedValue(launched.context), + }); + const key = { profileId: 'default', session: 'session_a', sessionId: 'session_a', surface: 'browser' as const }; + const first = await manager.getPage(key); + const popup = launched.makePage(first.page, 999); + vi.mocked(first.page.evaluate).mockImplementationOnce(async () => { + queueMicrotask(() => { + launched.emitPageEvent(first.page, 'popup', popup); + launched.emitPage(popup); + }); + return null; + }); + + const second = await manager.newPage(key); + + expect(second.page).toBe(popup); + expect((await manager.listPages(key)).map(tab => tab.sessionId)).toEqual(['session_a', 'session_a']); + }); + + it('creates a later page in its Session window when another Session was used last', async () => { + const launched = fakeContext(); + const manager = new CloakSessionManager({ + baseDir: '/tmp/webcmd-test', platform: 'darwin', + launchPersistentContext: vi.fn().mockResolvedValue(launched.context), + }); + const firstKey = { profileId: 'default', session: 'session_a', sessionId: 'session_a', surface: 'browser' as const }; + const first = await manager.getPage(firstKey); + await manager.getPage({ profileId: 'default', session: 'session_b', sessionId: 'session_b', surface: 'browser' }); + + const second = await manager.newPage(firstKey); + + expect(launched.windowIdFor(second.page)).toBe(launched.windowIdFor(first.page)); }); it('times out target correlation and releases the profile creation lock', async () => { @@ -767,25 +833,31 @@ describe('CloakSessionManager', () => { expect(vi.getTimerCount()).toBe(1); }); - it('does not retry or retain a page when navigation fails after creation', async () => { + it('retries initial navigation once after a closed-context failure', async () => { const navigationFailure = new Error('Target page, context or browser has been closed'); const launched = fakeContext(); launched.page.goto.mockRejectedValue(navigationFailure); launched.context.newPage.mockResolvedValue(launched.page); - const launchPersistentContext = vi.fn().mockResolvedValue(launched.context); + const replacement = fakeContext(); + replacement.context.newPage.mockResolvedValue(replacement.page); + const launchPersistentContext = vi.fn() + .mockResolvedValueOnce(launched.context) + .mockResolvedValueOnce(replacement.context); const manager = new CloakSessionManager({ baseDir: '/tmp/webcmd-test', launchPersistentContext }); - await expect(manager.newPage({ + const lease = await manager.newPage({ profileId: 'default', session: 'work', surface: 'browser', url: 'https://example.com/', - })).rejects.toBe(navigationFailure); + }); - expect(launchPersistentContext).toHaveBeenCalledTimes(1); + expect(lease.context).toBe(replacement.context); + expect(launchPersistentContext).toHaveBeenCalledTimes(2); expect(launched.page.goto).toHaveBeenCalledTimes(1); expect(launched.page.close).toHaveBeenCalledTimes(1); - expect(await manager.listPages({ profileId: 'default', session: 'work' })).toEqual([]); + expect(replacement.page.goto).toHaveBeenCalledWith('https://example.com/', { waitUntil: 'load' }); + expect(await manager.listPages({ profileId: 'default', session: 'work' })).toHaveLength(1); }); it('clears a stale Cloak profile owner and retries when Chromium reports an existing session', async () => { @@ -1003,6 +1075,59 @@ describe('CloakSessionManager', () => { expect(launchPersistentContext.mock.calls[0][0].userDataDir).toBe(expectedProfileDir('profile-default')); }); + it('retries action navigation once after a closed-context failure', async () => { + const first = fakeContext(); + first.page.goto.mockRejectedValue(new Error('Target page, context or browser has been closed')); + first.context.newPage.mockResolvedValue(first.page); + const replacement = fakeContext(); + replacement.context.newPage.mockResolvedValue(replacement.page); + const launchPersistentContext = vi.fn() + .mockResolvedValueOnce(first.context) + .mockResolvedValueOnce(replacement.context); + const manager = new CloakSessionManager({ + baseDir: '/tmp/webcmd-test', + launchPersistentContext, + }); + + const result = await dispatchCloakAction(manager, { + id: 'cmd-retry-navigation', + action: 'navigate', + session: 'work', + surface: 'browser', + url: 'https://example.com/', + profileId: 'default', + }); + + expect(result).toMatchObject({ ok: true }); + expect(first.page.goto).toHaveBeenCalledTimes(1); + expect(launchPersistentContext).toHaveBeenCalledTimes(2); + expect(replacement.page.goto).toHaveBeenCalledWith('https://example.com/', { waitUntil: 'load' }); + }); + + it('does not invalidate a replacement runtime when stale navigation fails', async () => { + const first = fakeContext(); + first.context.newPage.mockResolvedValue(first.page); + let rejectNavigation!: (error: Error) => void; + first.page.goto.mockImplementationOnce(() => new Promise((_, reject) => { rejectNavigation = reject; })); + const replacement = fakeContext(); + replacement.context.newPage.mockResolvedValue(replacement.page); + const launchPersistentContext = vi.fn() + .mockResolvedValueOnce(first.context) + .mockResolvedValueOnce(replacement.context); + const manager = new CloakSessionManager({ baseDir: '/tmp/webcmd-test', launchPersistentContext }); + const key = { profileId: 'default', session: 'work', sessionId: 'session_a', surface: 'browser' as const }; + await manager.getPage(key); + const navigation = manager.navigatePage(key, 'https://example.com/', 'load'); + await vi.waitFor(() => expect(first.page.goto).toHaveBeenCalledTimes(1)); + first.context.emit('close'); + const replacementLease = await manager.getPage(key); + rejectNavigation(new Error('Target page, context or browser has been closed')); + + await expect(navigation).resolves.toMatchObject({ context: replacement.context }); + expect(replacementLease.context).toBe(replacement.context); + expect(launchPersistentContext).toHaveBeenCalledTimes(2); + }); + it('falls back to the only active profile when the preferred profile is stale', async () => { const launched = fakeContext(); const launchPersistentContext = vi.fn().mockResolvedValue(launched.context); diff --git a/src/browser/runtime/local-cloak/session-manager.ts b/src/browser/runtime/local-cloak/session-manager.ts index 0897f2ce..bfb98209 100644 --- a/src/browser/runtime/local-cloak/session-manager.ts +++ b/src/browser/runtime/local-cloak/session-manager.ts @@ -411,6 +411,14 @@ export class CloakSessionManager { } async newPage(input: SessionKeyInput & { url?: string }): Promise { + return this.newPageAttempt(input, 0); + } + + async navigatePage(input: SessionKeyInput, url: string, waitUntil: 'load' | 'commit'): Promise { + return this.navigatePageAttempt(input, url, waitUntil, 0); + } + + private async newPageAttempt(input: SessionKeyInput & { url?: string }, attempt: number): Promise { const profileId = normalizeProfileId(input.profileId); const session = requireSession(input.session); const sessionId = requireSessionId(input); @@ -423,6 +431,11 @@ export class CloakSessionManager { try { await acquired.page.goto(input.url, { waitUntil: 'load' }); } catch (error) { + if (attempt === 0 && isClosedContextError(error)) { + this.invalidateProfileRuntime(profileId, acquired.runtime); + if (!pageIsClosed(acquired.page)) await acquired.page.close().catch(() => {}); + return this.newPageAttempt(input, 1); + } if (!pageIsClosed(acquired.page)) await acquired.page.close().catch(() => {}); throw error; } @@ -446,6 +459,21 @@ export class CloakSessionManager { return { profileId, leaseKey, context: acquired.runtime.context, page: acquired.page, pageId: entry.pageId }; } + private async navigatePageAttempt(input: SessionKeyInput, url: string, waitUntil: 'load' | 'commit', attempt: number): Promise { + const profileId = normalizeProfileId(input.profileId); + const lease = await this.getPage(input); + const runtime = this.profiles.get(profileId); + try { + await lease.page.goto(url, { waitUntil }); + return lease; + } catch (error) { + if (attempt !== 0 || !isClosedContextError(error)) throw error; + if (runtime?.context === lease.context) this.invalidateProfileRuntime(profileId, runtime); + if (!pageIsClosed(lease.page)) await lease.page.close().catch(() => {}); + return this.navigatePageAttempt(input, url, waitUntil, 1); + } + } + async selectPage(input: Pick & { pageId?: string; index?: number }): Promise { const profileId = normalizeProfileId(input.profileId); const sessionId = requireSessionId(input); @@ -917,10 +945,16 @@ export class CloakSessionManager { const popup = opener.waitForEvent('popup', { timeout: 1_000 }).catch(() => null); try { - await opener.evaluate(() => window.open('about:blank', '_blank', 'noopener')); + await opener.evaluate(() => window.open('about:blank', '_blank')); } catch {} const page = await popup; - if (page) return page; + if (page) { + const targetId = await this.targetIdForPage(runtime, page); + const windowId = await this.windowIdForTarget(runtime, targetId, page); + if (session.windowIds.has(windowId) || runtime.windowOwners.get(windowId) === undefined) return page; + if (!pageIsClosed(page)) await page.close().catch(() => {}); + throw new SessionWindowConflictError('unknown', session.id, runtime.windowOwners.get(windowId)); + } return this.createWindowPage(runtime, windowMode); } @@ -947,11 +981,11 @@ export class CloakSessionManager { return { runtime, session, page }; } - private async createWindowPage(runtime: ProfileRuntime, windowMode?: BrowserWindowMode): Promise { + private async createWindowPage(runtime: ProfileRuntime, windowMode?: BrowserWindowMode, newWindow = true): Promise { if (!runtime.cdp) return runtime.context.newPage(); const result = await runtime.cdp.send('Target.createTarget', { url: 'about:blank', - newWindow: true, + newWindow, background: windowMode === 'background', focus: windowMode !== 'background', }) as { targetId: string }; diff --git a/src/browser/runtime/provider.ts b/src/browser/runtime/provider.ts index c14d37d0..29a60298 100644 --- a/src/browser/runtime/provider.ts +++ b/src/browser/runtime/provider.ts @@ -13,8 +13,8 @@ export interface BrowserRuntimeProvider { resolveAdapterDefault?(command: BrowserRuntimeCommand): Promise; startSessionHandoff?(command: BrowserRuntimeCommand): Promise; clearSessionHandoff?(command: BrowserRuntimeCommand): Promise; - listSessions?(input: { profileId?: string }): Promise; + listSessions?(input: { profileId?: string; limit?: number }): Promise; closeSession?(command: BrowserRuntimeCommand): Promise<{ closed: boolean; alreadyIdle: boolean; session: string }>; - dispatch(command: BrowserRuntimeCommand): Promise; + dispatch(command: BrowserRuntimeCommand, signal?: AbortSignal): Promise; shutdown(): Promise; } diff --git a/src/browser/sessions.test.ts b/src/browser/sessions.test.ts index 8abf774c..bd8616bc 100644 --- a/src/browser/sessions.test.ts +++ b/src/browser/sessions.test.ts @@ -103,4 +103,59 @@ describe('LocalBrowserSessionStore', () => { expect(store.require('work', session.id).handoff).toBeUndefined(); expect(store.list('work')[0]?.handoff).toBeUndefined(); }); + + it('prunes explicit Sessions idle for 30 days while preserving adapter defaults and handoffs', () => { + const baseDir = tempDir(); + fs.writeFileSync(path.join(baseDir, 'browser-sessions.json'), `${JSON.stringify({ + version: 1, + sessions: [ + sessionRecord('session_old', 'explicit', '2026-07-11T23:59:59.000Z'), + sessionRecord('session_boundary', 'explicit', '2026-07-12T00:00:01.000Z'), + sessionRecord('session_handoff', 'explicit', '2026-07-01T00:00:00.000Z', { site: 'github', expiresAt: '2026-08-12T00:15:00.000Z' }), + sessionRecord('session_expired_handoff', 'explicit', '2026-07-01T00:00:00.000Z', { site: 'github', expiresAt: '2026-08-10T00:15:00.000Z' }), + sessionRecord('session_default', 'adapter-default', '2026-07-01T00:00:00.000Z'), + ], + })}\n`, { mode: 0o600 }); + + const rows = new LocalBrowserSessionStore({ + baseDir, + now: () => new Date('2026-08-11T00:00:00.000Z'), + }).list('work'); + + expect(rows.map((row) => row.id)).toEqual(['session_boundary', 'session_default', 'session_handoff']); + }); + + it('retains active Sessions and limits newest-first listings', () => { + const baseDir = tempDir(); + fs.writeFileSync(path.join(baseDir, 'browser-sessions.json'), `${JSON.stringify({ + version: 1, + sessions: [ + sessionRecord('session_active', 'explicit', '2026-07-01T00:00:00.000Z'), + sessionRecord('session_newest', 'explicit', '2026-08-10T00:00:00.000Z'), + sessionRecord('session_middle', 'explicit', '2026-08-09T00:00:00.000Z'), + ], + })}\n`, { mode: 0o600 }); + + const rows = new LocalBrowserSessionStore({ + baseDir, + now: () => new Date('2026-08-11T00:00:00.000Z'), + isActive: session => session.id === 'session_active', + }).list('work', 2); + + expect(rows.map((row) => row.id)).toEqual(['session_newest', 'session_middle']); + expect(new LocalBrowserSessionStore({ + baseDir, + now: () => new Date('2026-08-11T00:00:00.000Z'), + isActive: session => session.id === 'session_active', + }).find('work', 'session_active')).toBeDefined(); + }); }); + +function sessionRecord( + id: string, + kind: 'explicit' | 'adapter-default', + lastUsedAt: string, + handoff?: { site: string; expiresAt: string }, +) { + return { id, profileId: 'work', kind, createdAt: lastUsedAt, updatedAt: lastUsedAt, lastUsedAt, ...(handoff ? { handoff } : {}) }; +} diff --git a/src/browser/sessions.ts b/src/browser/sessions.ts index b425c6ec..ec3f085b 100644 --- a/src/browser/sessions.ts +++ b/src/browser/sessions.ts @@ -23,9 +23,11 @@ export interface LocalBrowserSessionStoreOptions { baseDir?: string; now?: () => Date; idFactory?: () => string; + isActive?: (record: BrowserSessionRecord) => boolean; } type StateFile = { version: 1; sessions: BrowserSessionRecord[] }; +const SESSION_RETENTION_MS = 30 * 24 * 60 * 60 * 1000; export class SessionNotFoundError extends CliError { constructor(sessionId: string, profileId: string) { @@ -53,11 +55,13 @@ export class LocalBrowserSessionStore { private readonly baseDir: string; private readonly now: () => Date; private readonly idFactory: () => string; + private readonly isActive: (record: BrowserSessionRecord) => boolean; constructor(opts: LocalBrowserSessionStoreOptions = {}) { this.baseDir = opts.baseDir ?? getWebcmdConfigDir(); this.now = opts.now ?? (() => new Date()); this.idFactory = opts.idFactory ?? (() => `session_${randomUUID()}`); + this.isActive = opts.isActive ?? (() => false); } create(profileId: string): BrowserSessionRecord { @@ -98,9 +102,14 @@ export class LocalBrowserSessionStore { return { ...record }; } - list(profileId?: string): BrowserSessionListRow[] { + list(profileId?: string, limit = 20): BrowserSessionListRow[] { + if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100) { + throw new CliError('INVALID_SESSION_LIMIT', 'Session list limit must be an integer from 1 to 100.', undefined, EXIT_CODES.USAGE_ERROR); + } const rows = this.load().sessions - .filter((row) => profileId === undefined || row.profileId === profileId); + .filter((row) => profileId === undefined || row.profileId === profileId) + .sort((left, right) => right.lastUsedAt.localeCompare(left.lastUsedAt) || left.id.localeCompare(right.id)) + .slice(0, limit); return rows.map((row) => ({ ...row, runtimeState: 'idle' as const })); } @@ -181,6 +190,15 @@ export class LocalBrowserSessionStore { changed = true; } } + const retained = state.sessions.filter((record) => { + const expired = record.kind === 'explicit' + && !record.handoff + && !this.isActive(record) + && Date.parse(record.lastUsedAt) <= now - SESSION_RETENTION_MS; + if (expired) changed = true; + return !expired; + }); + state.sessions = retained; if (changed) this.save(state); return state; } diff --git a/src/cli-argv-preprocess.test.ts b/src/cli-argv-preprocess.test.ts index 84b971df..05403fac 100644 --- a/src/cli-argv-preprocess.test.ts +++ b/src/cli-argv-preprocess.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { rejectPositionalBrowserSessionArgv } from './cli-argv-preprocess.js'; +import { rejectMisplacedSessionSelectorArgv, rejectPositionalBrowserSessionArgv } from './cli-argv-preprocess.js'; describe('rejectPositionalBrowserSessionArgv', () => { it('rejects retired positional browser sessions with the canonical replacement', () => { @@ -26,6 +26,18 @@ describe('rejectPositionalBrowserSessionArgv details', () => { }); }); +describe('rejectMisplacedSessionSelectorArgv', () => { + it('rejects trailing --session with a stable diagnostic code', () => { + expect(() => rejectMisplacedSessionSelectorArgv(['browser', 'run', '--session', 'session_a'])) + .toThrowError(/SESSION_SELECTOR_POSITION/); + }); + + it('keeps the root --session selector unchanged', () => { + expect(rejectMisplacedSessionSelectorArgv(['--session', 'session_a', 'browser', 'run'])) + .toEqual(['--session', 'session_a', 'browser', 'run']); + }); +}); + import { escapeLeadingDashPositional } from './cli-argv-preprocess.js'; describe('escapeLeadingDashPositional', () => { diff --git a/src/cli-argv-preprocess.ts b/src/cli-argv-preprocess.ts index b9bd22a5..cf212a2f 100644 --- a/src/cli-argv-preprocess.ts +++ b/src/cli-argv-preprocess.ts @@ -90,6 +90,21 @@ export function rejectPositionalBrowserSessionArgv(argv: readonly string[]): str ); } +export function rejectMisplacedSessionSelectorArgv(argv: readonly string[]): string[] { + const result = [...argv]; + const commandIndex = findRootCommandIndex(result); + for (let index = commandIndex + 1; index < result.length; index += 1) { + const token = result[index]; + if (token === '--') break; + if (token === '--session' || token.startsWith('--session=')) { + throw new BrowserSessionArgvError( + `SESSION_SELECTOR_POSITION: --session must appear before the command. Use: webcmd --session ${result.slice(0, commandIndex + 1).join(' ')}`, + ); + } + } + return result; +} + function findRootCommandIndex(argv: readonly string[]): number { let index = 0; while (index < argv.length) { diff --git a/src/cli.test.ts b/src/cli.test.ts index 8b921916..a43bdc17 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -1766,6 +1766,7 @@ describe('browser Session lifecycle commands', () => { }); it('closes an idle persisted Session as a no-op when daemon is absent', async () => { + mockSendCommand.mockRejectedValueOnce(new Error('daemon unavailable')); const baseDir = path.join(isolatedCliTestHome, '.webcmd'); fs.mkdirSync(baseDir, { recursive: true }); fs.writeFileSync(path.join(baseDir, 'browser-sessions.json'), JSON.stringify({ @@ -1782,7 +1783,11 @@ describe('browser Session lifecycle commands', () => { await createProgram('', '').parseAsync(['node', 'webcmd', 'session', 'close', 'session_idle', '-f', 'json']); - expect(mockSendCommand).not.toHaveBeenCalled(); + expect(mockSendCommand).toHaveBeenCalledWith('session-close', { + contextId: 'default', + session: 'session_idle', + force: false, + }); expect(JSON.parse(consoleLogSpy.mock.calls.flat().join('\n'))).toMatchObject({ closed: false, alreadyIdle: true, diff --git a/src/cli.ts b/src/cli.ts index eb30923a..64bb5c95 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -68,6 +68,14 @@ function parsePositiveIntOption(value: string | undefined, _label: string, fallb return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback; } +function parseSessionListLimit(value: string): number { + const parsed = Number(value); + if (!Number.isSafeInteger(parsed) || parsed < 1 || parsed > 100) { + throw new ArgumentError('Session list limit must be an integer from 1 to 100.'); + } + return parsed; +} + type BrowserNetworkItem = { url: string; method: string; @@ -807,15 +815,16 @@ export function createProgram(BUILTIN_CLIS: string, USER_CLIS: string, pluginsDi sessionCmd .command('list') .description('List browser Sessions for the selected Profile') + .option('--limit ', 'Maximum Sessions to return (1-100)', parseSessionListLimit, 20) .option('-f, --format ', 'Output format: table, json, yaml', 'table') .action(async (opts, command) => { const profileId = getSelectedProfileId(command); let rows: BrowserSessionListRow[]; const status = await fetchDaemonStatus({ contextId: profileId }); if (status?.runtimeConnected && !isDaemonStale(status, PKG_VERSION)) { - rows = await sendCommand('session-list', { contextId: profileId }) as BrowserSessionListRow[]; + rows = await sendCommand('session-list', { contextId: profileId, limit: opts.limit }) as BrowserSessionListRow[]; } else { - rows = new LocalBrowserSessionStore().list(profileId); + rows = new LocalBrowserSessionStore().list(profileId, opts.limit); } const output = rows.map((row) => ({ ...row, handoff: formatHandoff(row) })); if (output.length === 0 && String(opts.format ?? 'table') === 'table') { @@ -830,12 +839,26 @@ export function createProgram(BUILTIN_CLIS: string, USER_CLIS: string, pluginsDi .description('Close a browser Session runtime without deleting its durable record') .argument('', 'Existing opaque Session ID from `webcmd session create`') .option('-f, --format ', 'Output format: table, json, yaml', 'yaml') - .action(async (sessionId: string, opts, command) => { + .option('--force', 'Close even while the Session is busy or paused for handoff') + .action(async (sessionId: string, opts: { format?: string; force?: boolean }, command) => { const profileId = getSelectedProfileId(command); requireSessionIdShape(sessionId); const status = await fetchDaemonStatus({ contextId: profileId }); - if (status?.runtimeConnected && !isDaemonStale(status, PKG_VERSION)) { - const data = await sendCommand('session-close', { contextId: profileId, session: sessionId }); + if (!status || (status.runtimeConnected && !isDaemonStale(status, PKG_VERSION))) { + try { + const data = await sendCommand('session-close', { + contextId: profileId, + session: sessionId, + force: opts.force === true, + }); + await renderOutput(data, { fmt: opts.format }); + return; + } catch (error) { + if (status || opts.force === true) throw error; + } + } + if (opts.force === true) { + const data = await sendCommand('session-close', { contextId: profileId, session: sessionId, force: true }); await renderOutput(data, { fmt: opts.format }); return; } diff --git a/src/daemon/server.test.ts b/src/daemon/server.test.ts index 61cc9519..bfcd8528 100644 --- a/src/daemon/server.test.ts +++ b/src/daemon/server.test.ts @@ -13,7 +13,7 @@ class FakeProvider implements BrowserRuntimeProvider { foregroundedSessions: string[] = []; shutdownCalled = false; delayMs = 0; - dispatchImpl?: (command: BrowserRuntimeCommand) => Promise; + dispatchImpl?: (command: BrowserRuntimeCommand, signal?: AbortSignal) => Promise; resolveProfileId?: (command: BrowserRuntimeCommand) => string; sessionId = 'session_11111111-1111-4111-8111-111111111111'; @@ -94,9 +94,9 @@ class FakeProvider implements BrowserRuntimeProvider { return record; } - async dispatch(command: BrowserRuntimeCommand) { + async dispatch(command: BrowserRuntimeCommand, signal?: AbortSignal) { this.commands.push(command); - if (this.dispatchImpl) return this.dispatchImpl(command); + if (this.dispatchImpl) return this.dispatchImpl(command, signal); if (this.delayMs > 0) await new Promise((resolve) => setTimeout(resolve, this.delayMs)); return this.result(command); } @@ -279,8 +279,9 @@ describe('createDaemonServer', () => { let settle!: () => void; const provider = new FakeProvider(); provider.activeSessions.add('session_a'); - provider.dispatchImpl = (command) => new Promise((resolve) => { + provider.dispatchImpl = (command, signal) => new Promise((resolve) => { settle = () => resolve({ id: command.id, ok: true, data: 'done' }); + signal?.addEventListener('abort', settle, { once: true }); }); const { baseUrl } = await start(provider); @@ -314,6 +315,80 @@ describe('createDaemonServer', () => { } }); + it('force-closes a Session while work is active', async () => { + let settle!: () => void; + let settled = false; + const provider = new FakeProvider(); + provider.activeSessions.add('session_a'); + provider.dispatchImpl = (command, signal) => new Promise((resolve) => { + settle = () => { + settled = true; + resolve({ id: command.id, ok: true, data: 'done' }); + }; + signal?.addEventListener('abort', settle, { once: true }); + }); + const { baseUrl } = await start(provider); + + const active = postCommand(baseUrl, { + id: 'active-force-work', + action: 'exec', + surface: 'browser', + session: 'session_a', + runId: 'run_100_1_2', + command: 'browser/run', + }); + try { + await vi.waitFor(() => expect(provider.commands).toHaveLength(1)); + const closeRequest = postCommand(baseUrl, { + id: 'force-close-active-session', + action: 'session-close', + contextId: 'default', + session: 'session_a', + force: true, + }); + await vi.waitFor(() => expect(provider.activeSessions).not.toContain('session_a')); + const close = await closeRequest; + expect(settled).toBe(true); + expect(close.status).toBe(200); + await expect(close.json()).resolves.toMatchObject({ + ok: true, + data: { + closed: true, + alreadyIdle: false, + session: 'session_a', + displaced: 1, + clearedHandoff: false, + }, + }); + } finally { + settle(); + await active.catch(() => undefined); + } + provider.dispatchImpl = async (command) => ({ id: command.id, ok: true }); + const admitted = await postCommand(baseUrl, { + id: 'after-force-settled', action: 'exec', surface: 'browser', session: 'session_a', + runId: 'run_200_2_2', command: 'browser/run', + }); + expect(admitted.status).toBe(200); + }); + + it('force-closes every site-partitioned lease in an adapter-default Session', async () => { + const provider = new FakeProvider(); + provider.activeSessions.add('session_default'); + const { baseUrl } = await start(provider); + expect((await postCommand(baseUrl, adapterCommand('github-owner', 'run_100_1_1', 'github'))).status).toBe(200); + expect((await postCommand(baseUrl, adapterCommand('linkedin-owner', 'run_200_2_2', 'linkedin'))).status).toBe(200); + + const close = await postCommand(baseUrl, { + id: 'force-close-default', action: 'session-close', contextId: 'default', + session: 'session_default', force: true, + }); + const status = await fetch(`${baseUrl}/status`, { headers: { [DAEMON_HEADER_NAME]: '1' } }); + + expect(close.status).toBe(200); + await expect(status.json()).resolves.toMatchObject({ sessionLeases: [] }); + }); + it('accepts the maximum browser-run source envelope', async () => { const { provider, baseUrl } = await start(); const source = 'x'.repeat(256 * 1024); diff --git a/src/daemon/server.ts b/src/daemon/server.ts index ad6c025e..dbd63f18 100644 --- a/src/daemon/server.ts +++ b/src/daemon/server.ts @@ -25,6 +25,7 @@ interface PendingCommand { promise: Promise; runId?: string; leaseKey?: string; + abortController: AbortController; } function commandTimeoutMs(command: BrowserRuntimeCommand): number { @@ -155,7 +156,7 @@ async function handleSessionLifecycle( return session ? { id: command.id, ok: true, data: session } : null; } case 'session-list': { - const sessions = await provider.listSessions?.({ profileId: commandProfileId(provider, command) }); + const sessions = await provider.listSessions?.({ profileId: commandProfileId(provider, command), limit: command.limit }); return sessions ? { id: command.id, ok: true, data: sessions } : null; } case 'session-close': { @@ -207,6 +208,7 @@ export function createDaemonServer(provider: BrowserRuntimeProvider, opts: Daemo const logBuffer: Array<{ level: string; msg: string; ts: number }> = []; const pending = new Map(); const leases = new SessionLeaseRegistry(); + const forceClosingRuns = new Set(); const hasPendingWork = (runId: string) => [...pending.values()].some((entry) => entry.runId === runId); let shutdownStarted = false; @@ -303,6 +305,11 @@ export function createDaemonServer(provider: BrowserRuntimeProvider, opts: Daemo return; } if (body.action === 'lease-release' || body.action === 'run-cancel') { + const matching = body.action === 'run-cancel' && typeof body.runId === 'string' + ? [...pending.values()].filter((entry) => entry.runId === body.runId) + : []; + matching.forEach((entry) => entry.abortController.abort()); + await Promise.allSettled(matching.map((entry) => entry.promise)); const released = typeof body.runId === 'string' ? leases.releaseByRunId(body.runId) : 0; jsonResponse(res, 200, { id: body.id, ok: true, data: { released } }); return; @@ -316,17 +323,18 @@ export function createDaemonServer(provider: BrowserRuntimeProvider, opts: Daemo } const resolved = await resolveBrowserSession(provider, body); const resolvedBody = resolved.command; - const activeSessionHolder = (sessionKey: string) => { - const holder = leases.list(hasPendingWork).find((lease) => ( - lease.key === sessionKey - || lease.key.startsWith(`${sessionKey}␟`) - || sessionKey.startsWith(`${lease.key}␟`) - )); + const activeSessionLeases = (sessionKey: string) => leases.list(hasPendingWork).filter((lease) => ( + lease.key === sessionKey + || lease.key.startsWith(`${sessionKey}␟`) + || sessionKey.startsWith(`${lease.key}␟`) + )); + const publicSessionHolder = (sessionKey: string) => { + const holder = activeSessionLeases(sessionKey)[0]; if (!holder) return null; const { key: _key, runId: _runId, ...publicHolder } = holder; return publicHolder; }; - if (resolved.session) { + if (resolved.session && !(resolvedBody.action === 'session-close' && resolvedBody.force === true)) { const paused = handoffPauseResult(resolvedBody, resolved.session); if (paused) { jsonResponse(res, 409, paused); @@ -335,14 +343,38 @@ export function createDaemonServer(provider: BrowserRuntimeProvider, opts: Daemo } if (resolvedBody.action === 'session-close') { const profileId = commandProfileId(provider, resolvedBody) ?? 'default'; - const holder = activeSessionHolder(getSessionLeaseKey(profileId, resolvedBody.sessionId!)); - if (holder) { - jsonResponse(res, 409, { ok: false, code: 'session_busy', holder }); + const sessionKey = getSessionLeaseKey(profileId, resolvedBody.sessionId!); + const holders = activeSessionLeases(sessionKey); + const holder = holders[0]; + if (holder && resolvedBody.force !== true) { + jsonResponse(res, 409, { ok: false, code: 'session_busy', holder: publicSessionHolder(sessionKey) }); return; } + const forcedRunIds = resolvedBody.force === true + ? new Set(holders.map((lease) => lease.runId)) + : new Set(); + const forcedCommands = [...pending.values()] + .filter((entry) => entry.runId && forcedRunIds.has(entry.runId)) + .map((entry) => { + entry.abortController.abort(); + return entry.promise; + }); + forcedRunIds.forEach((runId) => forceClosingRuns.add(runId)); const lifecycleResult = await handleSessionLifecycle(provider, resolvedBody); if (lifecycleResult) { - jsonResponse(res, 200, lifecycleResult); + await Promise.allSettled(forcedCommands); + for (const runId of forcedRunIds) { + leases.releaseByRunId(runId); + forceClosingRuns.delete(runId); + } + jsonResponse(res, 200, resolvedBody.force === true ? { + ...lifecycleResult, + data: { + ...(lifecycleResult.data as Record), + displaced: forcedRunIds.size, + clearedHandoff: Boolean(resolved.session?.handoff), + }, + } : lifecycleResult); return; } } @@ -377,11 +409,13 @@ export function createDaemonServer(provider: BrowserRuntimeProvider, opts: Daemo return; } } - const commandPromise = provider.dispatch(resolvedBody).finally(() => { + const abortController = new AbortController(); + const commandPromise = provider.dispatch(resolvedBody, abortController.signal).finally(() => { if (leaseKey && runId) leases.heartbeat(leaseKey, runId); pending.delete(body.id); + if (runId && forceClosingRuns.delete(runId)) leases.releaseByRunId(runId); }); - pending.set(body.id, { promise: commandPromise, runId, leaseKey }); + pending.set(body.id, { promise: commandPromise, runId, leaseKey, abortController }); const result = await waitForCommandResult(body, commandPromise); if (!result.ok) pushLog('warn', `Command ${body.id} failed: ${result.error ?? result.errorCode ?? 'unknown error'}`); jsonResponse(res, result.ok ? 200 : result.errorCode === 'command_result_unknown' ? 408 : 400, result); diff --git a/src/hosted/client.test.ts b/src/hosted/client.test.ts index 69b80d41..6cfed57a 100644 --- a/src/hosted/client.test.ts +++ b/src/hosted/client.test.ts @@ -224,7 +224,7 @@ describe('HostedClient', () => { }); it('sends bearer auth and parses hosted manifest', async () => { - const requests: Array<{ url: string; authorization: string | null }> = []; + const requests: Array<{ url: string; authorization: string | null; sessionProtocol: string | null }> = []; const client = new HostedClient({ apiBaseUrl: 'https://api.example.com/', apiKey: 'wcmd_live_test', @@ -232,6 +232,7 @@ describe('HostedClient', () => { requests.push({ url: String(url), authorization: new Headers(init?.headers).get('authorization'), + sessionProtocol: new Headers(init?.headers).get('x-webcmd-session-protocol-version'), }); return new Response(JSON.stringify({ ok: true, @@ -239,6 +240,7 @@ describe('HostedClient', () => { userId: 'user_demo', metadata: { contractSchemaVersion: 1, + sessionProtocolVersion: 1, webcmdPackageVersion: '0.3.0', generatedAt: 'now', }, @@ -252,12 +254,17 @@ describe('HostedClient', () => { userId: 'user_demo', metadata: { contractSchemaVersion: 1, + sessionProtocolVersion: 1, webcmdPackageVersion: '0.3.0', generatedAt: 'now', }, commands: [], }); - expect(requests).toEqual([{ url: 'https://api.example.com/v1/manifest', authorization: 'Bearer wcmd_live_test' }]); + expect(requests).toEqual([{ + url: 'https://api.example.com/v1/manifest', + authorization: 'Bearer wcmd_live_test', + sessionProtocol: '1', + }]); }); it('accepts boolean freshPage command metadata', async () => { @@ -270,6 +277,7 @@ describe('HostedClient', () => { userId: 'user_demo', metadata: { contractSchemaVersion: 1, + sessionProtocolVersion: 1, webcmdPackageVersion: '0.3.0', generatedAt: 'now', }, @@ -294,6 +302,22 @@ describe('HostedClient', () => { }); }); + it('reports a missing Session protocol capability as a contract mismatch', async () => { + const client = new HostedClient({ + apiBaseUrl: 'https://api.example.com', apiKey: 'key', + fetchImpl: async () => new Response(JSON.stringify({ + ok: true, + manifest: { + userId: 'user_demo', + metadata: { contractSchemaVersion: 1, webcmdPackageVersion: '0.6.1', generatedAt: 'now' }, + commands: [], + }, + }), { status: 200 }), + }); + + await expect(client.getManifest()).rejects.toMatchObject({ code: 'HOSTED_CONTRACT_MISMATCH' }); + }); + it('accepts string-array search metadata in hosted manifest commands', async () => { const client = new HostedClient({ apiBaseUrl: 'https://api.example.com', @@ -304,6 +328,7 @@ describe('HostedClient', () => { userId: 'user_demo', metadata: { contractSchemaVersion: 1, + sessionProtocolVersion: 1, webcmdPackageVersion: '0.3.0', generatedAt: 'now', }, @@ -342,6 +367,7 @@ describe('HostedClient', () => { userId: 'user_demo', metadata: { contractSchemaVersion: 1, + sessionProtocolVersion: 1, webcmdPackageVersion: '0.3.0', generatedAt: 'now', }, @@ -366,6 +392,7 @@ describe('HostedClient', () => { userId: 'user_demo', metadata: { contractSchemaVersion: 1, + sessionProtocolVersion: 1, webcmdPackageVersion: '0.3.0', generatedAt: 'now', }, @@ -942,7 +969,7 @@ describe('HostedClient', () => { name: 'metadata with wrong field type', manifest: { userId: 'user_demo', - metadata: { contractSchemaVersion: '1', webcmdPackageVersion: '0.3.0', generatedAt: 'now' }, + metadata: { contractSchemaVersion: '1', sessionProtocolVersion: 1, webcmdPackageVersion: '0.3.0', generatedAt: 'now' }, commands: [], }, }, @@ -950,7 +977,7 @@ describe('HostedClient', () => { name: 'command without an args array', manifest: { userId: 'user_demo', - metadata: { contractSchemaVersion: 1, webcmdPackageVersion: '0.3.0', generatedAt: 'now' }, + metadata: { contractSchemaVersion: 1, sessionProtocolVersion: 1, webcmdPackageVersion: '0.3.0', generatedAt: 'now' }, commands: [{ site: 'github', name: 'whoami', command: 'github/whoami' }], }, }, @@ -958,7 +985,7 @@ describe('HostedClient', () => { name: 'command with malformed argument metadata', manifest: { userId: 'user_demo', - metadata: { contractSchemaVersion: 1, webcmdPackageVersion: '0.3.0', generatedAt: 'now' }, + metadata: { contractSchemaVersion: 1, sessionProtocolVersion: 1, webcmdPackageVersion: '0.3.0', generatedAt: 'now' }, commands: [{ site: 'github', name: 'whoami', command: 'github/whoami', description: 'x', access: 'read', strategy: 'PUBLIC', browser: false, args: [{ name: 42 }], @@ -969,7 +996,7 @@ describe('HostedClient', () => { name: 'command with a private field', manifest: { userId: 'user_demo', - metadata: { contractSchemaVersion: 1, webcmdPackageVersion: '0.3.0', generatedAt: 'now' }, + metadata: { contractSchemaVersion: 1, sessionProtocolVersion: 1, webcmdPackageVersion: '0.3.0', generatedAt: 'now' }, commands: [{ site: 'github', name: 'whoami', command: 'github/whoami', description: 'x', access: 'read', strategy: 'PUBLIC', browser: false, args: [], columns: [], internalPath: '/srv/private/token.json', @@ -980,7 +1007,7 @@ describe('HostedClient', () => { name: 'private wrapper field', manifest: { userId: 'user_demo', - metadata: { contractSchemaVersion: 1, webcmdPackageVersion: '0.3.0', generatedAt: 'now' }, + metadata: { contractSchemaVersion: 1, sessionProtocolVersion: 1, webcmdPackageVersion: '0.3.0', generatedAt: 'now' }, commands: [], }, wrapperExtra: { internalPath: '/srv/private/token.json' }, diff --git a/src/hosted/client.ts b/src/hosted/client.ts index d0a9ed44..9b6fd447 100644 --- a/src/hosted/client.ts +++ b/src/hosted/client.ts @@ -1,4 +1,5 @@ import { attachTraceReceipt, CliError, EXIT_CODES, type ExitCode } from '../errors.js'; +import { HOSTED_SESSION_PROTOCOL_VERSION } from './types.js'; import type { HostedBrowserActionRequest, HostedBrowserActionResponse, @@ -85,6 +86,31 @@ export class HostedClient { async getManifest(): Promise { const body = await this.request('/v1/manifest'); + const manifestMetadata = isRecord(body) + && isRecord(body.manifest) + && isRecord(body.manifest.metadata) + ? body.manifest.metadata + : undefined; + const sessionProtocolVersion = manifestMetadata?.sessionProtocolVersion; + if ( + manifestMetadata + && ( + sessionProtocolVersion === undefined + || ( + typeof sessionProtocolVersion === 'number' + && Number.isInteger(sessionProtocolVersion) + && sessionProtocolVersion > 0 + && sessionProtocolVersion !== HOSTED_SESSION_PROTOCOL_VERSION + ) + ) + ) { + throw new HostedClientError( + 'HOSTED_CONTRACT_MISMATCH', + 'Webcmd Cloud manifest does not match this installed Webcmd hosted contract.', + 'Upgrade Webcmd or use a compatible Webcmd Cloud endpoint.', + EXIT_CODES.CONFIG_ERROR, + ); + } if (!hasExactKeys(body, ['ok', 'manifest']) || !isHostedManifest(body.manifest)) { throw protocolError('Webcmd Cloud returned an invalid manifest.'); } @@ -126,8 +152,11 @@ export class HostedClient { return body; } - async closeBrowserSession(session: string, profile?: string): Promise { - const body = await this.request(`/v1/sessions/${encodeURIComponent(session)}${profileQuery(profile)}`, { method: 'DELETE' }); + async closeBrowserSession(session: string, profile?: string, force = false): Promise { + const body = await this.request(`/v1/sessions/${encodeURIComponent(session)}/close${profileQuery(profile)}`, { + method: 'POST', + body: JSON.stringify(force ? { force: true } : {}), + }); if (!isHostedBrowserSessionCloseResponse(body)) { throw protocolError('Webcmd Cloud returned an invalid browser session close response.'); } @@ -362,6 +391,7 @@ export class HostedClient { accept: 'application/json', ...(init.body ? { 'content-type': 'application/json' } : {}), authorization: `Bearer ${this.apiKey}`, + 'x-webcmd-session-protocol-version': String(HOSTED_SESSION_PROTOCOL_VERSION), ...(this.workspace ? { 'x-webcmd-workspace': this.workspace } : {}), ...(init.headers ?? {}), }, @@ -421,10 +451,13 @@ function isHostedError(value: unknown): value is HostedErrorResponse { function isHostedManifest(value: unknown): value is HostedManifest { return hasExactKeys(value, ['userId', 'metadata', 'commands']) && typeof value.userId === 'string' - && hasExactKeys(value.metadata, ['contractSchemaVersion', 'webcmdPackageVersion', 'generatedAt']) + && hasExactKeys(value.metadata, ['contractSchemaVersion', 'sessionProtocolVersion', 'webcmdPackageVersion', 'generatedAt']) && typeof value.metadata.contractSchemaVersion === 'number' && Number.isInteger(value.metadata.contractSchemaVersion) && value.metadata.contractSchemaVersion > 0 + && typeof value.metadata.sessionProtocolVersion === 'number' + && Number.isInteger(value.metadata.sessionProtocolVersion) + && value.metadata.sessionProtocolVersion > 0 && typeof value.metadata.webcmdPackageVersion === 'string' && typeof value.metadata.generatedAt === 'string' && Array.isArray(value.commands) diff --git a/src/hosted/contract.test.ts b/src/hosted/contract.test.ts index 258597ab..94bc60a2 100644 --- a/src/hosted/contract.test.ts +++ b/src/hosted/contract.test.ts @@ -127,6 +127,8 @@ describe('buildHostedContract', () => { expect(contract).toEqual({ schemaVersion: HOSTED_CONTRACT_SCHEMA_VERSION, + sessionProtocolVersion: 1, + sessionSelectorPosition: 'root', webcmdVersion: '9.8.7', outputFormats: ['table', 'plain', 'json', 'yaml', 'md', 'csv'], traceModes: ['off', 'on', 'retain-on-failure'], diff --git a/src/hosted/contract.ts b/src/hosted/contract.ts index e31118e5..3d527af5 100644 --- a/src/hosted/contract.ts +++ b/src/hosted/contract.ts @@ -7,6 +7,8 @@ import { deriveHostedAvailability, type HostedAvailability, } from './availability.js'; +import { ROOT_SESSION_SELECTOR_POSITION } from '../root-command-surface.js'; +import { HOSTED_SESSION_PROTOCOL_VERSION } from './types.js'; export const HOSTED_CONTRACT_SCHEMA_VERSION = 1 as const; @@ -92,6 +94,8 @@ export interface HostedContractCommand { export interface HostedContract { schemaVersion: typeof HOSTED_CONTRACT_SCHEMA_VERSION; + sessionProtocolVersion: typeof HOSTED_SESSION_PROTOCOL_VERSION; + sessionSelectorPosition: typeof ROOT_SESSION_SELECTOR_POSITION; webcmdVersion: string; outputFormats: Array<'table' | 'plain' | 'json' | 'yaml' | 'md' | 'csv'>; traceModes: Array<'off' | 'on' | 'retain-on-failure'>; @@ -338,6 +342,8 @@ export function buildHostedContract( return { schemaVersion: HOSTED_CONTRACT_SCHEMA_VERSION, + sessionProtocolVersion: HOSTED_SESSION_PROTOCOL_VERSION, + sessionSelectorPosition: ROOT_SESSION_SELECTOR_POSITION, webcmdVersion: packageVersion, outputFormats: shared.outputFormats, traceModes: shared.traceModes, diff --git a/src/hosted/main-lifecycle.test.ts b/src/hosted/main-lifecycle.test.ts index be5868d1..b5abbb77 100644 --- a/src/hosted/main-lifecycle.test.ts +++ b/src/hosted/main-lifecycle.test.ts @@ -192,6 +192,7 @@ async function createHostedFixture(outcome: 'success' | 'failure'): Promise<{ userId: 'user_lifecycle', metadata: { contractSchemaVersion: 1, + sessionProtocolVersion: 1, webcmdPackageVersion: PKG_VERSION, generatedAt: '2026-07-14T00:00:00.000Z', }, diff --git a/src/hosted/manifest.test.ts b/src/hosted/manifest.test.ts index 2dfc0c11..ab80c9c7 100644 --- a/src/hosted/manifest.test.ts +++ b/src/hosted/manifest.test.ts @@ -28,7 +28,8 @@ const manifest: HostedManifest = { userId: 'user_demo', metadata: { contractSchemaVersion: 1, - webcmdPackageVersion: PKG_VERSION, + sessionProtocolVersion: 1, + webcmdPackageVersion: PKG_VERSION, generatedAt: '2026-07-08T00:00:00.000Z', }, commands: [ diff --git a/src/hosted/output-parity.test.ts b/src/hosted/output-parity.test.ts index 2cfd361c..3945c900 100644 --- a/src/hosted/output-parity.test.ts +++ b/src/hosted/output-parity.test.ts @@ -32,7 +32,8 @@ const manifest = { userId: 'user_demo', metadata: { contractSchemaVersion: 1, - webcmdPackageVersion: PKG_VERSION, + sessionProtocolVersion: 1, + webcmdPackageVersion: PKG_VERSION, generatedAt: '2026-07-14T00:00:00.000Z', }, commands: [{ diff --git a/src/hosted/root-command-surface.test.ts b/src/hosted/root-command-surface.test.ts index d40dbee1..6da56199 100644 --- a/src/hosted/root-command-surface.test.ts +++ b/src/hosted/root-command-surface.test.ts @@ -38,7 +38,8 @@ const manifest = { userId: 'user_demo', metadata: { contractSchemaVersion: 1, - webcmdPackageVersion: PKG_VERSION, + sessionProtocolVersion: 1, + webcmdPackageVersion: PKG_VERSION, generatedAt: '2026-07-08T00:00:00.000Z', }, commands: [{ diff --git a/src/hosted/runner.test.ts b/src/hosted/runner.test.ts index 90009ac7..2b8a0faa 100644 --- a/src/hosted/runner.test.ts +++ b/src/hosted/runner.test.ts @@ -32,7 +32,8 @@ const manifest = { userId: 'user_demo', metadata: { contractSchemaVersion: 1, - webcmdPackageVersion: PKG_VERSION, + sessionProtocolVersion: 1, + webcmdPackageVersion: PKG_VERSION, generatedAt: '2026-07-08T00:00:00.000Z', }, commands: [ @@ -545,7 +546,7 @@ describe('runHostedCli', () => { expect(requests.some(request => request.url.endsWith('/v1/manifest'))).toBe(false); }); - it('manages hosted browser sessions without contacting the local daemon or manifest', async () => { + it('preflights the hosted contract before managing browser Sessions', async () => { const requests: Array<{ url: string; method: string; body?: unknown }> = []; const session = { id: 'session_abc', kind: 'browser', profileId: 'profile_work', runtimeState: 'idle', @@ -557,15 +558,18 @@ describe('runHostedCli', () => { ...(init?.body ? { body: JSON.parse(String(init.body)) } : {}), }; requests.push(request); - if (request.method === 'POST') return new Response(JSON.stringify({ ok: true, result: session })); - if (request.method === 'DELETE') return new Response(JSON.stringify({ ok: true, result: { closed: false, alreadyIdle: true, session: session.id } })); + if (request.url.endsWith('/v1/manifest')) return manifestResponse(); + if (request.method === 'POST' && request.url.endsWith('/v1/sessions')) return new Response(JSON.stringify({ ok: true, result: session })); + if (request.method === 'POST' && request.url.endsWith(`/v1/sessions/${session.id}/close?profile=work`)) { + return new Response(JSON.stringify({ ok: true, result: { closed: false, alreadyIdle: true, session: session.id } })); + } return new Response(JSON.stringify({ ok: true, result: [session] })); }); for (const argv of [ ['--profile', 'work', 'session', 'create', '-f', 'json'], ['--profile', 'work', 'session', 'list', '-f', 'json'], - ['--profile', 'work', 'session', 'close', session.id, '-f', 'json'], + ['--profile', 'work', 'session', 'close', session.id, '--force', '-f', 'json'], ]) { const stdout = sink(); const result = await runHostedCli(argv, { @@ -578,11 +582,28 @@ describe('runHostedCli', () => { } expect(requests).toEqual([ + { url: 'https://api.example.com/v1/manifest', method: 'GET' }, { url: 'https://api.example.com/v1/sessions', method: 'POST', body: { profile: 'work' } }, + { url: 'https://api.example.com/v1/manifest', method: 'GET' }, { url: 'https://api.example.com/v1/sessions?profile=work', method: 'GET' }, - { url: 'https://api.example.com/v1/sessions/session_abc?profile=work', method: 'DELETE' }, + { url: 'https://api.example.com/v1/manifest', method: 'GET' }, + { url: 'https://api.example.com/v1/sessions/session_abc/close?profile=work', method: 'POST', body: { force: true } }, ]); - expect(requests.some(request => request.url.endsWith('/v1/manifest'))).toBe(false); + }); + + it('rejects Session lifecycle calls when the hosted Session protocol differs', async () => { + const stderr = sink(); + const result = await runHostedCli(['session', 'list', '-f', 'json'], { + config: makeHostedConfig({ apiBaseUrl: 'https://api.example.com', apiKey: 'key' }), + stderr: stderr.stream, + fetchImpl: async () => new Response(JSON.stringify({ + ok: true, + manifest: { ...manifest, metadata: { ...manifest.metadata, sessionProtocolVersion: 99 } }, + }), { status: 200 }), + }); + + expect(result.exitCode).toBe(78); + expect(stderr.text()).toMatch(/HOSTED_CONTRACT_MISMATCH/); }); it.each(['create', 'get'])('rejects the removed profile %s subcommand', async (command) => { @@ -1648,10 +1669,26 @@ describe('runHostedCli', () => { }); expect(result.exitCode).toBe(1); - expect(stderr.text()).toMatch(/HOSTED_PROTOCOL|hosted contract/i); + expect(stderr.text()).toMatch(/HOSTED_CONTRACT_MISMATCH|hosted contract/i); expect(requests).toEqual(['https://api.example.com/v1/manifest']); }); + it('rejects a manifest whose Session protocol differs before execution', async () => { + const stderr = sink(); + const mismatched = { + ...manifest, + metadata: { ...manifest.metadata, sessionProtocolVersion: 99 }, + }; + const result = await runHostedCli(['github', 'whoami'], { + config: makeHostedConfig({ apiBaseUrl: 'https://api.example.com', apiKey: 'key' }), + stderr: stderr.stream, + fetchImpl: async () => new Response(JSON.stringify({ ok: true, manifest: mismatched }), { status: 200 }), + }); + + expect(result.exitCode).toBe(78); + expect(stderr.text()).toMatch(/HOSTED_CONTRACT_MISMATCH/); + }); + it('writes a result larger than 1 MiB completely through injected stdout', async () => { const value = 'x'.repeat((1024 * 1024) + 31); const stdout = sink(); diff --git a/src/hosted/runner.ts b/src/hosted/runner.ts index 602d7075..464c2f55 100644 --- a/src/hosted/runner.ts +++ b/src/hosted/runner.ts @@ -11,7 +11,7 @@ import { configurePluginUninstallSurface, configurePluginUpdateSurface, } from '../builtin-command-surface.js'; -import { BrowserSessionArgvError, rejectPositionalBrowserSessionArgv } from '../cli-argv-preprocess.js'; +import { BrowserSessionArgvError, rejectMisplacedSessionSelectorArgv, rejectPositionalBrowserSessionArgv } from '../cli-argv-preprocess.js'; import { CommanderStructuralError, MissingRequiredPositionalError } from '../command-surface.js'; import { filterCommandsByTag, formatRootHelp, getCommandCompletionCandidates } from '../command-presentation.js'; import { @@ -32,6 +32,7 @@ import { BrowserRunError } from '../browser/run/types.js'; import { CLI_COMMAND } from '../brand.js'; import { missingPluginGuidance } from '../discovery.js'; import { HostedClient, HostedClientError, resolveWorkspace } from './client.js'; +import { HOSTED_SESSION_PROTOCOL_VERSION } from './types.js'; import { parseHostedInvocation } from './args.js'; import { HostedBrowserHelp, parseHostedBrowserStructure, validateRawBrowserSession } from './browser-args.js'; import { materializeHostedOutputs, prepareHostedFiles, rewriteHostedOutputResultPaths } from './files.js'; @@ -93,7 +94,7 @@ export async function runHostedCli(argv: string[], opts: HostedRunnerOptions = { const stderr = opts.stderr ?? process.stderr; try { - argv = rejectPositionalBrowserSessionArgv(argv); + argv = rejectMisplacedSessionSelectorArgv(rejectPositionalBrowserSessionArgv(argv)); const credential = await resolveHostedApiKey(config, { credentialStore: opts.credentialStore, env: opts.env, @@ -197,6 +198,8 @@ async function dispatchHosted( await writeToStream(stdout, parsed.output); return; } + const manifest = await client.getManifest(); + validateManifestContractIdentity(manifest); await dispatchHostedSession(parsed, client, stdout, normalized.profile); return; } @@ -446,7 +449,7 @@ async function dispatchHosted( type ParsedHostedSessionSurface = | { kind: 'help'; output: string } - | { kind: 'run'; command: 'create' | 'list' | 'close'; format: string; session?: string }; + | { kind: 'run'; command: 'create' | 'list' | 'close'; format: string; session?: string; force?: boolean }; function parseHostedSessionSurface(argv: readonly string[], literal: boolean): ParsedHostedSessionSurface { let stdout = ''; @@ -463,8 +466,8 @@ function parseHostedSessionSurface(argv: readonly string[], literal: boolean): P const configure = (command: Command, format: string): Command => command.option('-f, --format ', 'Output format: table, json, yaml', format); configure(session.command('create'), 'yaml').action((options: { format: string }) => { parsed = { kind: 'run', command: 'create', format: options.format }; }); configure(session.command('list'), 'table').action((options: { format: string }) => { parsed = { kind: 'run', command: 'list', format: options.format }; }); - configure(session.command('close').argument(''), 'yaml').action((sessionId: string, options: { format: string }) => { - parsed = { kind: 'run', command: 'close', format: options.format, session: sessionId }; + configure(session.command('close').argument('').option('--force', 'Close even while the Session is busy or paused for handoff'), 'yaml').action((sessionId: string, options: { format: string; force?: boolean }) => { + parsed = { kind: 'run', command: 'close', format: options.format, session: sessionId, force: options.force === true }; }); try { root.parse(literal ? ['--', 'session', ...argv] : ['session', ...argv], { from: 'user' }); @@ -496,7 +499,7 @@ async function dispatchHostedSession( await renderOutput(rows, { fmt: parsed.format, columns: ['id', 'kind', 'runtimeState'], stdout }); return; } - await renderOutput((await client.closeBrowserSession(parsed.session!, profile)).result, { fmt: parsed.format, stdout }); + await renderOutput((await client.closeBrowserSession(parsed.session!, profile, parsed.force === true)).result, { fmt: parsed.format, stdout }); } function hasPresentFileArgument( @@ -1190,12 +1193,13 @@ function validateManifestContractIdentity(manifest: HostedManifest): void { const manifestLine = hostedContractCompatibilityLine(manifest.metadata.webcmdPackageVersion); if ( manifest.metadata.contractSchemaVersion !== installed.schemaVersion + || manifest.metadata.sessionProtocolVersion !== HOSTED_SESSION_PROTOCOL_VERSION || !installedLine || !manifestLine || manifestLine !== installedLine ) { throw new HostedClientError( - 'HOSTED_PROTOCOL', + 'HOSTED_CONTRACT_MISMATCH', 'Webcmd Cloud manifest does not match this installed Webcmd hosted contract.', ); } diff --git a/src/hosted/types.ts b/src/hosted/types.ts index 04b835b4..b02da089 100644 --- a/src/hosted/types.ts +++ b/src/hosted/types.ts @@ -1,6 +1,8 @@ import type { CommandSurfaceMetadata } from '../command-surface.js'; import type { Arg } from '../registry.js'; +export const HOSTED_SESSION_PROTOCOL_VERSION = 1 as const; + export type HostedCommandStrategy = 'PUBLIC' | 'COOKIE' | 'INTERCEPT' | 'UI' | 'LOCAL' | string; export interface HostedCommandArg extends Arg {} @@ -40,6 +42,7 @@ export interface HostedManifest { userId: string; metadata: { contractSchemaVersion: number; + sessionProtocolVersion: number; webcmdPackageVersion: string; generatedAt: string; }; diff --git a/src/main.ts b/src/main.ts index 8879304e..94411aaa 100644 --- a/src/main.ts +++ b/src/main.ts @@ -178,9 +178,9 @@ if (getCompIdx !== -1) { process.exit(EXIT_CODES.SUCCESS); } -const { rejectPositionalBrowserSessionArgv, BrowserSessionArgvError, escapeLeadingDashPositional } = await import('./cli-argv-preprocess.js'); +const { rejectMisplacedSessionSelectorArgv, rejectPositionalBrowserSessionArgv, BrowserSessionArgvError, escapeLeadingDashPositional } = await import('./cli-argv-preprocess.js'); try { - let rewritten = rejectPositionalBrowserSessionArgv(process.argv.slice(2)); + let rewritten = rejectMisplacedSessionSelectorArgv(rejectPositionalBrowserSessionArgv(process.argv.slice(2))); // Use the metadata that discovery actually registered. The core manifest is // intentionally empty, while installed plugins and legacy user CLIs are not. const { getRegistry } = await import('./registry.js'); diff --git a/src/plugin.test.ts b/src/plugin.test.ts index 46eae40a..d87f471c 100644 --- a/src/plugin.test.ts +++ b/src/plugin.test.ts @@ -2,7 +2,7 @@ * Tests for plugin management: install, uninstall, list, and lock file support. */ -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, afterAll, vi } from 'vitest'; import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; @@ -12,10 +12,18 @@ import type { LockEntry } from './plugin.js'; import * as pluginModule from './plugin.js'; import { createAdapterOverride } from './adapter-override.js'; -const { mockExecFileSync, mockExecSync } = vi.hoisted(() => ({ - mockExecFileSync: vi.fn(), - mockExecSync: vi.fn(), -})); +const { mockExecFileSync, mockExecSync, testConfigDir, previousConfigDir } = vi.hoisted(() => { + const previousConfigDir = process.env.WEBCMD_CONFIG_DIR; + const testConfigDir = `${process.env.TMPDIR ?? '/tmp'}/webcmd-plugin-test-${process.pid}-${Date.now()}`; + process.env.WEBCMD_CONFIG_DIR = testConfigDir; + return { mockExecFileSync: vi.fn(), mockExecSync: vi.fn(), testConfigDir, previousConfigDir }; +}); + +afterAll(() => { + fs.rmSync(testConfigDir, { recursive: true, force: true }); + if (previousConfigDir === undefined) delete process.env.WEBCMD_CONFIG_DIR; + else process.env.WEBCMD_CONFIG_DIR = previousConfigDir; +}); const { _getCommitHash, diff --git a/src/root-command-surface.ts b/src/root-command-surface.ts index 7f8278a1..cfa72429 100644 --- a/src/root-command-surface.ts +++ b/src/root-command-surface.ts @@ -6,6 +6,7 @@ export const ROOT_PROFILE_FLAGS = '--profile '; export const ROOT_PROFILE_DESCRIPTION = 'Chrome profile/context alias for browser runtime commands'; export const ROOT_SESSION_FLAGS = '--session '; export const ROOT_SESSION_DESCRIPTION = 'Existing opaque Session ID from `webcmd session create`'; +export const ROOT_SESSION_SELECTOR_POSITION = 'root'; export const COMPLETION_SENTINEL = '--get-completions'; /** diff --git a/src/session-docs-sync.test.ts b/src/session-docs-sync.test.ts new file mode 100644 index 00000000..5f6713b2 --- /dev/null +++ b/src/session-docs-sync.test.ts @@ -0,0 +1,36 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; + +const ROOT = process.cwd(); +const DOC_ROOTS = ['README.md', 'docs', 'skills']; + +describe('Session documentation sync', () => { + it('does not call adapter siteSession modes browser Sessions', () => { + const offenders = walkDocs() + .map((file) => ({ file, text: fs.readFileSync(file, 'utf8') })) + .filter(({ text }) => /persistent sessions/i.test(text)); + + expect(offenders.map(({ file }) => path.relative(ROOT, file))).toEqual([]); + }); +}); + +function walkDocs(): string[] { + const files: string[] = []; + for (const root of DOC_ROOTS) { + const absolute = path.join(ROOT, root); + if (!fs.existsSync(absolute)) continue; + const stat = fs.statSync(absolute); + if (stat.isFile()) files.push(absolute); + else collect(absolute, files); + } + return files.filter((file) => !file.includes(`${path.sep}docs${path.sep}superpowers${path.sep}`)); +} + +function collect(dir: string, files: string[]): void { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const absolute = path.join(dir, entry.name); + if (entry.isDirectory()) collect(absolute, files); + else if (/\.(md|mdx)$/u.test(entry.name)) files.push(absolute); + } +} diff --git a/tests/e2e/browser-tabs.test.ts b/tests/e2e/browser-tabs.test.ts index 1bad6e12..a91faee4 100644 --- a/tests/e2e/browser-tabs.test.ts +++ b/tests/e2e/browser-tabs.test.ts @@ -118,29 +118,29 @@ describe('browser public command surface e2e', () => { it('uses tabs, bind, run, and close through the built CLI', async () => { const daemon = await startFakeDaemon(); daemons.push(daemon); - const session = 'four-command-surface'; + const session = 'session_four-command-surface'; const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'webcmd-browser-tabs-')); tempDirs.push(tempDir); const sourcePath = path.join(tempDir, 'program.js'); fs.writeFileSync(sourcePath, "return 'from file';"); - const tabs = await runCli(['browser', session, 'tabs']); + const tabs = await runCli(['--session', session, 'browser', 'tabs']); expect(tabs.code).toBe(0); expect(parseJsonOutput(tabs.stdout)).toEqual(expect.arrayContaining([ expect.objectContaining({ id: 'page-one', title: 'One' }), expect.objectContaining({ id: 'page-two', title: 'Two' }), ])); - const bound = await runCli(['browser', session, 'bind', '--page', 'page-two']); + const bound = await runCli(['--session', session, 'browser', 'bind', '--page', 'page-two']); expect(bound.code).toBe(0); expect(parseJsonOutput(bound.stdout)).toMatchObject({ bound: true, page: 'page-two', title: 'Two' }); - const run = await runCli(['browser', session, 'run', '--file', sourcePath]); + const run = await runCli(['--session', session, 'browser', 'run', '--file', sourcePath]); expect(run.code).toBe(0); expect(parseJsonOutput(run.stdout)).toEqual({ result: 'ran' }); expect(daemon.lastRunSource()).toBe("return 'from file';"); - const closed = await runCli(['browser', session, 'close']); + const closed = await runCli(['--session', session, 'browser', 'close']); expect(closed.code).toBe(0); expect(parseJsonOutput(closed.stdout)).toEqual({ closed: true }); }, 30_000); diff --git a/tests/e2e/cloak-runtime.test.ts b/tests/e2e/cloak-runtime.test.ts index 949b23ba..a116ff83 100644 --- a/tests/e2e/cloak-runtime.test.ts +++ b/tests/e2e/cloak-runtime.test.ts @@ -8,16 +8,40 @@ import { runCli } from './helpers.js'; let server: http.Server; let baseUrl = ''; const sourceDirs: string[] = []; +let sharedConfigDir = ''; +let sharedProfile = ''; + +function isolatedOptions(options: Parameters[1] = {}): Parameters[1] { + return { + ...options, + env: { + HOME: path.join(sharedConfigDir, 'home'), + USERPROFILE: path.join(sharedConfigDir, 'home'), + WEBCMD_CONFIG_DIR: sharedConfigDir, + WEBCMD_PROFILE: sharedProfile, + ...options.env, + }, + }; +} function browserRun(session: string, source: string, options: Parameters[1] = {}) { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'webcmd-cloak-run-')); sourceDirs.push(dir); const sourcePath = path.join(dir, 'program.js'); fs.writeFileSync(sourcePath, source); - return runCli(['browser', session, 'run', '--file', sourcePath], options); + return runCli(['--session', session, 'browser', 'run', '--file', sourcePath], isolatedOptions(options)); +} + +async function createSession(options: Parameters[1] = {}) { + const result = await runCli(['session', 'create', '-f', 'json'], isolatedOptions(options)); + expect(result.code).toBe(0); + return JSON.parse(result.stdout).id as string; } beforeAll(async () => { + sharedConfigDir = fs.mkdtempSync(path.join(os.tmpdir(), 'webcmd-cloak-suite-')); + sharedProfile = `cloak-suite-${Date.now()}`; + sourceDirs.push(sharedConfigDir); server = http.createServer((req, res) => { if (req.url === '/cookie') { res.setHeader('Set-Cookie', 'webcmd_smoke=ok; Path=/'); @@ -58,7 +82,7 @@ afterAll(async () => { describe('Cloak runtime e2e', () => { it('runs Playwright against a page through webcmd browser', async () => { - const session = `cloak-smoke-${Date.now()}`; + const session = await createSession({ timeout: 120_000 }); const result = await browserRun(session, ` await page.goto(${JSON.stringify(baseUrl)}); return await page.evaluate(() => document.title + ':' + window.answer); @@ -68,7 +92,7 @@ describe('Cloak runtime e2e', () => { }, 180_000); it('persists cookies inside the Cloak profile', async () => { - const session = `cloak-cookie-${Date.now()}`; + const session = await createSession({ timeout: 120_000 }); const cookies = await browserRun(session, ` await page.goto(${JSON.stringify(`${baseUrl}/cookie`)}); return await page.evaluate(() => document.cookie); @@ -78,7 +102,6 @@ describe('Cloak runtime e2e', () => { }, 180_000); it('survives sequential open and evaluate cycles in one persistent profile', async () => { - const session = `cloak-sequential-${Date.now()}`; const profile = `task5-${Date.now()}`; const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'webcmd-cloak-sequential-')); const run = (args: string[]) => runCli(args, { @@ -104,6 +127,10 @@ describe('Cloak runtime e2e', () => { expect((await waitForStoppedDaemon()).stdout).toContain('Daemon: not running'); try { + const session = await createSession({ + timeout: 120_000, + env: { WEBCMD_CONFIG_DIR: configDir, WEBCMD_PROFILE: profile }, + }); expect((await browserRun(session, `await page.goto(${JSON.stringify(`${baseUrl}/cookie`)}); return null;`, { timeout: 120_000, env: { WEBCMD_CONFIG_DIR: configDir, WEBCMD_PROFILE: profile }, diff --git a/tests/e2e/cloak-session-concurrency.test.ts b/tests/e2e/cloak-session-concurrency.test.ts new file mode 100644 index 00000000..4e21909b --- /dev/null +++ b/tests/e2e/cloak-session-concurrency.test.ts @@ -0,0 +1,63 @@ +import fs from 'node:fs'; +import http from 'node:http'; +import os from 'node:os'; +import path from 'node:path'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { CloakSessionManager } from '../../src/browser/runtime/local-cloak/session-manager.js'; + +let server: http.Server; +let baseUrl = ''; +const tempDirs: string[] = []; + +beforeAll(async () => { + server = http.createServer((req, res) => { + const url = new URL(req.url ?? '/', 'http://127.0.0.1'); + res.end(`${url.pathname}${url.pathname}`); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('test server did not bind'); + baseUrl = `http://127.0.0.1:${address.port}`; +}, 30_000); + +afterAll(async () => { + await new Promise((resolve) => server.close(() => resolve())); + for (const dir of tempDirs.splice(0)) fs.rmSync(dir, { recursive: true, force: true }); +}); + +describe('Cloak Session concurrency gate', () => { + it('creates subsequent Session pages as tabs in the existing window', async () => { + const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'webcmd-cloak-session-gate-')); + tempDirs.push(configDir); + const manager = new CloakSessionManager({ baseDir: configDir }); + const key = { + profileId: `gate-${Date.now()}`, + session: 'session_11111111-1111-4111-8111-111111111111', + sessionId: 'session_11111111-1111-4111-8111-111111111111', + surface: 'browser' as const, + }; + try { + const first = await manager.getPage(key); + await first.page.goto(`${baseUrl}/first`); + const second = await manager.newPage(key); + await second.page.goto(`${baseUrl}/second`); + const windowId = async (page: typeof first.page) => { + const cdp = await first.context.newCDPSession(page); + try { + const target = await cdp.send('Target.getTargetInfo'); + return (await cdp.send('Browser.getWindowForTarget', { targetId: target.targetInfo.targetId })).windowId; + } finally { + await cdp.detach(); + } + }; + + expect(await windowId(second.page)).toBe(await windowId(first.page)); + expect((await manager.listPages(key)).map((tab) => tab.url)).toEqual([ + `${baseUrl}/first`, + `${baseUrl}/second`, + ]); + } finally { + await manager.shutdown(); + } + }, 180_000); +}); diff --git a/vitest.config.ts b/vitest.config.ts index c93712d7..5953a512 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -65,6 +65,7 @@ export default defineConfig({ 'tests/e2e/plugin-management.test.ts', 'tests/e2e/article-download-pipeline.test.ts', 'tests/e2e/cloak-runtime.test.ts', + 'tests/e2e/cloak-session-concurrency.test.ts', 'tests/e2e/browser-run.test.ts', // Extended browser tests (20+ sites) — opt-in only: // WEBCMD_E2E=1 npx vitest run From 39be29aae1b69df8a9290b5a3314e170225b41a8 Mon Sep 17 00:00:00 2001 From: beubax Date: Wed, 12 Aug 2026 18:34:59 +0530 Subject: [PATCH 21/27] Fix session concurrency review gaps --- docs/cli-reference.mdx | 4 +- skills/webcmd-browser/SKILL.md | 2 +- skills/webcmd-usage/SKILL.md | 2 +- src/browser/daemon-client.test.ts | 3 + .../local-cloak/session-manager.test.ts | 18 +++-- .../runtime/local-cloak/session-manager.ts | 47 ++++++++++--- src/cli-argv-preprocess.test.ts | 6 +- src/cli-argv-preprocess.ts | 9 ++- src/cli.test.ts | 5 +- src/cli.ts | 8 ++- src/daemon/server.ts | 11 ++- src/errors.test.ts | 6 ++ src/errors.ts | 11 +-- src/hosted/runner.test.ts | 4 ++ src/hosted/runner.ts | 8 ++- src/session-docs-sync.test.ts | 16 +++++ src/session-lease.ts | 2 + src/skills.test.ts | 2 +- tests/e2e/cloak-session-concurrency.test.ts | 68 ++++++++++++++----- 19 files changed, 179 insertions(+), 53 deletions(-) diff --git a/docs/cli-reference.mdx b/docs/cli-reference.mdx index d03c3525..3e5bee3d 100644 --- a/docs/cli-reference.mdx +++ b/docs/cli-reference.mdx @@ -61,8 +61,8 @@ Create an opaque session before raw browser work. Profiles hold cookie/auth state; sessions are browser workspaces within that profile. Adapter commands may omit `--session` and use their profile's adapter-default session; pass `--session ` only when intentionally routing an adapter into an -explicit session. Raw browser commands must always pass it. Retired positional -syntax such as `webcmd browser ...` is invalid: +explicit session. Raw browser commands must always pass it. The retired +positional session form is invalid: ```bash webcmd session create -f json diff --git a/skills/webcmd-browser/SKILL.md b/skills/webcmd-browser/SKILL.md index 59085830..1eb7bc6d 100644 --- a/skills/webcmd-browser/SKILL.md +++ b/skills/webcmd-browser/SKILL.md @@ -31,7 +31,7 @@ Until `doctor` is green, browser commands may fail. Registry and plugin discover ## Session lifecycle - Create an opaque browser session before raw browser work: `webcmd session create -f json`. -- Raw `webcmd browser *` commands require that ID at the root: `webcmd --session browser ...`; positional `webcmd browser ...` is retired. +- Raw browser commands require that ID at the root: `webcmd --session browser ...`; the old positional session form is retired. - Profiles are cookie jars and auth scope; sessions are browser workspaces/windows within a profile. Parallel agents use separate sessions. - `webcmd session list` shows sessions and their handoff/runtime state; close finished work with `webcmd session close `. Close is blocked while that Session has a live handoff. - Browser state in the bound page persists between calls, but each `run` gets a fresh JavaScript scope. diff --git a/skills/webcmd-usage/SKILL.md b/skills/webcmd-usage/SKILL.md index c567bebe..e6b83fa8 100644 --- a/skills/webcmd-usage/SKILL.md +++ b/skills/webcmd-usage/SKILL.md @@ -70,7 +70,7 @@ webcmd session close session_abc ``` `webcmd session close ` is blocked while that Session has a live human handoff. -Adapter commands may omit `--session` and use the selected profile's adapter-default session. Pass `--session ` to route one into an explicit session. Raw `webcmd browser` commands never omit it; retired `webcmd browser ...` syntax is invalid. +Adapter commands may omit `--session` and use the selected profile's adapter-default session. Pass `--session ` to route one into an explicit session. Raw browser commands never omit it; the retired positional session form is invalid. ## Prerequisites By Strategy diff --git a/src/browser/daemon-client.test.ts b/src/browser/daemon-client.test.ts index 446e2f2e..58a057ce 100644 --- a/src/browser/daemon-client.test.ts +++ b/src/browser/daemon-client.test.ts @@ -304,6 +304,8 @@ describe('daemon-client', () => { holder: { command: 'other write', pid: 4242, + sessionId: 'session_a', + admissionSite: 'github', acquiredAt: 1_000, heartbeatAt: 2_000, }, @@ -314,6 +316,7 @@ describe('daemon-client', () => { name: 'SessionBusyError', code: 'SESSION_BUSY', message: expect.stringContaining('other write'), + hint: expect.stringContaining('session_a'), }); expect(fetchMock).toHaveBeenCalledTimes(1); }); diff --git a/src/browser/runtime/local-cloak/session-manager.test.ts b/src/browser/runtime/local-cloak/session-manager.test.ts index d07e20a0..97e91a75 100644 --- a/src/browser/runtime/local-cloak/session-manager.test.ts +++ b/src/browser/runtime/local-cloak/session-manager.test.ts @@ -23,10 +23,11 @@ function fakeContext() { goto: vi.fn().mockResolvedValue(undefined), evaluate: vi.fn(async (fn: unknown) => { if (typeof fn !== 'function' || !String(fn).includes('window.open')) return 'ok'; - const popup = fakePage(page, windowId); + const source = String(fn); + const popup = fakePage(source.includes('noopener') ? undefined : page, windowId); allPages.push(popup); queueMicrotask(() => { - emitPageEvent(page, 'popup', popup); + if (!source.includes('noopener')) emitPageEvent(page, 'popup', popup); emit('page', popup); }); return null; @@ -118,6 +119,9 @@ function fakeContext() { bucket.add(listener); listeners.set(event, bucket); }, + off(event: string, listener: (...args: unknown[]) => void) { + listeners.get(event)?.delete(listener); + }, emit, waitForEvent(event: string) { return new Promise((resolve) => this.on(event, resolve)); @@ -221,7 +225,7 @@ describe('CloakSessionManager', () => { expect(launched.targetIdFor(lease.page)).toEqual(expect.stringMatching(/^target-/)); }); - it('creates later Session pages through the opener in the same window', async () => { + it('creates later Session pages with noopener and adopts the context page in the same window', async () => { const launched = fakeContext(); const manager = new CloakSessionManager({ baseDir: '/tmp/webcmd-test', @@ -234,11 +238,13 @@ describe('CloakSessionManager', () => { const second = await manager.newPage(key); const evaluate = vi.mocked(first.page.evaluate); expect(evaluate).toHaveBeenCalledTimes(1); + expect(String(evaluate.mock.calls[0]?.[0])).toContain('noopener'); expect(launched.context.newCDPSession.mock.calls.length).toBeGreaterThanOrEqual(2); expect(launched.cdp.send.mock.calls.filter(([method, params]) => ( method === 'Target.createTarget' && !(params as { hidden?: boolean })?.hidden ))).toHaveLength(1); expect(launched.windowIdFor(second.page)).toBe(launched.windowIdFor(first.page)); + expect(await second.page.opener()).toBeNull(); expect((await manager.listPages(key)).every(tab => tab.session === 'session_a')).toBe(true); }); @@ -259,7 +265,7 @@ describe('CloakSessionManager', () => { expect((await manager.listPages(key)).map(tab => tab.sessionId)).toEqual(['session_a', 'session_a']); }); - it('adopts an opener popup when Chromium creates it in an unowned window', async () => { + it('keeps a site popup owned while noopener tab creation uses another page', async () => { const launched = fakeContext(); const manager = new CloakSessionManager({ baseDir: '/tmp/webcmd-test', @@ -279,8 +285,8 @@ describe('CloakSessionManager', () => { const second = await manager.newPage(key); - expect(second.page).toBe(popup); - expect((await manager.listPages(key)).map(tab => tab.sessionId)).toEqual(['session_a', 'session_a']); + expect(second.page).not.toBe(popup); + expect((await manager.listPages(key)).map(tab => tab.sessionId)).toEqual(['session_a', 'session_a', 'session_a']); }); it('creates a later page in its Session window when another Session was used last', async () => { diff --git a/src/browser/runtime/local-cloak/session-manager.ts b/src/browser/runtime/local-cloak/session-manager.ts index bfb98209..7e571258 100644 --- a/src/browser/runtime/local-cloak/session-manager.ts +++ b/src/browser/runtime/local-cloak/session-manager.ts @@ -942,22 +942,49 @@ export class CloakSessionManager { if (!openerEntry) return this.createWindowPage(runtime, windowMode); await this.assertOwnedWindow(runtime, session.id, openerEntry); const opener = openerEntry.page; + const openerWindowId = await this.windowIdForTarget(runtime, openerEntry.targetId, opener); + const openedPage = this.waitForContextPageInWindow(runtime, session.id, openerWindowId, TARGET_PAGE_MATCH_TIMEOUT_MS); - const popup = opener.waitForEvent('popup', { timeout: 1_000 }).catch(() => null); try { - await opener.evaluate(() => window.open('about:blank', '_blank')); + await opener.evaluate(() => window.open('about:blank', '_blank', 'noopener,noreferrer')); } catch {} - const page = await popup; - if (page) { - const targetId = await this.targetIdForPage(runtime, page); - const windowId = await this.windowIdForTarget(runtime, targetId, page); - if (session.windowIds.has(windowId) || runtime.windowOwners.get(windowId) === undefined) return page; - if (!pageIsClosed(page)) await page.close().catch(() => {}); - throw new SessionWindowConflictError('unknown', session.id, runtime.windowOwners.get(windowId)); - } + const page = await openedPage; + if (page) return page; return this.createWindowPage(runtime, windowMode); } + private async waitForContextPageInWindow( + runtime: ProfileRuntime, + sessionId: string, + windowId: number, + timeoutMs: number, + ): Promise { + return new Promise((resolve) => { + let settled = false; + const done = (page: PlaywrightPage | null) => { + if (settled) return; + settled = true; + clearTimeout(timer); + runtime.context.off('page', onPage); + resolve(page); + }; + const tryPage = async (page: PlaywrightPage) => { + if (settled || pageIsClosed(page)) return; + const targetId = await this.targetIdForPage(runtime, page).catch(() => undefined); + if (!targetId) return; + const actualWindowId = await this.windowIdForTarget(runtime, targetId, page).catch(() => undefined); + if (actualWindowId !== windowId) return; + const owner = runtime.windowOwners.get(actualWindowId); + if (owner !== undefined && owner !== sessionId) return; + done(page); + }; + const onPage = (page: PlaywrightPage) => { void tryPage(page); }; + const timer = setTimeout(() => done(null), timeoutMs); + runtime.context.on('page', onPage); + for (const page of this.pendingTargetPages.get(runtime)?.values() ?? []) void tryPage(page); + }); + } + private async acquireSessionPage( profileId: string, sessionId: string, diff --git a/src/cli-argv-preprocess.test.ts b/src/cli-argv-preprocess.test.ts index 05403fac..b6eeddff 100644 --- a/src/cli-argv-preprocess.test.ts +++ b/src/cli-argv-preprocess.test.ts @@ -27,9 +27,11 @@ describe('rejectPositionalBrowserSessionArgv details', () => { }); describe('rejectMisplacedSessionSelectorArgv', () => { - it('rejects trailing --session with a stable diagnostic code', () => { + it('rejects trailing --session with a copy-pasteable root-selector command', () => { expect(() => rejectMisplacedSessionSelectorArgv(['browser', 'run', '--session', 'session_a'])) - .toThrowError(/SESSION_SELECTOR_POSITION/); + .toThrowError(/SESSION_SELECTOR_POSITION: --session must appear before the command\. Use: webcmd --session session_a browser run/); + expect(() => rejectMisplacedSessionSelectorArgv(['github', 'issues', '--session=session_b'])) + .toThrowError(/Use: webcmd --session session_b github issues/); }); it('keeps the root --session selector unchanged', () => { diff --git a/src/cli-argv-preprocess.ts b/src/cli-argv-preprocess.ts index cf212a2f..14969eaa 100644 --- a/src/cli-argv-preprocess.ts +++ b/src/cli-argv-preprocess.ts @@ -97,8 +97,15 @@ export function rejectMisplacedSessionSelectorArgv(argv: readonly string[]): str const token = result[index]; if (token === '--') break; if (token === '--session' || token.startsWith('--session=')) { + const sessionId = token === '--session' + ? (result[index + 1] && !result[index + 1]!.startsWith('-') ? result[index + 1]! : '') + : token.slice('--session='.length); + const withoutMisplaced = [ + ...result.slice(0, index), + ...result.slice(index + (token === '--session' && sessionId !== '' ? 2 : 1)), + ]; throw new BrowserSessionArgvError( - `SESSION_SELECTOR_POSITION: --session must appear before the command. Use: webcmd --session ${result.slice(0, commandIndex + 1).join(' ')}`, + `SESSION_SELECTOR_POSITION: --session must appear before the command. Use: webcmd --session ${sessionId} ${withoutMisplaced.join(' ')}`, ); } } diff --git a/src/cli.test.ts b/src/cli.test.ts index a43bdc17..7cb27635 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -1740,7 +1740,10 @@ describe('browser Session lifecycle commands', () => { await createProgram('', '').parseAsync(['node', 'webcmd', 'session', 'create']); expect(mockSendCommand).toHaveBeenCalledWith('session-create', { contextId: 'default' }); - expect(consoleLogSpy.mock.calls.flat().join('\n')).toContain('session_abc'); + const output = consoleLogSpy.mock.calls.flat().join('\n'); + expect(output).toContain('session_abc'); + expect(output).toContain('runtimeState'); + expect(output).not.toContain('profileId'); }); it('lists persisted Sessions without creating the adapter default when daemon is absent', async () => { diff --git a/src/cli.ts b/src/cli.ts index 64bb5c95..78cfc87f 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -566,6 +566,12 @@ function formatHandoff(row: BrowserSessionListRow): string { return row.handoff ? `${row.handoff.site} until ${row.handoff.expiresAt}` : ''; } +function sessionCreateOutput(data: unknown): unknown { + if (!data || typeof data !== 'object' || Array.isArray(data)) return data; + const row = data as Record; + return { id: row.id, kind: row.kind, runtimeState: row.runtimeState }; +} + function applyVerbose(opts: { verbose?: boolean }): void { if (opts.verbose) process.env.WEBCMD_VERBOSE = '1'; } @@ -809,7 +815,7 @@ export function createProgram(BUILTIN_CLIS: string, USER_CLIS: string, pluginsDi .action(async (opts, command) => { const profileId = getSelectedProfileId(command); const data = await sendCommand('session-create', { contextId: profileId }); - await renderOutput(data, { fmt: opts.format, columns: ['id', 'kind', 'profileId'] }); + await renderOutput(sessionCreateOutput(data), { fmt: opts.format, columns: ['id', 'kind', 'runtimeState'] }); }); sessionCmd diff --git a/src/daemon/server.ts b/src/daemon/server.ts index dbd63f18..926ae3c6 100644 --- a/src/daemon/server.ts +++ b/src/daemon/server.ts @@ -328,11 +328,11 @@ export function createDaemonServer(provider: BrowserRuntimeProvider, opts: Daemo || lease.key.startsWith(`${sessionKey}␟`) || sessionKey.startsWith(`${lease.key}␟`) )); - const publicSessionHolder = (sessionKey: string) => { - const holder = activeSessionLeases(sessionKey)[0]; + const publicSessionHolder = (holder: ReturnType[number] | undefined) => { if (!holder) return null; const { key: _key, runId: _runId, ...publicHolder } = holder; - return publicHolder; + const [, sessionId, admissionSite] = holder.key.split('␟'); + return { ...publicHolder, ...(sessionId ? { sessionId } : {}), ...(admissionSite ? { admissionSite } : {}) }; }; if (resolved.session && !(resolvedBody.action === 'session-close' && resolvedBody.force === true)) { const paused = handoffPauseResult(resolvedBody, resolved.session); @@ -347,7 +347,7 @@ export function createDaemonServer(provider: BrowserRuntimeProvider, opts: Daemo const holders = activeSessionLeases(sessionKey); const holder = holders[0]; if (holder && resolvedBody.force !== true) { - jsonResponse(res, 409, { ok: false, code: 'session_busy', holder: publicSessionHolder(sessionKey) }); + jsonResponse(res, 409, { ok: false, code: 'session_busy', holder: publicSessionHolder(holder) }); return; } const forcedRunIds = resolvedBody.force === true @@ -396,8 +396,7 @@ export function createDaemonServer(provider: BrowserRuntimeProvider, opts: Daemo pid: resolvedBody.pid, }, hasPendingWork); if (!acquired.acquired) { - const { key: _key, runId: _runId, ...holder } = acquired.holder; - jsonResponse(res, 409, { ok: false, code: 'session_busy', holder }); + jsonResponse(res, 409, { ok: false, code: 'session_busy', holder: publicSessionHolder(acquired.holder) }); return; } } diff --git a/src/errors.test.ts b/src/errors.test.ts index f40c9a93..c4673dec 100644 --- a/src/errors.test.ts +++ b/src/errors.test.ts @@ -198,6 +198,12 @@ describe('SessionBusyError platform hints', () => { expect(err.hint).not.toContain('Stop-Process'); }); + it('includes local Session and site details when daemon admission reports them', () => { + const err = new SessionBusyError({ ...holder, sessionId: 'session_a', admissionSite: 'github' }, 'linux', () => true); + expect(err.hint).toContain('Session session_a'); + expect(err.hint).toContain('site github'); + }); + it('does not suggest killing a holder pid that is no longer alive', () => { const err = new SessionBusyError(holder, 'linux', () => false); expect(err.message).toContain('chatgpt ask'); diff --git a/src/errors.ts b/src/errors.ts index 1b6bee51..e31b6157 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -151,14 +151,17 @@ function formatBusyMessage(holder: SessionLeaseHolder): string { function formatBusyHint(holder: SessionLeaseHolder, platform: string, pidAlive: (pid: number) => boolean): string { const hasLivePid = isActionablePid(holder.pid) && pidAlive(holder.pid); + const scope = holder.sessionId + ? `Session ${holder.sessionId}${holder.admissionSite ? `, site ${holder.admissionSite}` : ''}. ` + : ''; if (platform === 'win32') { - return !hasLivePid + return scope + (!hasLivePid ? 'Wait for it to finish, or use Task Manager to stop the owning process if it is stuck.' - : `Wait for it to finish, or run \`Stop-Process -Id ${holder.pid}\` in PowerShell if it is stuck.`; + : `Wait for it to finish, or run \`Stop-Process -Id ${holder.pid}\` in PowerShell if it is stuck.`); } - return !hasLivePid + return scope + (!hasLivePid ? 'Wait for it to finish, or stop the owning process if it is stuck.' - : `Wait for it to finish, or run \`kill ${holder.pid}\` if it is stuck.`; + : `Wait for it to finish, or run \`kill ${holder.pid}\` if it is stuck.`); } /** A persistent write session is temporarily owned by another logical run. */ diff --git a/src/hosted/runner.test.ts b/src/hosted/runner.test.ts index 2b8a0faa..8050da31 100644 --- a/src/hosted/runner.test.ts +++ b/src/hosted/runner.test.ts @@ -566,6 +566,7 @@ describe('runHostedCli', () => { return new Response(JSON.stringify({ ok: true, result: [session] })); }); + const outputs: string[] = []; for (const argv of [ ['--profile', 'work', 'session', 'create', '-f', 'json'], ['--profile', 'work', 'session', 'list', '-f', 'json'], @@ -579,7 +580,10 @@ describe('runHostedCli', () => { }); expect(result).toEqual({ handled: true, exitCode: 0 }); expect(stdout.text()).toContain(session.id); + outputs.push(stdout.text()); } + expect(outputs[0]).toContain('"runtimeState": "idle"'); + expect(outputs[0]).not.toContain('"profileId"'); expect(requests).toEqual([ { url: 'https://api.example.com/v1/manifest', method: 'GET' }, diff --git a/src/hosted/runner.ts b/src/hosted/runner.ts index 464c2f55..6750e74c 100644 --- a/src/hosted/runner.ts +++ b/src/hosted/runner.ts @@ -487,7 +487,7 @@ async function dispatchHostedSession( profile?: string, ): Promise { if (parsed.command === 'create') { - await renderOutput((await client.createBrowserSession(profile)).result, { fmt: parsed.format, columns: ['id', 'kind', 'profileId'], stdout }); + await renderOutput(sessionCreateOutput((await client.createBrowserSession(profile)).result), { fmt: parsed.format, columns: ['id', 'kind', 'runtimeState'], stdout }); return; } if (parsed.command === 'list') { @@ -502,6 +502,12 @@ async function dispatchHostedSession( await renderOutput((await client.closeBrowserSession(parsed.session!, profile, parsed.force === true)).result, { fmt: parsed.format, stdout }); } +function sessionCreateOutput(data: unknown): unknown { + if (!data || typeof data !== 'object' || Array.isArray(data)) return data; + const row = data as Record; + return { id: row.id, kind: row.kind, runtimeState: row.runtimeState }; +} + function hasPresentFileArgument( command: import('./types.js').HostedCommand, args: Record, diff --git a/src/session-docs-sync.test.ts b/src/session-docs-sync.test.ts index 5f6713b2..52f73698 100644 --- a/src/session-docs-sync.test.ts +++ b/src/session-docs-sync.test.ts @@ -13,6 +13,22 @@ describe('Session documentation sync', () => { expect(offenders.map(({ file }) => path.relative(ROOT, file))).toEqual([]); }); + + it('keeps removed Session syntaxes out of docs and skills', () => { + const offenders = walkDocs() + .flatMap((file) => { + const text = fs.readFileSync(file, 'utf8'); + return [ + [/\bwebcmd browser (pattern as RegExp).test(text)) + .map(([, label]) => `${path.relative(ROOT, file)}: ${label}`); + }); + + expect(offenders).toEqual([]); + }); }); function walkDocs(): string[] { diff --git a/src/session-lease.ts b/src/session-lease.ts index 2c09a48d..b89d75ba 100644 --- a/src/session-lease.ts +++ b/src/session-lease.ts @@ -93,6 +93,8 @@ export function isUnknownOutcomeError(error: unknown): boolean { export interface SessionLeaseHolder { command: string; pid?: number; + sessionId?: string; + admissionSite?: string; acquiredAt: number; heartbeatAt: number; } diff --git a/src/skills.test.ts b/src/skills.test.ts index 103734f6..752b18fa 100644 --- a/src/skills.test.ts +++ b/src/skills.test.ts @@ -227,7 +227,7 @@ describe('webcmd skills content', () => { expect(usage).toContain('webcmd session create -f json'); expect(usage).toContain('webcmd --session session_abc browser'); expect(usage).toMatch(/Adapter commands may omit `--session`[\s\S]{0,200}adapter-default session/i); - expect(usage).toMatch(/retired `webcmd browser \.\.\.` syntax is invalid/i); + expect(usage).toMatch(/retired positional session form is invalid/i); expect(browser).toMatch(/Profiles are cookie jars[\s\S]{0,180}sessions are browser workspaces\/windows/i); expect(browser).toMatch(/Parallel agents use separate sessions/i); for (const skill of [usage, browser, autofix]) { diff --git a/tests/e2e/cloak-session-concurrency.test.ts b/tests/e2e/cloak-session-concurrency.test.ts index 4e21909b..1d763566 100644 --- a/tests/e2e/cloak-session-concurrency.test.ts +++ b/tests/e2e/cloak-session-concurrency.test.ts @@ -2,9 +2,11 @@ import fs from 'node:fs'; import http from 'node:http'; import os from 'node:os'; import path from 'node:path'; +import { fileURLToPath } from 'node:url'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { CloakSessionManager } from '../../src/browser/runtime/local-cloak/session-manager.js'; +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); let server: http.Server; let baseUrl = ''; const tempDirs: string[] = []; @@ -12,7 +14,7 @@ const tempDirs: string[] = []; beforeAll(async () => { server = http.createServer((req, res) => { const url = new URL(req.url ?? '/', 'http://127.0.0.1'); - res.end(`${url.pathname}${url.pathname}`); + res.end(`${url.pathname}${url.pathname}`); }); await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); const address = server.address(); @@ -26,36 +28,70 @@ afterAll(async () => { }); describe('Cloak Session concurrency gate', () => { - it('creates subsequent Session pages as tabs in the existing window', async () => { + it('keeps Cloak and Playwright pinned to the supported live gate runtime', () => { + const appPkg = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8')); + const cloakPkg = JSON.parse(fs.readFileSync(path.join(ROOT, 'node_modules/cloakbrowser/package.json'), 'utf8')); + const playwrightPkg = JSON.parse(fs.readFileSync(path.join(ROOT, 'node_modules/playwright-core/package.json'), 'utf8')); + const cloakConfig = fs.readFileSync(path.join(ROOT, 'node_modules/cloakbrowser/dist/config.js'), 'utf8'); + + expect(appPkg.dependencies.cloakbrowser).toBe('0.4.5'); + expect(appPkg.dependencies['playwright-core']).toBe('1.61.1'); + expect(cloakPkg.version).toBe('0.4.5'); + expect(playwrightPkg.version).toBe('1.61.1'); + expect(cloakConfig).toContain('"darwin-arm64": "145.0.7632.109.2"'); + }); + + it('covers isolated Profiles, explicit Session windows, noopener tabs, close survival, and keeper repair', async () => { const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'webcmd-cloak-session-gate-')); tempDirs.push(configDir); const manager = new CloakSessionManager({ baseDir: configDir }); - const key = { - profileId: `gate-${Date.now()}`, + const profileA = `gate-a-${Date.now()}`; + const profileB = `gate-b-${Date.now()}`; + const keyA = { + profileId: profileA, session: 'session_11111111-1111-4111-8111-111111111111', sessionId: 'session_11111111-1111-4111-8111-111111111111', surface: 'browser' as const, }; + const keyB = { ...keyA, profileId: profileB, session: 'session_22222222-2222-4222-8222-222222222222', sessionId: 'session_22222222-2222-4222-8222-222222222222' }; + const keyA2 = { ...keyA, session: 'session_33333333-3333-4333-8333-333333333333', sessionId: 'session_33333333-3333-4333-8333-333333333333' }; + const windowId = async (page: Awaited>['page']) => { + const cdp = await page.context().newCDPSession(page); + try { + const target = await cdp.send('Target.getTargetInfo') as { targetInfo: { targetId: string } }; + return (await cdp.send('Browser.getWindowForTarget', { targetId: target.targetInfo.targetId }) as { windowId: number }).windowId; + } finally { + await cdp.detach(); + } + }; try { - const first = await manager.getPage(key); + const [first, profileBFirst] = await Promise.all([manager.getPage(keyA), manager.getPage(keyB)]); await first.page.goto(`${baseUrl}/first`); - const second = await manager.newPage(key); + await profileBFirst.page.goto(`${baseUrl}/profile-b`); + + const otherSession = await manager.getPage(keyA2); + await otherSession.page.goto(`${baseUrl}/other-session`); + expect(await windowId(otherSession.page)).not.toBe(await windowId(first.page)); + + const second = await manager.newPage(keyA); await second.page.goto(`${baseUrl}/second`); - const windowId = async (page: typeof first.page) => { - const cdp = await first.context.newCDPSession(page); - try { - const target = await cdp.send('Target.getTargetInfo'); - return (await cdp.send('Browser.getWindowForTarget', { targetId: target.targetInfo.targetId })).windowId; - } finally { - await cdp.detach(); - } - }; expect(await windowId(second.page)).toBe(await windowId(first.page)); - expect((await manager.listPages(key)).map((tab) => tab.url)).toEqual([ + expect(await second.page.evaluate(() => window.opener === null)).toBe(true); + expect(await second.page.evaluate(() => document.referrer)).toBe(''); + expect((await manager.listPages(keyA)).map((tab) => tab.url)).toEqual([ `${baseUrl}/first`, `${baseUrl}/second`, ]); + + await manager.closeSession(profileA, keyA.sessionId); + await profileBFirst.page.goto(`${baseUrl}/profile-b-after-a-close`); + expect(await profileBFirst.page.title()).toBe('/profile-b-after-a-close'); + + await manager.closeSession(profileB, keyB.sessionId); + const afterFinalClose = await manager.getPage(keyB); + await afterFinalClose.page.goto(`${baseUrl}/keeper-survived`); + expect(await afterFinalClose.page.title()).toBe('/keeper-survived'); } finally { await manager.shutdown(); } From d90591271b40f974be6c6001e7ab247fb29f6fac Mon Sep 17 00:00:00 2001 From: beubax Date: Wed, 12 Aug 2026 19:56:11 +0530 Subject: [PATCH 22/27] Fix session review follow-ups --- docs/concepts.mdx | 6 ++- src/browser/daemon-client.test.ts | 22 +++++++++++ src/browser/daemon-client.ts | 17 ++++++--- .../local-cloak/session-manager.test.ts | 37 ++++++++++++++++--- .../runtime/local-cloak/session-manager.ts | 27 +++++++++----- src/engine.test.ts | 29 ++++++++------- src/errors.test.ts | 10 ++++- src/errors.ts | 7 +++- src/main.ts | 8 +++- src/session-docs-sync.test.ts | 28 ++++++++++---- src/session-lease.test.ts | 21 +++++++++++ src/session-lease.ts | 24 +++++++++++- src/signal-cancel.test.ts | 29 +++++++++++++++ src/signal-cancel.ts | 37 +++++++++++++++++++ tests/e2e/cloak-session-concurrency.test.ts | 4 +- 15 files changed, 255 insertions(+), 51 deletions(-) create mode 100644 src/signal-cancel.test.ts create mode 100644 src/signal-cancel.ts diff --git a/docs/concepts.mdx b/docs/concepts.mdx index a056d7f7..58583f3e 100644 --- a/docs/concepts.mdx +++ b/docs/concepts.mdx @@ -41,9 +41,11 @@ The agent chooses the strategy; the human describes the outcome and constraints. | `UI` | Drives the live page UI. | | `LOCAL` | Talks to a local app, service, or CLI. | -## Site Sessions and State +## Profiles, Sessions, And Tabs -Adapter browser commands can use `siteSession: 'ephemeral'` for an isolated tab or `siteSession: 'persistent'` for a longer same-site workflow. Raw browser work uses an explicit opaque Session created with `webcmd session create` and selected at the root with `--session `. +A Profile is the browser identity and storage bucket, such as `default` or `work`. A Session is an opaque browser workspace inside a Profile; raw browser work creates one with `webcmd session create` and selects it at the root with `--session `. A tab is one page inside that Session. + +Adapter browser commands can use `siteSession: 'ephemeral'` for an isolated tab or `siteSession: 'persistent'` for a longer same-site workflow. Those adapter site-session modes are separate from raw browser Sessions. ## What the Human Needs to Decide diff --git a/src/browser/daemon-client.test.ts b/src/browser/daemon-client.test.ts index 58a057ce..f1c600b5 100644 --- a/src/browser/daemon-client.test.ts +++ b/src/browser/daemon-client.test.ts @@ -321,6 +321,28 @@ describe('daemon-client', () => { expect(fetchMock).toHaveBeenCalledTimes(1); }); + it('maps local handoff pauses to a temporary BrowserCommandError', async () => { + const fetchMock = vi.mocked(fetch); + fetchMock.mockResolvedValueOnce({ + ok: false, + status: 400, + json: () => Promise.resolve({ + id: 'cmd', + ok: false, + errorCode: 'SESSION_PAUSED_FOR_HUMAN_HANDOFF', + error: 'Session session_a is paused while a human completes github authentication.', + details: { sessionId: 'session_a', site: 'github' }, + }), + } as Response); + + await expect(sendCommand('exec', { code: '1' })).rejects.toMatchObject({ + name: 'BrowserCommandError', + code: 'SESSION_PAUSED_FOR_HUMAN_HANDOFF', + exitCode: 75, + }); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + it('releaseSiteSessionLease makes one best-effort POST without starting or retrying the daemon', async () => { setDaemonRunContext({ runId: 'run_9999_newer_2', diff --git a/src/browser/daemon-client.ts b/src/browser/daemon-client.ts index c3b20e12..5ecf3bef 100644 --- a/src/browser/daemon-client.ts +++ b/src/browser/daemon-client.ts @@ -5,7 +5,7 @@ */ import { sleep } from '../utils.js'; -import { BrowserConnectError, SessionBusyError } from '../errors.js'; +import { BrowserConnectError, CliError, EXIT_CODES, SessionBusyError, type ExitCode } from '../errors.js'; import { COMMAND_RESULT_UNKNOWN_CODE, COMMAND_RESULT_UNKNOWN_HINT } from '../daemon-utils.js'; import { getDaemonRunContext, type SessionLeaseHolder } from '../session-lease.js'; import { classifyBrowserError } from './errors.js'; @@ -83,18 +83,23 @@ function isPreConnectFetchError(err: unknown): boolean { export type DaemonCommand = BrowserRuntimeCommand; export type DaemonResult = BrowserRuntimeResult; -export class BrowserCommandError extends Error { +export class BrowserCommandError extends CliError { constructor( message: string, - readonly code?: string, - readonly hint?: string, + code?: string, + hint?: string, readonly details?: unknown, ) { - super(message); - this.name = 'BrowserCommandError'; + super(code ?? 'BROWSER_COMMAND', message, hint, browserCommandExitCode(code)); } } +function browserCommandExitCode(code?: string): ExitCode { + return code === 'SESSION_PAUSED_FOR_HUMAN_HANDOFF' || code === 'SESSION_WINDOW_CONFLICT' + ? EXIT_CODES.TEMPFAIL + : EXIT_CODES.GENERIC_ERROR; +} + export { fetchDaemonStatus, getDaemonHealth, diff --git a/src/browser/runtime/local-cloak/session-manager.test.ts b/src/browser/runtime/local-cloak/session-manager.test.ts index 97e91a75..2c0fceeb 100644 --- a/src/browser/runtime/local-cloak/session-manager.test.ts +++ b/src/browser/runtime/local-cloak/session-manager.test.ts @@ -17,14 +17,17 @@ function fakeContext() { const emitPageEvent = (page: any, event: string, ...args: unknown[]) => { for (const listener of pageListeners.get(page)?.get(event) ?? []) listener(...args); }; - const fakePage = (opener?: any, windowId = ++windowCounter) => { + const fakePage = (opener?: any, windowId = ++windowCounter, initialUrl = 'https://example.com/') => { let closed = false; + let currentUrl = initialUrl; const page: any = { - goto: vi.fn().mockResolvedValue(undefined), - evaluate: vi.fn(async (fn: unknown) => { + goto: vi.fn().mockImplementation(async (url: string) => { + currentUrl = url; + }), + evaluate: vi.fn(async (fn: unknown, ...args: unknown[]) => { if (typeof fn !== 'function' || !String(fn).includes('window.open')) return 'ok'; const source = String(fn); - const popup = fakePage(source.includes('noopener') ? undefined : page, windowId); + const popup = fakePage(source.includes('noopener') ? undefined : page, windowId, typeof args[0] === 'string' ? args[0] : 'about:blank'); allPages.push(popup); queueMicrotask(() => { if (!source.includes('noopener')) emitPageEvent(page, 'popup', popup); @@ -33,7 +36,7 @@ function fakeContext() { return null; }), title: vi.fn().mockResolvedValue('Title'), - url: vi.fn().mockReturnValue('https://example.com/'), + url: vi.fn(() => currentUrl), screenshot: vi.fn().mockResolvedValue(Buffer.from('png')), bringToFront: vi.fn().mockResolvedValue(undefined), isClosed: vi.fn(() => closed), @@ -265,6 +268,30 @@ describe('CloakSessionManager', () => { expect((await manager.listPages(key)).map(tab => tab.sessionId)).toEqual(['session_a', 'session_a']); }); + it('adopts a noopener page when Chromium opens it in a new window', async () => { + const launched = fakeContext(); + const manager = new CloakSessionManager({ + baseDir: '/tmp/webcmd-test', + platform: 'darwin', + launchPersistentContext: vi.fn().mockResolvedValue(launched.context), + }); + const key = { profileId: 'default', session: 'session_a', sessionId: 'session_a', surface: 'browser' as const }; + const first = await manager.getPage(key); + const opened = launched.makePage(undefined, 999); + vi.mocked(first.page.evaluate).mockImplementationOnce(async () => { + queueMicrotask(() => launched.emitPage(opened)); + return null; + }); + + const second = await manager.newPage(key); + + expect(second.page).toBe(opened); + expect(launched.cdp.send.mock.calls.filter(([method, params]) => ( + method === 'Target.createTarget' && !(params as { hidden?: boolean })?.hidden + ))).toHaveLength(1); + expect((await manager.listPages(key)).map(tab => tab.sessionId)).toEqual(['session_a', 'session_a']); + }); + it('keeps a site popup owned while noopener tab creation uses another page', async () => { const launched = fakeContext(); const manager = new CloakSessionManager({ diff --git a/src/browser/runtime/local-cloak/session-manager.ts b/src/browser/runtime/local-cloak/session-manager.ts index 7e571258..84744ee7 100644 --- a/src/browser/runtime/local-cloak/session-manager.ts +++ b/src/browser/runtime/local-cloak/session-manager.ts @@ -10,6 +10,7 @@ import { CloakNetworkCapture } from './network.js'; import { findPackageRoot } from '../../../package-paths.js'; import { findExactCloakProfileProcesses } from './process-matcher.js'; import { log } from '../../../logger.js'; +import { CliError, EXIT_CODES } from '../../../errors.js'; const UNRESOLVED = Symbol('unresolved'); const TARGET_PAGE_MATCH_TIMEOUT_MS = 1_000; @@ -128,11 +129,14 @@ export interface BrowserRunSessionScope { onPage(listener: (page: PlaywrightPage) => void): () => void; } -export class SessionWindowConflictError extends Error { - readonly code = 'SESSION_WINDOW_CONFLICT'; - +export class SessionWindowConflictError extends CliError { constructor(pageId: string, sessionId: string, owner?: string) { - super(`Page ${pageId} is in a window owned by Session ${owner ?? 'unknown'}, not ${sessionId}.`); + super( + 'SESSION_WINDOW_CONFLICT', + `Page ${pageId} is in a window owned by Session ${owner ?? 'unknown'}, not ${sessionId}.`, + undefined, + EXIT_CODES.TEMPFAIL, + ); } } @@ -943,20 +947,22 @@ export class CloakSessionManager { await this.assertOwnedWindow(runtime, session.id, openerEntry); const opener = openerEntry.page; const openerWindowId = await this.windowIdForTarget(runtime, openerEntry.targetId, opener); - const openedPage = this.waitForContextPageInWindow(runtime, session.id, openerWindowId, TARGET_PAGE_MATCH_TIMEOUT_MS); + const targetUrl = `about:blank#webcmd-${Date.now()}-${Math.random().toString(36).slice(2)}`; + const openedPage = this.waitForContextPageForSession(runtime, session.id, openerWindowId, targetUrl, TARGET_PAGE_MATCH_TIMEOUT_MS); try { - await opener.evaluate(() => window.open('about:blank', '_blank', 'noopener,noreferrer')); + await opener.evaluate((url) => window.open(url, '_blank', 'noopener,noreferrer'), targetUrl); } catch {} const page = await openedPage; if (page) return page; return this.createWindowPage(runtime, windowMode); } - private async waitForContextPageInWindow( + private async waitForContextPageForSession( runtime: ProfileRuntime, sessionId: string, - windowId: number, + openerWindowId: number, + targetUrl: string, timeoutMs: number, ): Promise { return new Promise((resolve) => { @@ -973,9 +979,12 @@ export class CloakSessionManager { const targetId = await this.targetIdForPage(runtime, page).catch(() => undefined); if (!targetId) return; const actualWindowId = await this.windowIdForTarget(runtime, targetId, page).catch(() => undefined); - if (actualWindowId !== windowId) return; + if (actualWindowId === undefined) return; const owner = runtime.windowOwners.get(actualWindowId); if (owner !== undefined && owner !== sessionId) return; + const isRequestedTarget = page.url() === targetUrl; + const opener = await page.opener().catch(() => null); + if (opener && !isRequestedTarget) return; done(page); }; const onPage = (page: PlaywrightPage) => { void tryPage(page); }; diff --git a/src/engine.test.ts b/src/engine.test.ts index 3e4bf58f..5a7ddba3 100644 --- a/src/engine.test.ts +++ b/src/engine.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { discoverClis, discoverPlugins, ensureUserCliCompatShims, ensureUserAdapters, PLUGINS_DIR } from './discovery.js'; +import { discoverClis, discoverPlugins, ensureUserCliCompatShims, ensureUserAdapters } from './discovery.js'; import { executeCommand } from './execution.js'; import { getRegistry, cli, Strategy } from './registry.js'; import { clearAllHooks, onAfterExecute } from './hooks.js'; @@ -255,21 +255,17 @@ describe('ensureUserAdapters', () => { }); describe('discoverPlugins', () => { - const testPluginDir = path.join(PLUGINS_DIR, '__test-plugin__'); - const yamlPath = path.join(testPluginDir, 'greeting.yaml'); const symlinkTargetDir = path.join(os.tmpdir(), '__test-plugin-symlink-target__'); - const symlinkPluginDir = path.join(PLUGINS_DIR, '__test-plugin-symlink__'); - const brokenSymlinkDir = path.join(PLUGINS_DIR, '__test-plugin-broken__'); const dirSymlinkType: fs.symlink.Type = process.platform === 'win32' ? 'junction' : 'dir'; afterEach(async () => { - try { await fs.promises.rm(testPluginDir, { recursive: true }); } catch {} - try { await fs.promises.rm(symlinkPluginDir, { recursive: true, force: true }); } catch {} try { await fs.promises.rm(symlinkTargetDir, { recursive: true, force: true }); } catch {} - try { await fs.promises.rm(brokenSymlinkDir, { recursive: true, force: true }); } catch {} }); it('ignores YAML files in plugin directories (YAML format removed)', async () => { + const root = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'webcmd-yaml-plugin-')); + const testPluginDir = path.join(root, '__test-plugin__'); + const yamlPath = path.join(testPluginDir, 'greeting.yaml'); await fs.promises.mkdir(testPluginDir, { recursive: true }); await fs.promises.writeFile(yamlPath, ` site: __test-plugin__ @@ -279,16 +275,17 @@ strategy: public browser: false `); - await discoverPlugins(); + await discoverPlugins(root); const registry = getRegistry(); const cmd = registry.get('__test-plugin__/greeting'); expect(cmd).toBeUndefined(); + await fs.promises.rm(root, { recursive: true, force: true }); }); it('handles non-existent plugins directory gracefully', async () => { // discoverPlugins should not throw if ~/.webcmd/plugins/ does not exist - await expect(discoverPlugins()).resolves.not.toThrow(); + await expect(discoverPlugins(path.join(os.tmpdir(), 'missing-webcmd-plugin-root'))).resolves.not.toThrow(); }); it('discovers only the explicitly supplied installed-plugin root', async () => { @@ -313,7 +310,8 @@ cli({ }); it('ignores YAML files in symlinked plugin directories (YAML format removed)', async () => { - await fs.promises.mkdir(PLUGINS_DIR, { recursive: true }); + const root = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'webcmd-yaml-plugin-link-')); + const symlinkPluginDir = path.join(root, '__test-plugin-symlink__'); await fs.promises.mkdir(symlinkTargetDir, { recursive: true }); await fs.promises.writeFile(path.join(symlinkTargetDir, 'hello.yaml'), ` site: __test-plugin-symlink__ @@ -324,18 +322,21 @@ browser: false `); await fs.promises.symlink(symlinkTargetDir, symlinkPluginDir, dirSymlinkType); - await discoverPlugins(); + await discoverPlugins(root); const cmd = getRegistry().get('__test-plugin-symlink__/hello'); expect(cmd).toBeUndefined(); + await fs.promises.rm(root, { recursive: true, force: true }); }); it('skips broken plugin symlinks without throwing', async () => { - await fs.promises.mkdir(PLUGINS_DIR, { recursive: true }); + const root = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'webcmd-broken-plugin-link-')); + const brokenSymlinkDir = path.join(root, '__test-plugin-broken__'); await fs.promises.symlink(path.join(os.tmpdir(), '__missing-plugin-target__'), brokenSymlinkDir, dirSymlinkType); - await expect(discoverPlugins()).resolves.not.toThrow(); + await expect(discoverPlugins(root)).resolves.not.toThrow(); expect(getRegistry().get('__test-plugin-broken__/hello')).toBeUndefined(); + await fs.promises.rm(root, { recursive: true, force: true }); }); }); diff --git a/src/errors.test.ts b/src/errors.test.ts index c4673dec..fba9ada6 100644 --- a/src/errors.test.ts +++ b/src/errors.test.ts @@ -123,6 +123,13 @@ describe('toEnvelope', () => { expect(envelope.error).not.toHaveProperty('help'); }); + it('keeps Session window conflicts on the structured temporary-failure contract', async () => { + const { SessionWindowConflictError } = await import('./browser/runtime/local-cloak/session-manager.js'); + + expect(toEnvelope(new SessionWindowConflictError('page_1', 'session_a', 'session_b')).error) + .toMatchObject({ code: 'SESSION_WINDOW_CONFLICT', exitCode: 75 }); + }); + it('converts unknown Error to UNKNOWN envelope', () => { const envelope = toEnvelope(new Error('random failure')); expect(envelope).toEqual({ @@ -205,10 +212,11 @@ describe('SessionBusyError platform hints', () => { }); it('does not suggest killing a holder pid that is no longer alive', () => { - const err = new SessionBusyError(holder, 'linux', () => false); + const err = new SessionBusyError({ ...holder, sessionId: 'session_a' }, 'linux', () => false); expect(err.message).toContain('chatgpt ask'); expect(err.hint).toMatch(/wait/i); expect(err.hint).not.toContain('kill 4242'); + expect(err.hint).toContain('webcmd session close --force session_a'); }); it.each([ diff --git a/src/errors.ts b/src/errors.ts index e31b6157..58ac6bc7 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -154,13 +154,16 @@ function formatBusyHint(holder: SessionLeaseHolder, platform: string, pidAlive: const scope = holder.sessionId ? `Session ${holder.sessionId}${holder.admissionSite ? `, site ${holder.admissionSite}` : ''}. ` : ''; + const forceClose = !hasLivePid && holder.sessionId + ? ` Last resort: run \`webcmd session close --force ${holder.sessionId}\`.` + : ''; if (platform === 'win32') { return scope + (!hasLivePid - ? 'Wait for it to finish, or use Task Manager to stop the owning process if it is stuck.' + ? `Wait for it to finish, or use Task Manager to stop the owning process if it is stuck.${forceClose}` : `Wait for it to finish, or run \`Stop-Process -Id ${holder.pid}\` in PowerShell if it is stuck.`); } return scope + (!hasLivePid - ? 'Wait for it to finish, or stop the owning process if it is stuck.' + ? `Wait for it to finish, or stop the owning process if it is stuck.${forceClose}` : `Wait for it to finish, or run \`kill ${holder.pid}\` if it is stuck.`); } diff --git a/src/main.ts b/src/main.ts index 94411aaa..b496b008 100644 --- a/src/main.ts +++ b/src/main.ts @@ -87,7 +87,13 @@ if (!fastPathHandled) { const result = await runHostedCli(argv); process.exitCode = result.exitCode; } else { - await runLocalMain(); + const { installDaemonRunSignalCancellation } = await import('./signal-cancel.js'); + const uninstallSignalCancellation = installDaemonRunSignalCancellation(); + try { + await runLocalMain(); + } finally { + uninstallSignalCancellation(); + } } } } diff --git a/src/session-docs-sync.test.ts b/src/session-docs-sync.test.ts index 52f73698..4c2dc73e 100644 --- a/src/session-docs-sync.test.ts +++ b/src/session-docs-sync.test.ts @@ -9,28 +9,40 @@ describe('Session documentation sync', () => { it('does not call adapter siteSession modes browser Sessions', () => { const offenders = walkDocs() .map((file) => ({ file, text: fs.readFileSync(file, 'utf8') })) - .filter(({ text }) => /persistent sessions/i.test(text)); + .filter(({ text }) => /persistent sessions?/i.test(text)); expect(offenders.map(({ file }) => path.relative(ROOT, file))).toEqual([]); }); + it('flags retired Session wording and examples', () => { + expect(findRemovedSessionSyntaxes('Use webcmd browser session_a run')).toEqual(['positional browser command']); + expect(findRemovedSessionSyntaxes('Use webcmd browser tabs')).toEqual(['positional browser session']); + expect(findRemovedSessionSyntaxes('Use browser --session for this')).toEqual(['browser-local session selector']); + expect(findRemovedSessionSyntaxes('session_abc...')).toEqual(['truncated session id']); + expect(/persistent sessions?/i.test('a persistent session')).toBe(true); + }); + it('keeps removed Session syntaxes out of docs and skills', () => { const offenders = walkDocs() .flatMap((file) => { const text = fs.readFileSync(file, 'utf8'); - return [ - [/\bwebcmd browser (pattern as RegExp).test(text)) - .map(([, label]) => `${path.relative(ROOT, file)}: ${label}`); + return findRemovedSessionSyntaxes(text).map((label) => `${path.relative(ROOT, file)}: ${label}`); }); expect(offenders).toEqual([]); }); }); +function findRemovedSessionSyntaxes(text: string): string[] { + return [ + [/\bwebcmd browser (pattern as RegExp).test(text)) + .map(([, label]) => label as string); +} + function walkDocs(): string[] { const files: string[] = []; for (const root of DOC_ROOTS) { diff --git a/src/session-lease.test.ts b/src/session-lease.test.ts index 374a6f50..bc31ca92 100644 --- a/src/session-lease.test.ts +++ b/src/session-lease.test.ts @@ -6,6 +6,7 @@ import { clearDaemonRunContext, generateRunId, getDaemonRunContext, + getSignalDaemonRunContext, getSessionLeaseKey, isSessionLeaseCommand, isUnknownOutcomeError, @@ -78,6 +79,26 @@ describe('logical daemon run context', () => { expect(await first).toEqual(firstContext); expect(getDaemonRunContext()).toBeUndefined(); }); + + it('exposes a running async context only to out-of-band signal handlers', async () => { + let resume!: () => void; + const gate = new Promise(resolve => { resume = resolve; }); + const context: DaemonRunContext = { + runId: 'run_111_1_1', + command: 'browser run', + }; + + const pending = runWithDaemonRunContext(context, async () => { + await gate; + return getDaemonRunContext(); + }); + + expect(getDaemonRunContext()).toBeUndefined(); + expect(getSignalDaemonRunContext()).toEqual(context); + resume(); + await expect(pending).resolves.toEqual(context); + expect(getSignalDaemonRunContext()).toBeUndefined(); + }); }); describe('isUnknownOutcomeError', () => { diff --git a/src/session-lease.ts b/src/session-lease.ts index b89d75ba..d30b936a 100644 --- a/src/session-lease.ts +++ b/src/session-lease.ts @@ -16,11 +16,29 @@ export interface DaemonRunContext { } let activeRun: DaemonRunContext | undefined; +let signalRun: DaemonRunContext | undefined; const daemonRunContextStorage = new AsyncLocalStorage(); /** Run one logical execution with context isolated across its async chain. */ export function runWithDaemonRunContext(context: DaemonRunContext, callback: () => T): T { - return daemonRunContextStorage.run(context, callback); + const previousSignalRun = signalRun; + signalRun = context; + const restore = () => { + if (signalRun?.runId === context.runId) signalRun = previousSignalRun; + }; + try { + return daemonRunContextStorage.run(context, () => { + const result = callback(); + if (result && typeof (result as { finally?: unknown }).finally === 'function') { + return (result as unknown as Promise).finally(restore) as T; + } + restore(); + return result; + }); + } catch (err) { + restore(); + throw err; + } } export function setDaemonRunContext(context: DaemonRunContext): void { @@ -31,6 +49,10 @@ export function getDaemonRunContext(): DaemonRunContext | undefined { return daemonRunContextStorage.getStore() ?? activeRun; } +export function getSignalDaemonRunContext(): DaemonRunContext | undefined { + return signalRun ?? activeRun; +} + /** * Clear only the context still owned by `runId`. Deferred cleanup from an old * command must not clear a newer command's run identity. diff --git a/src/signal-cancel.test.ts b/src/signal-cancel.test.ts new file mode 100644 index 00000000..827e7cdd --- /dev/null +++ b/src/signal-cancel.test.ts @@ -0,0 +1,29 @@ +import { EventEmitter } from 'node:events'; +import { describe, expect, it, vi } from 'vitest'; +import { installDaemonRunSignalCancellation } from './signal-cancel.js'; +import { runWithDaemonRunContext } from './session-lease.js'; + +describe('installDaemonRunSignalCancellation', () => { + it('cancels the active daemon run once before exiting on SIGINT', async () => { + const proc = new EventEmitter() as NodeJS.Process; + proc.once = proc.once.bind(proc) as NodeJS.Process['once']; + proc.off = proc.off.bind(proc) as NodeJS.Process['off']; + const cancelRun = vi.fn().mockResolvedValue(undefined); + const exit = vi.fn(); + let resume!: () => void; + const gate = new Promise(resolve => { resume = resolve; }); + const pending = runWithDaemonRunContext({ runId: 'run_111_1_1', command: 'browser run' }, async () => { + await gate; + }); + + installDaemonRunSignalCancellation({ process: proc, cancelRun, exit }); + proc.emit('SIGINT', 'SIGINT'); + proc.emit('SIGTERM', 'SIGTERM'); + + await vi.waitFor(() => expect(exit).toHaveBeenCalledWith(130)); + expect(cancelRun).toHaveBeenCalledTimes(1); + expect(cancelRun).toHaveBeenCalledWith('run_111_1_1'); + resume(); + await pending; + }); +}); diff --git a/src/signal-cancel.ts b/src/signal-cancel.ts new file mode 100644 index 00000000..267b1711 --- /dev/null +++ b/src/signal-cancel.ts @@ -0,0 +1,37 @@ +import { EXIT_CODES } from './errors.js'; +import { getSignalDaemonRunContext } from './session-lease.js'; +import { cancelDaemonRun } from './browser/daemon-client.js'; + +type SignalName = 'SIGINT' | 'SIGTERM'; + +export function installDaemonRunSignalCancellation({ + process: proc = process, + cancelRun = cancelDaemonRun, + exit = (code: number) => process.exit(code), +}: { + process?: Pick; + cancelRun?: (runId: string) => Promise; + exit?: (code: number) => void; +} = {}): () => void { + let fired = false; + const cleanup = () => { + proc.off('SIGINT', onSignal); + proc.off('SIGTERM', onSignal); + }; + const onSignal = (signal: SignalName) => { + if (fired) return; + fired = true; + cleanup(); + const run = getSignalDaemonRunContext(); + const leave = () => exit(EXIT_CODES.INTERRUPTED); + if (!run) { + leave(); + return; + } + void cancelRun(run.runId).finally(leave); + }; + + proc.once('SIGINT', onSignal); + proc.once('SIGTERM', onSignal); + return cleanup; +} diff --git a/tests/e2e/cloak-session-concurrency.test.ts b/tests/e2e/cloak-session-concurrency.test.ts index 1d763566..35f038c7 100644 --- a/tests/e2e/cloak-session-concurrency.test.ts +++ b/tests/e2e/cloak-session-concurrency.test.ts @@ -41,7 +41,7 @@ describe('Cloak Session concurrency gate', () => { expect(cloakConfig).toContain('"darwin-arm64": "145.0.7632.109.2"'); }); - it('covers isolated Profiles, explicit Session windows, noopener tabs, close survival, and keeper repair', async () => { + it('covers isolated Profiles, explicit Session windows, noopener pages, close survival, and keeper repair', async () => { const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'webcmd-cloak-session-gate-')); tempDirs.push(configDir); const manager = new CloakSessionManager({ baseDir: configDir }); @@ -76,7 +76,7 @@ describe('Cloak Session concurrency gate', () => { const second = await manager.newPage(keyA); await second.page.goto(`${baseUrl}/second`); - expect(await windowId(second.page)).toBe(await windowId(first.page)); + expect(await windowId(second.page)).toEqual(expect.any(Number)); expect(await second.page.evaluate(() => window.opener === null)).toBe(true); expect(await second.page.evaluate(() => document.referrer)).toBe(''); expect((await manager.listPages(keyA)).map((tab) => tab.url)).toEqual([ From ab53badf17985635d0e37a44102f5cbf0f4cbfea Mon Sep 17 00:00:00 2001 From: beubax Date: Wed, 12 Aug 2026 22:38:59 +0530 Subject: [PATCH 23/27] fix hosted session list parity --- .../local-cloak/session-manager.test.ts | 20 +++++++++++++++++ .../runtime/local-cloak/session-manager.ts | 4 +++- src/hosted/client.ts | 11 ++++++++-- src/hosted/runner.test.ts | 22 ++++++++++++++++++- src/hosted/runner.ts | 18 +++++++++++---- src/session-docs-sync.test.ts | 7 ++++-- 6 files changed, 72 insertions(+), 10 deletions(-) diff --git a/src/browser/runtime/local-cloak/session-manager.test.ts b/src/browser/runtime/local-cloak/session-manager.test.ts index 2c0fceeb..ed5dbcce 100644 --- a/src/browser/runtime/local-cloak/session-manager.test.ts +++ b/src/browser/runtime/local-cloak/session-manager.test.ts @@ -268,6 +268,26 @@ describe('CloakSessionManager', () => { expect((await manager.listPages(key)).map(tab => tab.sessionId)).toEqual(['session_a', 'session_a']); }); + it('logs when window.open fails before falling back to another owned window', async () => { + const launched = fakeContext(); + const manager = new CloakSessionManager({ + baseDir: '/tmp/webcmd-test', + platform: 'darwin', + launchPersistentContext: vi.fn().mockResolvedValue(launched.context), + }); + const key = { profileId: 'default', session: 'session_a', sessionId: 'session_a', surface: 'browser' as const }; + const first = await manager.getPage(key); + vi.mocked(first.page.evaluate).mockRejectedValueOnce(new Error('window.open blocked')); + const warn = vi.spyOn(log, 'warn').mockImplementation(() => {}); + vi.useFakeTimers(); + + const second = manager.newPage(key); + await vi.advanceTimersByTimeAsync(1_000); + + await expect(second).resolves.toMatchObject({ page: expect.any(Object) }); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('window.open failed')); + }); + it('adopts a noopener page when Chromium opens it in a new window', async () => { const launched = fakeContext(); const manager = new CloakSessionManager({ diff --git a/src/browser/runtime/local-cloak/session-manager.ts b/src/browser/runtime/local-cloak/session-manager.ts index 84744ee7..6358c4e1 100644 --- a/src/browser/runtime/local-cloak/session-manager.ts +++ b/src/browser/runtime/local-cloak/session-manager.ts @@ -952,7 +952,9 @@ export class CloakSessionManager { try { await opener.evaluate((url) => window.open(url, '_blank', 'noopener,noreferrer'), targetUrl); - } catch {} + } catch (error) { + log.warn(`Cloak window.open failed while creating a Session tab; falling back to a new window: ${errorMessage(error)}`); + } const page = await openedPage; if (page) return page; return this.createWindowPage(runtime, windowMode); diff --git a/src/hosted/client.ts b/src/hosted/client.ts index 9b6fd447..29430868 100644 --- a/src/hosted/client.ts +++ b/src/hosted/client.ts @@ -144,8 +144,8 @@ export class HostedClient { return body; } - async listBrowserSessions(profile?: string): Promise { - const body = await this.request(`/v1/sessions${profileQuery(profile)}`); + async listBrowserSessions(profile?: string, limit?: number): Promise { + const body = await this.request(`/v1/sessions${sessionListQuery(profile, limit)}`); if (!isHostedBrowserSessionsResponse(body)) { throw protocolError('Webcmd Cloud returned an invalid browser session list.'); } @@ -559,6 +559,13 @@ function profileQuery(profile: string | undefined): string { return `?${params}`; } +function sessionListQuery(profile: string | undefined, limit: number | undefined): string { + const params = new URLSearchParams(); + if (profile !== undefined) params.set('profile', profile); + if (limit !== undefined) params.set('limit', String(limit)); + return params.size ? `?${params}` : ''; +} + function isHostedMarketplaceSearchResult(value: unknown): value is HostedMarketplaceSearchResult { return hasExactKeys(value, ['plugins', 'errors']) && Array.isArray(value.plugins) diff --git a/src/hosted/runner.test.ts b/src/hosted/runner.test.ts index 8050da31..cb30f7e8 100644 --- a/src/hosted/runner.test.ts +++ b/src/hosted/runner.test.ts @@ -589,7 +589,7 @@ describe('runHostedCli', () => { { url: 'https://api.example.com/v1/manifest', method: 'GET' }, { url: 'https://api.example.com/v1/sessions', method: 'POST', body: { profile: 'work' } }, { url: 'https://api.example.com/v1/manifest', method: 'GET' }, - { url: 'https://api.example.com/v1/sessions?profile=work', method: 'GET' }, + { url: 'https://api.example.com/v1/sessions?profile=work&limit=20', method: 'GET' }, { url: 'https://api.example.com/v1/manifest', method: 'GET' }, { url: 'https://api.example.com/v1/sessions/session_abc/close?profile=work', method: 'POST', body: { force: true } }, ]); @@ -610,6 +610,26 @@ describe('runHostedCli', () => { expect(stderr.text()).toMatch(/HOSTED_CONTRACT_MISMATCH/); }); + it('forwards hosted session list limit to Cloud', async () => { + const requests: Array<{ url: string; method: string }> = []; + const stdout = sink(); + const result = await runHostedCli(['session', 'list', '--limit', '50', '-f', 'json'], { + config: makeHostedConfig({ apiBaseUrl: 'https://api.example.com', apiKey: 'key' }), + stdout: stdout.stream, + fetchImpl: async (url, init) => { + requests.push({ url: String(url), method: init?.method ?? 'GET' }); + if (String(url).endsWith('/v1/manifest')) return manifestResponse(); + return new Response(JSON.stringify({ ok: true, result: [] })); + }, + }); + + expect(result).toEqual({ handled: true, exitCode: 0 }); + expect(requests).toContainEqual({ + url: 'https://api.example.com/v1/sessions?limit=50', + method: 'GET', + }); + }); + it.each(['create', 'get'])('rejects the removed profile %s subcommand', async (command) => { const stderr = sink(); const fetchImpl = vi.fn(); diff --git a/src/hosted/runner.ts b/src/hosted/runner.ts index 6750e74c..8a9778f2 100644 --- a/src/hosted/runner.ts +++ b/src/hosted/runner.ts @@ -1,7 +1,7 @@ import { readFileSync, writeFileSync } from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { Command, CommanderError } from 'commander'; +import { Command, CommanderError, InvalidArgumentError } from 'commander'; import { configureCompletionCommandSurface, configureListCommandSurface, @@ -449,7 +449,7 @@ async function dispatchHosted( type ParsedHostedSessionSurface = | { kind: 'help'; output: string } - | { kind: 'run'; command: 'create' | 'list' | 'close'; format: string; session?: string; force?: boolean }; + | { kind: 'run'; command: 'create' | 'list' | 'close'; format: string; session?: string; force?: boolean; limit?: number }; function parseHostedSessionSurface(argv: readonly string[], literal: boolean): ParsedHostedSessionSurface { let stdout = ''; @@ -465,7 +465,9 @@ function parseHostedSessionSurface(argv: readonly string[], literal: boolean): P session.exitOverride().configureOutput(output); const configure = (command: Command, format: string): Command => command.option('-f, --format ', 'Output format: table, json, yaml', format); configure(session.command('create'), 'yaml').action((options: { format: string }) => { parsed = { kind: 'run', command: 'create', format: options.format }; }); - configure(session.command('list'), 'table').action((options: { format: string }) => { parsed = { kind: 'run', command: 'list', format: options.format }; }); + configure(session.command('list').option('--limit ', 'Maximum Sessions to return (1-100)', parseHostedSessionListLimit, 20), 'table').action((options: { format: string; limit: number }) => { + parsed = { kind: 'run', command: 'list', format: options.format, limit: options.limit }; + }); configure(session.command('close').argument('').option('--force', 'Close even while the Session is busy or paused for handoff'), 'yaml').action((sessionId: string, options: { format: string; force?: boolean }) => { parsed = { kind: 'run', command: 'close', format: options.format, session: sessionId, force: options.force === true }; }); @@ -491,7 +493,7 @@ async function dispatchHostedSession( return; } if (parsed.command === 'list') { - const rows = (await client.listBrowserSessions(profile)).result; + const rows = (await client.listBrowserSessions(profile, parsed.limit)).result; if (rows.length === 0 && parsed.format === 'table') { await writeToStream(stdout, `No browser Sessions found${profile ? ` for Profile ${profile}` : ''}.\n`); return; @@ -502,6 +504,14 @@ async function dispatchHostedSession( await renderOutput((await client.closeBrowserSession(parsed.session!, profile, parsed.force === true)).result, { fmt: parsed.format, stdout }); } +function parseHostedSessionListLimit(value: string): number { + const limit = Number(value); + if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100) { + throw new InvalidArgumentError('Session list limit must be an integer from 1 to 100.'); + } + return limit; +} + function sessionCreateOutput(data: unknown): unknown { if (!data || typeof data !== 'object' || Array.isArray(data)) return data; const row = data as Record; diff --git a/src/session-docs-sync.test.ts b/src/session-docs-sync.test.ts index 4c2dc73e..58722ba3 100644 --- a/src/session-docs-sync.test.ts +++ b/src/session-docs-sync.test.ts @@ -9,7 +9,7 @@ describe('Session documentation sync', () => { it('does not call adapter siteSession modes browser Sessions', () => { const offenders = walkDocs() .map((file) => ({ file, text: fs.readFileSync(file, 'utf8') })) - .filter(({ text }) => /persistent sessions?/i.test(text)); + .filter(({ text }) => siteSessionModeAsSessionPattern.test(text)); expect(offenders.map(({ file }) => path.relative(ROOT, file))).toEqual([]); }); @@ -19,7 +19,8 @@ describe('Session documentation sync', () => { expect(findRemovedSessionSyntaxes('Use webcmd browser tabs')).toEqual(['positional browser session']); expect(findRemovedSessionSyntaxes('Use browser --session for this')).toEqual(['browser-local session selector']); expect(findRemovedSessionSyntaxes('session_abc...')).toEqual(['truncated session id']); - expect(/persistent sessions?/i.test('a persistent session')).toBe(true); + expect(siteSessionModeAsSessionPattern.test('a persistent session')).toBe(true); + expect(siteSessionModeAsSessionPattern.test('an ephemeral session')).toBe(true); }); it('keeps removed Session syntaxes out of docs and skills', () => { @@ -43,6 +44,8 @@ function findRemovedSessionSyntaxes(text: string): string[] { .map(([, label]) => label as string); } +const siteSessionModeAsSessionPattern = /\b(?:persistent|ephemeral) sessions?\b/i; + function walkDocs(): string[] { const files: string[] = []; for (const root of DOC_ROOTS) { From fd670eaceebe859229b1f88120b018b4c9bec38b Mon Sep 17 00:00:00 2001 From: beubax Date: Wed, 12 Aug 2026 23:16:44 +0530 Subject: [PATCH 24/27] fix hosted session contract parity --- docs/troubleshooting.mdx | 21 +++++++++++ package-lock.json | 4 +- package.json | 4 +- plugins/linkedin/package.json | 2 +- plugins/linkedin/webcmd-plugin.json | 2 +- skills/webcmd-browser/SKILL.md | 3 ++ skills/webcmd-usage/SKILL.md | 7 ++++ src/hosted/client.test.ts | 42 +++++++++++++++++++++ src/hosted/client.ts | 39 +++++++++++++------ src/hosted/runner.test.ts | 14 ++++--- src/hosted/runner.ts | 17 +++++++-- src/hosted/types.ts | 17 +++++---- src/session-docs-sync.test.ts | 20 ++++++++++ src/skills.test.ts | 5 +++ tests/e2e/cloak-session-concurrency.test.ts | 14 +++++-- webcmd-plugin.json | 2 +- 16 files changed, 172 insertions(+), 41 deletions(-) diff --git a/docs/troubleshooting.mdx b/docs/troubleshooting.mdx index 2ee3f6d8..2382c2ad 100644 --- a/docs/troubleshooting.mdx +++ b/docs/troubleshooting.mdx @@ -35,6 +35,27 @@ Webcmd says the browser bridge is unavailable. Diagnose the daemon and runtime, Public adapters may still work. Authenticated, intercepted, UI, and browser-backed workflows need the bridge. +## Browser Session Errors + +```text +Webcmd returned a SESSION_* error. Explain which Profile/Session is involved, whether another command or human handoff is holding it, and give me the next safe command. +``` + +Common Session codes: + +| Code | Meaning | Next step | +| --- | --- | --- | +| `SESSION_REQUIRED` | A raw browser command needs a root Session selector. | Run `webcmd session create -f json`, then retry as `webcmd --session browser ...`. | +| `INVALID_SESSION_SELECTOR` | The selector is not an opaque Webcmd Session ID. | Use an ID returned by `webcmd session create` or `webcmd session list`. | +| `SESSION_SELECTOR_POSITION` | `--session` was placed after the command name. | Move it before the command: `webcmd --session browser ...`. | +| `SESSION_NOT_FOUND` | The selected Session is missing for the current Profile. | Run `webcmd session list -f json`; create a new Session if needed. | +| `INVALID_SESSION_LIMIT` | `session list --limit` is outside 1-100. | Retry with a limit from 1 to 100. | +| `SESSION_BUSY` | Another command is writing in the same Session or site scope. | Wait for the holder to finish; if it is dead, use `webcmd session close --force` as the last resort. | +| `SESSION_PAUSED_FOR_HUMAN_HANDOFF` | The Session is waiting for user action such as sign-in. | Finish the action in the browser, then run the returned verifier before retrying. | +| `SESSION_WINDOW_CONFLICT` | The Session's window ownership no longer matches Webcmd's expected window. | Inspect with `webcmd session list`; close and recreate the Session if needed. | +| `SESSION_CAPACITY_EXCEEDED` | The account or runtime has reached its concurrent Session limit. | Close idle Sessions or wait for one to finish. | +| `HOSTED_CONTRACT_MISMATCH` | The hosted CLI and cloud API disagree about the Session protocol. | Upgrade `webcmd` to the version pinned by the cloud service, then retry. | + ## Multiple Profiles Are Connected ```text diff --git a/package-lock.json b/package-lock.json index 16a0b75d..042dfcda 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@agentrhq/webcmd", - "version": "0.6.1", + "version": "0.6.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@agentrhq/webcmd", - "version": "0.6.1", + "version": "0.6.2", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { diff --git a/package.json b/package.json index 31d713db..0b0cf0d1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@agentrhq/webcmd", - "version": "0.6.1", + "version": "0.6.2", "description": "Turn websites, browser sessions, desktop apps, and local tools into deterministic CLI surfaces for humans and AI agents.", "engines": { "node": ">=20.6.0" @@ -73,7 +73,7 @@ "test:plugin": "vitest run --project plugin", "test:all": "vitest run", "test:e2e": "vitest run --project e2e-fixed-port --project e2e", - "gate:cloak-sessions": "vitest run --project e2e tests/e2e/cloak-session-concurrency.test.ts", + "gate:cloak-sessions": "WEBCMD_LIVE_CLOAK=1 vitest run --project e2e tests/e2e/cloak-session-concurrency.test.ts", "check-community-plugins": "tsx scripts/sync-community-plugins.ts --check", "advise:listing-id-pairing": "node scripts/check-listing-id-pairing.mjs", "check:package-bin": "node scripts/check-package-bin.mjs", diff --git a/plugins/linkedin/package.json b/plugins/linkedin/package.json index a29edc3b..d2845134 100644 --- a/plugins/linkedin/package.json +++ b/plugins/linkedin/package.json @@ -4,6 +4,6 @@ "type": "module", "description": "LinkedIn profile, network, messaging, job, and Sales Navigator commands for WebCMD", "peerDependencies": { - "@agentrhq/webcmd": ">=0.6.1" + "@agentrhq/webcmd": ">=0.6.2" } } diff --git a/plugins/linkedin/webcmd-plugin.json b/plugins/linkedin/webcmd-plugin.json index 6ac56033..00bf8c16 100644 --- a/plugins/linkedin/webcmd-plugin.json +++ b/plugins/linkedin/webcmd-plugin.json @@ -2,7 +2,7 @@ "name": "linkedin", "version": "0.1.0", "description": "LinkedIn profile, network, messaging, job, and Sales Navigator commands for WebCMD", - "webcmd": ">=0.6.1", + "webcmd": ">=0.6.2", "author": { "name": "WebCMD Agent", "handle": "agentrhq" diff --git a/skills/webcmd-browser/SKILL.md b/skills/webcmd-browser/SKILL.md index 1eb7bc6d..3bc5a7c0 100644 --- a/skills/webcmd-browser/SKILL.md +++ b/skills/webcmd-browser/SKILL.md @@ -212,6 +212,9 @@ Use `run` and inspect `page.frames()`; target the frame by URL/name and keep ifr | Bound page is wrong or stale | Run `tabs`, choose the current page id, then `bind --page ` again. | | `run` times out before returning | Increase `--timeout` only after checking whether the wait condition is wrong. | | Write may have happened before timeout | Take a fresh snapshot before retrying. Avoid duplicate submissions. | +| `SESSION_REQUIRED` | Create a Session, then retry with root `--session `. | +| `SESSION_BUSY` | Wait for the listed holder; if it is dead, `webcmd session close --force` is the last resort. | +| `SESSION_PAUSED_FOR_HUMAN_HANDOFF` | Finish the handoff and run the returned verifier before retrying. | | Login wall appears | Use the Authentication and human handoff recipe. | | User reports login complete | Run the returned verifier first. Without one, inspect fresh state and verify identity/post-action state. | | Page shows expected data but returned extraction is empty | Use `snapshot --snapshot-mode tree` to locate scope, or capture the network response in `run`. | diff --git a/skills/webcmd-usage/SKILL.md b/skills/webcmd-usage/SKILL.md index e6b83fa8..a5d8a7ce 100644 --- a/skills/webcmd-usage/SKILL.md +++ b/skills/webcmd-usage/SKILL.md @@ -72,6 +72,13 @@ webcmd session close session_abc `webcmd session close ` is blocked while that Session has a live human handoff. Adapter commands may omit `--session` and use the selected profile's adapter-default session. Pass `--session ` to route one into an explicit session. Raw browser commands never omit it; the retired positional session form is invalid. +Structured Session failures are runtime state, not adapter breakage. `SESSION_REQUIRED` +means add a root `--session ` selector before `browser`; `SESSION_BUSY` +means another holder owns the same Session or site scope, so wait, inspect +`webcmd session list`, and use `webcmd session close --force` only +when the holder is dead. `SESSION_PAUSED_FOR_HUMAN_HANDOFF` means finish the +handoff and run its verifier before retrying. + ## Prerequisites By Strategy | Strategy | Needs | diff --git a/src/hosted/client.test.ts b/src/hosted/client.test.ts index 6cfed57a..1cbe67d3 100644 --- a/src/hosted/client.test.ts +++ b/src/hosted/client.test.ts @@ -71,6 +71,48 @@ const validTraceUrlCases = [ ] as const; describe('HostedClient', () => { + it('accepts the hosted Session API wire contract', async () => { + const requests: Array<{ url: string; method: string; body?: string }> = []; + const session = { + id: 'session_wire', + kind: 'explicit', + profileId: 'profile_default', + runtimeState: 'active', + handoff: { site: 'github', expiresAt: '2026-08-12T00:15:00.000Z' }, + createdAt: '2026-08-12T00:00:00.000Z', + updatedAt: '2026-08-12T00:01:00.000Z', + lastUsedAt: '2026-08-12T00:02:00.000Z', + }; + const client = new HostedClient({ + apiBaseUrl: 'https://api.example.com', + apiKey: 'key', + fetchImpl: async (url, init) => { + requests.push({ + url: String(url), + method: init?.method ?? 'GET', + ...(init?.body ? { body: String(init.body) } : {}), + }); + if (String(url).endsWith('/v1/sessions')) return new Response(JSON.stringify({ ok: true, session })); + if (String(url).endsWith('/v1/sessions?profile=default&limit=20')) return new Response(JSON.stringify({ ok: true, sessions: [session] })); + return new Response(JSON.stringify({ ok: true, closed: true, alreadyIdle: false, session: 'session_wire' })); + }, + }); + + await expect(client.createBrowserSession()).resolves.toEqual({ ok: true, session }); + await expect(client.listBrowserSessions('default', 20)).resolves.toEqual({ ok: true, sessions: [session] }); + await expect(client.closeBrowserSession('session_wire')).resolves.toEqual({ + ok: true, + closed: true, + alreadyIdle: false, + session: 'session_wire', + }); + expect(requests.map(({ url, method }) => ({ url, method }))).toEqual([ + { url: 'https://api.example.com/v1/sessions', method: 'POST' }, + { url: 'https://api.example.com/v1/sessions?profile=default&limit=20', method: 'GET' }, + { url: 'https://api.example.com/v1/sessions/session_wire/close', method: 'POST' }, + ]); + }); + it('searches the authenticated marketplace and validates every public plugin field', async () => { const requests: Array<{ url: string; method: string }> = []; const client = new HostedClient({ diff --git a/src/hosted/client.ts b/src/hosted/client.ts index 29430868..cee791b6 100644 --- a/src/hosted/client.ts +++ b/src/hosted/client.ts @@ -522,37 +522,52 @@ function isHostedProfilesResponse(value: unknown): value is HostedProfilesRespon } function isHostedBrowserSessionResponse(value: unknown): value is HostedBrowserSessionResponse { - return hasExactKeys(value, ['ok', 'result']) + return hasExactKeys(value, ['ok', 'session']) && value.ok === true - && isHostedBrowserSession(value.result); + && isHostedBrowserSession(value.session); } function isHostedBrowserSessionsResponse(value: unknown): value is HostedBrowserSessionsResponse { - return hasExactKeys(value, ['ok', 'result']) + return hasExactKeys(value, ['ok', 'sessions']) && value.ok === true - && Array.isArray(value.result) - && value.result.every(isHostedBrowserSession); + && Array.isArray(value.sessions) + && value.sessions.every(isHostedBrowserSession); } function isHostedBrowserSessionCloseResponse(value: unknown): value is HostedBrowserSessionCloseResponse { - return hasExactKeys(value, ['ok', 'result']) + return hasOnlyKeys(value, ['ok', 'closed', 'alreadyIdle', 'session', 'displaced']) && value.ok === true - && hasExactKeys(value.result, ['closed', 'alreadyIdle', 'session']) - && typeof value.result.closed === 'boolean' - && typeof value.result.alreadyIdle === 'boolean' - && typeof value.result.session === 'string'; + && typeof value.closed === 'boolean' + && typeof value.alreadyIdle === 'boolean' + && typeof value.session === 'string' + && (value.displaced === undefined || isHostedSessionDisplacement(value.displaced)); } function isHostedBrowserSession(value: unknown): boolean { - return hasExactKeys(value, ['id', 'kind', 'profileId', 'runtimeState', 'createdAt', 'lastUsedAt']) + return hasExactKeys(value, ['id', 'kind', 'profileId', 'runtimeState', 'handoff', 'createdAt', 'updatedAt', 'lastUsedAt']) && typeof value.id === 'string' - && value.kind === 'browser' + && (value.kind === 'explicit' || value.kind === 'adapter-default') && typeof value.profileId === 'string' && (value.runtimeState === 'active' || value.runtimeState === 'idle') + && isHostedSessionHandoff(value.handoff) && typeof value.createdAt === 'string' + && typeof value.updatedAt === 'string' && typeof value.lastUsedAt === 'string'; } +function isHostedSessionHandoff(value: unknown): boolean { + return value === null + || (hasExactKeys(value, ['site', 'expiresAt']) + && typeof value.site === 'string' + && typeof value.expiresAt === 'string'); +} + +function isHostedSessionDisplacement(value: unknown): boolean { + return hasOnlyKeys(value, ['executionId', 'handoffSite']) + && (value.executionId === undefined || typeof value.executionId === 'string') + && (value.handoffSite === undefined || typeof value.handoffSite === 'string'); +} + function profileQuery(profile: string | undefined): string { if (profile === undefined) return ''; const params = new URLSearchParams({ profile }); diff --git a/src/hosted/runner.test.ts b/src/hosted/runner.test.ts index cb30f7e8..961c13b1 100644 --- a/src/hosted/runner.test.ts +++ b/src/hosted/runner.test.ts @@ -549,8 +549,9 @@ describe('runHostedCli', () => { it('preflights the hosted contract before managing browser Sessions', async () => { const requests: Array<{ url: string; method: string; body?: unknown }> = []; const session = { - id: 'session_abc', kind: 'browser', profileId: 'profile_work', runtimeState: 'idle', - createdAt: '2026-01-01T00:00:00.000Z', lastUsedAt: '2026-01-01T00:00:00.000Z', + id: 'session_abc', kind: 'explicit', profileId: 'profile_work', runtimeState: 'idle', + handoff: { site: 'github', expiresAt: '2026-01-01T00:15:00.000Z' }, + createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:01:00.000Z', lastUsedAt: '2026-01-01T00:02:00.000Z', }; const fetchImpl = vi.fn(async (url, init) => { const request = { @@ -559,11 +560,11 @@ describe('runHostedCli', () => { }; requests.push(request); if (request.url.endsWith('/v1/manifest')) return manifestResponse(); - if (request.method === 'POST' && request.url.endsWith('/v1/sessions')) return new Response(JSON.stringify({ ok: true, result: session })); + if (request.method === 'POST' && request.url.endsWith('/v1/sessions')) return new Response(JSON.stringify({ ok: true, session })); if (request.method === 'POST' && request.url.endsWith(`/v1/sessions/${session.id}/close?profile=work`)) { - return new Response(JSON.stringify({ ok: true, result: { closed: false, alreadyIdle: true, session: session.id } })); + return new Response(JSON.stringify({ ok: true, closed: false, alreadyIdle: true, session: session.id })); } - return new Response(JSON.stringify({ ok: true, result: [session] })); + return new Response(JSON.stringify({ ok: true, sessions: [session] })); }); const outputs: string[] = []; @@ -583,6 +584,7 @@ describe('runHostedCli', () => { outputs.push(stdout.text()); } expect(outputs[0]).toContain('"runtimeState": "idle"'); + expect(outputs[1]).toContain('"handoff": "github until 2026-01-01T00:15:00.000Z"'); expect(outputs[0]).not.toContain('"profileId"'); expect(requests).toEqual([ @@ -619,7 +621,7 @@ describe('runHostedCli', () => { fetchImpl: async (url, init) => { requests.push({ url: String(url), method: init?.method ?? 'GET' }); if (String(url).endsWith('/v1/manifest')) return manifestResponse(); - return new Response(JSON.stringify({ ok: true, result: [] })); + return new Response(JSON.stringify({ ok: true, sessions: [] })); }, }); diff --git a/src/hosted/runner.ts b/src/hosted/runner.ts index 8a9778f2..f0a62753 100644 --- a/src/hosted/runner.ts +++ b/src/hosted/runner.ts @@ -489,19 +489,20 @@ async function dispatchHostedSession( profile?: string, ): Promise { if (parsed.command === 'create') { - await renderOutput(sessionCreateOutput((await client.createBrowserSession(profile)).result), { fmt: parsed.format, columns: ['id', 'kind', 'runtimeState'], stdout }); + await renderOutput(sessionCreateOutput((await client.createBrowserSession(profile)).session), { fmt: parsed.format, columns: ['id', 'kind', 'runtimeState'], stdout }); return; } if (parsed.command === 'list') { - const rows = (await client.listBrowserSessions(profile, parsed.limit)).result; + const rows = (await client.listBrowserSessions(profile, parsed.limit)).sessions + .map((row) => ({ ...row, handoff: formatHostedSessionHandoff(row.handoff) })); if (rows.length === 0 && parsed.format === 'table') { await writeToStream(stdout, `No browser Sessions found${profile ? ` for Profile ${profile}` : ''}.\n`); return; } - await renderOutput(rows, { fmt: parsed.format, columns: ['id', 'kind', 'runtimeState'], stdout }); + await renderOutput(rows, { fmt: parsed.format, columns: ['id', 'kind', 'runtimeState', 'handoff'], stdout }); return; } - await renderOutput((await client.closeBrowserSession(parsed.session!, profile, parsed.force === true)).result, { fmt: parsed.format, stdout }); + await renderOutput(await client.closeBrowserSession(parsed.session!, profile, parsed.force === true), { fmt: parsed.format, stdout }); } function parseHostedSessionListLimit(value: string): number { @@ -518,6 +519,14 @@ function sessionCreateOutput(data: unknown): unknown { return { id: row.id, kind: row.kind, runtimeState: row.runtimeState }; } +function formatHostedSessionHandoff(handoff: unknown): string { + if (!handoff || typeof handoff !== 'object') return ''; + const row = handoff as { site?: unknown; expiresAt?: unknown }; + return typeof row.site === 'string' && typeof row.expiresAt === 'string' + ? `${row.site} until ${row.expiresAt}` + : ''; +} + function hasPresentFileArgument( command: import('./types.js').HostedCommand, args: Record, diff --git a/src/hosted/types.ts b/src/hosted/types.ts index b02da089..6f61c02c 100644 --- a/src/hosted/types.ts +++ b/src/hosted/types.ts @@ -67,30 +67,31 @@ export interface HostedProfilesResponse { export interface HostedBrowserSession { id: string; - kind: 'browser'; + kind: 'explicit' | 'adapter-default'; profileId: string; runtimeState: 'active' | 'idle'; + handoff: { site: string; expiresAt: string } | null; createdAt: string; + updatedAt: string; lastUsedAt: string; } export interface HostedBrowserSessionResponse { ok: true; - result: HostedBrowserSession; + session: HostedBrowserSession; } export interface HostedBrowserSessionsResponse { ok: true; - result: HostedBrowserSession[]; + sessions: HostedBrowserSession[]; } export interface HostedBrowserSessionCloseResponse { ok: true; - result: { - closed: boolean; - alreadyIdle: boolean; - session: string; - }; + closed: boolean; + alreadyIdle: boolean; + session: string; + displaced?: { executionId?: string; handoffSite?: string }; } export interface HostedMarketplacePlugin { diff --git a/src/session-docs-sync.test.ts b/src/session-docs-sync.test.ts index 58722ba3..fae1d1d3 100644 --- a/src/session-docs-sync.test.ts +++ b/src/session-docs-sync.test.ts @@ -32,6 +32,26 @@ describe('Session documentation sync', () => { expect(offenders).toEqual([]); }); + + it('documents structured Session runtime errors', () => { + const troubleshooting = fs.readFileSync(path.join(ROOT, 'docs', 'troubleshooting.mdx'), 'utf8'); + + for (const code of [ + 'SESSION_REQUIRED', + 'INVALID_SESSION_SELECTOR', + 'SESSION_SELECTOR_POSITION', + 'SESSION_NOT_FOUND', + 'INVALID_SESSION_LIMIT', + 'SESSION_BUSY', + 'SESSION_PAUSED_FOR_HUMAN_HANDOFF', + 'SESSION_WINDOW_CONFLICT', + 'SESSION_CAPACITY_EXCEEDED', + 'HOSTED_CONTRACT_MISMATCH', + ]) { + expect(troubleshooting).toContain(code); + } + expect(troubleshooting).toContain('webcmd session close --force'); + }); }); function findRemovedSessionSyntaxes(text: string): string[] { diff --git a/src/skills.test.ts b/src/skills.test.ts index 752b18fa..37499ce8 100644 --- a/src/skills.test.ts +++ b/src/skills.test.ts @@ -226,10 +226,15 @@ describe('webcmd skills content', () => { expect(usage).toContain('webcmd session create -f json'); expect(usage).toContain('webcmd --session session_abc browser'); + expect(usage).toContain('SESSION_BUSY'); + expect(usage).toContain('SESSION_REQUIRED'); expect(usage).toMatch(/Adapter commands may omit `--session`[\s\S]{0,200}adapter-default session/i); expect(usage).toMatch(/retired positional session form is invalid/i); expect(browser).toMatch(/Profiles are cookie jars[\s\S]{0,180}sessions are browser workspaces\/windows/i); expect(browser).toMatch(/Parallel agents use separate sessions/i); + expect(browser).toContain('SESSION_BUSY'); + expect(browser).toContain('SESSION_PAUSED_FOR_HUMAN_HANDOFF'); + expect(browser).toContain('webcmd session close --force'); for (const skill of [usage, browser, autofix]) { expect(skill).toMatch(/handoff is scoped to (?:its|the) Session/i); expect(skill).toMatch(/(?:verify_command|handoff\.verifyCommand)[\s\S]{0,200}verbatim[\s\S]{0,120}`--session`/i); diff --git a/tests/e2e/cloak-session-concurrency.test.ts b/tests/e2e/cloak-session-concurrency.test.ts index 35f038c7..07a7c09d 100644 --- a/tests/e2e/cloak-session-concurrency.test.ts +++ b/tests/e2e/cloak-session-concurrency.test.ts @@ -27,7 +27,7 @@ afterAll(async () => { for (const dir of tempDirs.splice(0)) fs.rmSync(dir, { recursive: true, force: true }); }); -describe('Cloak Session concurrency gate', () => { +describe.skipIf(process.env.WEBCMD_LIVE_CLOAK !== '1')('Cloak Session concurrency gate', () => { it('keeps Cloak and Playwright pinned to the supported live gate runtime', () => { const appPkg = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8')); const cloakPkg = JSON.parse(fs.readFileSync(path.join(ROOT, 'node_modules/cloakbrowser/package.json'), 'utf8')); @@ -66,19 +66,25 @@ describe('Cloak Session concurrency gate', () => { }; try { const [first, profileBFirst] = await Promise.all([manager.getPage(keyA), manager.getPage(keyB)]); - await first.page.goto(`${baseUrl}/first`); - await profileBFirst.page.goto(`${baseUrl}/profile-b`); + await Promise.all([ + first.page.goto(`${baseUrl}/first`), + profileBFirst.page.goto(`${baseUrl}/profile-b`), + ]); const otherSession = await manager.getPage(keyA2); await otherSession.page.goto(`${baseUrl}/other-session`); expect(await windowId(otherSession.page)).not.toBe(await windowId(first.page)); - const second = await manager.newPage(keyA); + await first.page.bringToFront(); + expect(await first.page.evaluate(() => document.hasFocus())).toBe(true); + + const second = await manager.newPage({ ...keyA, windowMode: 'background' }); await second.page.goto(`${baseUrl}/second`); expect(await windowId(second.page)).toEqual(expect.any(Number)); expect(await second.page.evaluate(() => window.opener === null)).toBe(true); expect(await second.page.evaluate(() => document.referrer)).toBe(''); + expect(await first.page.evaluate(() => document.hasFocus())).toBe(true); expect((await manager.listPages(keyA)).map((tab) => tab.url)).toEqual([ `${baseUrl}/first`, `${baseUrl}/second`, diff --git a/webcmd-plugin.json b/webcmd-plugin.json index e3b08a3c..3170090d 100644 --- a/webcmd-plugin.json +++ b/webcmd-plugin.json @@ -638,7 +638,7 @@ "path": "plugins/linkedin", "version": "0.1.0", "description": "LinkedIn profile, network, messaging, job, and Sales Navigator commands for WebCMD", - "webcmd": ">=0.6.1", + "webcmd": ">=0.6.2", "author": { "name": "WebCMD Agent", "handle": "agentrhq" From 224bc74b6aeb7a4de0bc0ea75492febc6966e475 Mon Sep 17 00:00:00 2001 From: beubax Date: Wed, 12 Aug 2026 23:37:12 +0530 Subject: [PATCH 25/27] fix daemon disconnect cancellation --- src/daemon/server.test.ts | 36 +++++++++++++++++++++ src/daemon/server.ts | 26 +++++++++++++-- tests/e2e/cloak-session-concurrency.test.ts | 30 +++++++++++++++++ 3 files changed, 89 insertions(+), 3 deletions(-) diff --git a/src/daemon/server.test.ts b/src/daemon/server.test.ts index bfcd8528..570c9e7a 100644 --- a/src/daemon/server.test.ts +++ b/src/daemon/server.test.ts @@ -1,4 +1,5 @@ import { AddressInfo } from 'node:net'; +import { request as httpRequest } from 'node:http'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { DAEMON_HEADER_NAME } from '../constants.js'; import type { BrowserRuntimeCommand, BrowserRuntimeResult, BrowserRuntimeStatus } from '../browser/protocol.js'; @@ -844,6 +845,41 @@ describe('createDaemonServer', () => { expect(provider.commands.map((command) => command.id)).toEqual(['owner', 'next-owner']); }); + it('aborts pending work when the command request disconnects', async () => { + const provider = new FakeProvider(); + let aborted = false; + provider.dispatchImpl = (command, signal) => new Promise((resolve) => { + if (command.id !== 'owner') { + resolve({ id: command.id, ok: true, data: 'done' }); + return; + } + signal?.addEventListener('abort', () => { + aborted = true; + resolve({ id: command.id, ok: false, errorCode: 'aborted', error: 'aborted' }); + }); + }); + const { baseUrl } = await start(provider); + const url = new URL('/command', baseUrl); + const body = JSON.stringify(persistentWrite('owner', 'run_100_1_1')); + const req = httpRequest(url, { + method: 'POST', + headers: { + [DAEMON_HEADER_NAME]: '1', + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(body), + }, + }); + req.on('error', () => undefined); + req.end(body); + + await vi.waitFor(() => expect(provider.commands.map((command) => command.id)).toEqual(['owner'])); + req.destroy(); + + await vi.waitFor(() => expect(aborted).toBe(true)); + expect((await postCommand(baseUrl, persistentWrite('next-owner', 'run_200_2_2'))).status).toBe(200); + expect(provider.commands.map((command) => command.id)).toEqual(['owner', 'next-owner']); + }); + it('returns only sanitized current holders from status', async () => { let now = 1_000; vi.spyOn(Date, 'now').mockImplementation(() => now); diff --git a/src/daemon/server.ts b/src/daemon/server.ts index 926ae3c6..45e5a59a 100644 --- a/src/daemon/server.ts +++ b/src/daemon/server.ts @@ -202,6 +202,20 @@ function jsonResponse( res.end(JSON.stringify(data)); } +function abortOnResponseClose(res: ServerResponse, controller: AbortController, onAbort: () => void): () => void { + let completed = false; + const onClose = () => { + if (completed) return; + onAbort(); + controller.abort(); + }; + res.once('close', onClose); + return () => { + completed = true; + res.off('close', onClose); + }; +} + export function createDaemonServer(provider: BrowserRuntimeProvider, opts: DaemonServerOptions): DaemonServerHandle { const port = opts.port ?? DEFAULT_DAEMON_PORT; const host = opts.host ?? '127.0.0.1'; @@ -409,15 +423,21 @@ export function createDaemonServer(provider: BrowserRuntimeProvider, opts: Daemo } } const abortController = new AbortController(); + let responseAborted = false; + const removeResponseAbort = abortOnResponseClose(res, abortController, () => { + responseAborted = true; + }); const commandPromise = provider.dispatch(resolvedBody, abortController.signal).finally(() => { - if (leaseKey && runId) leases.heartbeat(leaseKey, runId); + removeResponseAbort(); + if (leaseKey && runId && !responseAborted) leases.heartbeat(leaseKey, runId); pending.delete(body.id); - if (runId && forceClosingRuns.delete(runId)) leases.releaseByRunId(runId); + if (runId && (responseAborted || forceClosingRuns.delete(runId))) leases.releaseByRunId(runId); }); pending.set(body.id, { promise: commandPromise, runId, leaseKey, abortController }); const result = await waitForCommandResult(body, commandPromise); + removeResponseAbort(); if (!result.ok) pushLog('warn', `Command ${body.id} failed: ${result.error ?? result.errorCode ?? 'unknown error'}`); - jsonResponse(res, result.ok ? 200 : result.errorCode === 'command_result_unknown' ? 408 : 400, result); + if (!responseAborted) jsonResponse(res, result.ok ? 200 : result.errorCode === 'command_result_unknown' ? 408 : 400, result); } catch (err) { jsonResponse(res, 400, { ok: false, error: err instanceof Error ? err.message : 'Invalid request' }); } diff --git a/tests/e2e/cloak-session-concurrency.test.ts b/tests/e2e/cloak-session-concurrency.test.ts index 07a7c09d..faaed816 100644 --- a/tests/e2e/cloak-session-concurrency.test.ts +++ b/tests/e2e/cloak-session-concurrency.test.ts @@ -102,4 +102,34 @@ describe.skipIf(process.env.WEBCMD_LIVE_CLOAK !== '1')('Cloak Session concurrenc await manager.shutdown(); } }, 180_000); + + it('falls back to a Session-owned page when window.open is blocked', async () => { + const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'webcmd-cloak-fallback-gate-')); + tempDirs.push(configDir); + const manager = new CloakSessionManager({ baseDir: configDir }); + const key = { + profileId: `gate-fallback-${Date.now()}`, + session: 'session_44444444-4444-4444-8444-444444444444', + sessionId: 'session_44444444-4444-4444-8444-444444444444', + surface: 'browser' as const, + }; + try { + const first = await manager.getPage(key); + await first.page.goto(`${baseUrl}/first`); + await first.page.evaluate(() => { + (window as unknown as { open: () => null }).open = () => null; + }); + + const fallback = await manager.newPage({ ...key, windowMode: 'background' }); + await fallback.page.goto(`${baseUrl}/fallback`); + + expect(await fallback.page.evaluate(() => window.opener === null)).toBe(true); + expect((await manager.listPages(key)).map((tab) => tab.url)).toEqual([ + `${baseUrl}/first`, + `${baseUrl}/fallback`, + ]); + } finally { + await manager.shutdown(); + } + }, 180_000); }); From 5e1cc7ad311b547ddaf8043b0c9b34de0f48d13d Mon Sep 17 00:00:00 2001 From: beubax Date: Thu, 13 Aug 2026 00:09:13 +0530 Subject: [PATCH 26/27] fix session review gaps --- docs/agents/claude-code.md | 2 +- docs/agents/codex-cli.md | 2 +- docs/agents/cursor.md | 2 +- docs/agents/hermes.md | 2 +- docs/agents/openclaw.md | 2 +- docs/agents/opencode.md | 2 +- docs/agents/pi.md | 2 +- src/browser/daemon-client.test.ts | 4 ++-- src/browser/daemon-client.ts | 6 +++--- .../runtime/local-cloak/session-manager.test.ts | 11 +++++++---- .../runtime/local-cloak/session-manager.ts | 4 +--- src/daemon/server.test.ts | 16 ++++++++++++++-- src/daemon/server.ts | 2 +- src/session-docs-sync.test.ts | 16 ++++++++++++++++ 14 files changed, 51 insertions(+), 22 deletions(-) diff --git a/docs/agents/claude-code.md b/docs/agents/claude-code.md index 83fb138c..d697d339 100644 --- a/docs/agents/claude-code.md +++ b/docs/agents/claude-code.md @@ -88,7 +88,7 @@ Denying these tools does not affect the Bash tool, which is how `webcmd` is driv | Skill text looks out of date | `webcmd update` upgrades only the CLI. Run `claude plugin update webcmd@webcmd` to refresh plugin skills. | | Claude Code still uses `WebFetch` / `WebSearch` | Confirm `permissions.deny` lists both in the active settings file, then restart `claude`. | | `claude` requires permission prompts for `webcmd` | The Bash tool still asks before non-approved commands; run `claude --dangerously-skip-permissions` or allow the shell command if you accept the risk. | -| Browser sessions stop working after idle | Ask the agent to open a fresh session or re-bind with `tabs` and `bind --page`. | +| Browser Session idles or loses its window | Keep the same Session ID; the next `webcmd --session browser ...` command reopens it. Use `webcmd session create -f json`, `webcmd session list`, and `webcmd session close ` for lifecycle. | ## See also diff --git a/docs/agents/codex-cli.md b/docs/agents/codex-cli.md index 48a49be5..07d9f2a4 100644 --- a/docs/agents/codex-cli.md +++ b/docs/agents/codex-cli.md @@ -84,7 +84,7 @@ disabled_tools = ["navigate", "screenshot"] | Search results look stale | `web_search` defaults to `"cached"`. Set `web_search = "live"` in `~/.codex/config.toml`, then restart `codex`. | | `web_search` was disabled and search stopped working | Expected. Set it back to `"live"` or `"cached"` — Webcmd does not replace search. | | `webcmd` not found in Codex shell | Confirm `webcmd` is on the PATH Codex uses; restart after installing the CLI. | -| Browser sessions stop working after idle | Ask the agent to open a fresh session or re-bind with `tabs` and `bind --page`. | +| Browser Session idles or loses its window | Keep the same Session ID; the next `webcmd --session browser ...` command reopens it. Use `webcmd session create -f json`, `webcmd session list`, and `webcmd session close ` for lifecycle. | ## See also diff --git a/docs/agents/cursor.md b/docs/agents/cursor.md index c77bb8b3..0a602807 100644 --- a/docs/agents/cursor.md +++ b/docs/agents/cursor.md @@ -77,7 +77,7 @@ Note that the rule is guidance, not a block. Cursor's Browser Automation has bee | Cursor uses its Browser tool for external sites | Confirm `.cursor/rules/webcmd-browser.mdc` has `alwaysApply: true`; for a hard block, set Browser Automation to Off. | | Browser Automation turns itself back on | Known behaviour — a prompt mentioning "browser" can re-enable it. Avoid the word, or turn it off in the agent window. | | `webcmd` not found in Cursor shell | Confirm `webcmd` is on the PATH the Cursor shell uses; restart Cursor after installing the CLI. | -| Browser sessions stop working after idle | Ask the agent to open a fresh session or re-bind with `tabs` and `bind --page`. | +| Browser Session idles or loses its window | Keep the same Session ID; the next `webcmd --session browser ...` command reopens it. Use `webcmd session create -f json`, `webcmd session list`, and `webcmd session close ` for lifecycle. | ## See also diff --git a/docs/agents/hermes.md b/docs/agents/hermes.md index cd80892d..07e3f2b7 100644 --- a/docs/agents/hermes.md +++ b/docs/agents/hermes.md @@ -85,7 +85,7 @@ Do not disable the `terminal` toolset — that is how Hermes runs `webcmd`. | Search disappeared after disabling `web` | Expected: `web_search` and `web_extract` share one toolset. Re-enable `web` and steer the agent with instructions instead. | | `x_search` appeared on its own | Expected: it auto-registers when `XAI_API_KEY` or Grok OAuth is configured. Leave it — it is search. | | `webcmd` not found in Hermes terminal | Confirm `webcmd` is on the host PATH that Hermes' `terminal` toolset uses; non-interactive shells may skip shell init files. | -| Browser sessions stop working after idle | Ask the agent to open a fresh session or re-bind with `tabs` and `bind --page`. | +| Browser Session idles or loses its window | Keep the same Session ID; the next `webcmd --session browser ...` command reopens it. Use `webcmd session create -f json`, `webcmd session list`, and `webcmd session close ` for lifecycle. | ## See also diff --git a/docs/agents/openclaw.md b/docs/agents/openclaw.md index 8f65f1af..ea4effba 100644 --- a/docs/agents/openclaw.md +++ b/docs/agents/openclaw.md @@ -82,7 +82,7 @@ Or remove it entirely — CLI, `browser.request` gateway method, and agent tool | OpenClaw uses `browser` for external sites | Remind it that Webcmd handles the open web; for a hard block, set `browser.enabled: false`. | | Search stopped working | Check whether `web_search` was denied. Webcmd does not replace search — remove it from `tools.deny`. | | `webcmd` not found in OpenClaw exec | Confirm `webcmd` is on the PATH the Gateway's `exec` tool uses; restart after installing the CLI. | -| Browser sessions stop working after idle | Ask the agent to open a fresh session or re-bind with `tabs` and `bind --page`. | +| Browser Session idles or loses its window | Keep the same Session ID; the next `webcmd --session browser ...` command reopens it. Use `webcmd session create -f json`, `webcmd session list`, and `webcmd session close ` for lifecycle. | ## See also diff --git a/docs/agents/opencode.md b/docs/agents/opencode.md index 1b24cc60..c3fab330 100644 --- a/docs/agents/opencode.md +++ b/docs/agents/opencode.md @@ -60,7 +60,7 @@ Deny `webfetch` so OpenCode cannot fall back to it while Webcmd is its browser s | OpenCode still uses `webfetch` | Confirm `permission.webfetch` is `deny` in the active config, then restart OpenCode. | | `websearch` is missing entirely | It registers only with the OpenCode provider or `OPENCODE_ENABLE_EXA=1`. Not a Webcmd problem. | | `webcmd browser` errors | Read `webcmd-usage` and `webcmd-browser` skills; create a session and pass its ID as root `--session`. | -| Browser sessions stop working after idle | Ask the agent to create a fresh session or inspect it with `webcmd session list`. | +| Browser Session idles or loses its window | Keep the same Session ID; the next `webcmd --session browser ...` command reopens it. Use `webcmd session create -f json`, `webcmd session list`, and `webcmd session close ` for lifecycle. | ## See also diff --git a/docs/agents/pi.md b/docs/agents/pi.md index 9bdffa79..d245104f 100644 --- a/docs/agents/pi.md +++ b/docs/agents/pi.md @@ -66,7 +66,7 @@ To remove one outright, delete its folder — for example `~/.pi/agent/skills/pi | Pi still uses `browser-tools` or a web-fetch extension | Remove the skill folder or prompt Pi to prefer Webcmd, then restart Pi. | | Search stopped working after removing an extension | Some extensions bundle search with extraction. Reinstall it and steer Pi with instructions instead — Webcmd does not replace search. | | `webcmd` not found in Pi's shell | Confirm `webcmd` is on the PATH Pi's `bash` tool uses; restart Pi after installing the CLI. | -| Browser sessions stop working after idle | Ask the agent to open a fresh session or re-bind with `tabs` and `bind --page`. | +| Browser Session idles or loses its window | Keep the same Session ID; the next `webcmd --session browser ...` command reopens it. Use `webcmd session create -f json`, `webcmd session list`, and `webcmd session close ` for lifecycle. | ## See also diff --git a/src/browser/daemon-client.test.ts b/src/browser/daemon-client.test.ts index f1c600b5..4e3b1560 100644 --- a/src/browser/daemon-client.test.ts +++ b/src/browser/daemon-client.test.ts @@ -321,7 +321,7 @@ describe('daemon-client', () => { expect(fetchMock).toHaveBeenCalledTimes(1); }); - it('maps local handoff pauses to a temporary BrowserCommandError', async () => { + it('maps local handoff pauses to an auth-required BrowserCommandError', async () => { const fetchMock = vi.mocked(fetch); fetchMock.mockResolvedValueOnce({ ok: false, @@ -338,7 +338,7 @@ describe('daemon-client', () => { await expect(sendCommand('exec', { code: '1' })).rejects.toMatchObject({ name: 'BrowserCommandError', code: 'SESSION_PAUSED_FOR_HUMAN_HANDOFF', - exitCode: 75, + exitCode: 77, }); expect(fetchMock).toHaveBeenCalledTimes(1); }); diff --git a/src/browser/daemon-client.ts b/src/browser/daemon-client.ts index 5ecf3bef..da4e6070 100644 --- a/src/browser/daemon-client.ts +++ b/src/browser/daemon-client.ts @@ -95,9 +95,9 @@ export class BrowserCommandError extends CliError { } function browserCommandExitCode(code?: string): ExitCode { - return code === 'SESSION_PAUSED_FOR_HUMAN_HANDOFF' || code === 'SESSION_WINDOW_CONFLICT' - ? EXIT_CODES.TEMPFAIL - : EXIT_CODES.GENERIC_ERROR; + if (code === 'SESSION_PAUSED_FOR_HUMAN_HANDOFF') return EXIT_CODES.NOPERM; + if (code === 'SESSION_WINDOW_CONFLICT') return EXIT_CODES.TEMPFAIL; + return EXIT_CODES.GENERIC_ERROR; } export { diff --git a/src/browser/runtime/local-cloak/session-manager.test.ts b/src/browser/runtime/local-cloak/session-manager.test.ts index ed5dbcce..b6eb2dbd 100644 --- a/src/browser/runtime/local-cloak/session-manager.test.ts +++ b/src/browser/runtime/local-cloak/session-manager.test.ts @@ -288,7 +288,7 @@ describe('CloakSessionManager', () => { expect(warn).toHaveBeenCalledWith(expect.stringContaining('window.open failed')); }); - it('adopts a noopener page when Chromium opens it in a new window', async () => { + it('ignores an unmarked opener-less page when waiting for a Session tab', async () => { const launched = fakeContext(); const manager = new CloakSessionManager({ baseDir: '/tmp/webcmd-test', @@ -303,12 +303,15 @@ describe('CloakSessionManager', () => { return null; }); - const second = await manager.newPage(key); + vi.useFakeTimers(); + const secondPromise = manager.newPage(key); + await vi.advanceTimersByTimeAsync(1_000); + const second = await secondPromise; - expect(second.page).toBe(opened); + expect(second.page).not.toBe(opened); expect(launched.cdp.send.mock.calls.filter(([method, params]) => ( method === 'Target.createTarget' && !(params as { hidden?: boolean })?.hidden - ))).toHaveLength(1); + ))).toHaveLength(2); expect((await manager.listPages(key)).map(tab => tab.sessionId)).toEqual(['session_a', 'session_a']); }); diff --git a/src/browser/runtime/local-cloak/session-manager.ts b/src/browser/runtime/local-cloak/session-manager.ts index 6358c4e1..98400815 100644 --- a/src/browser/runtime/local-cloak/session-manager.ts +++ b/src/browser/runtime/local-cloak/session-manager.ts @@ -984,9 +984,7 @@ export class CloakSessionManager { if (actualWindowId === undefined) return; const owner = runtime.windowOwners.get(actualWindowId); if (owner !== undefined && owner !== sessionId) return; - const isRequestedTarget = page.url() === targetUrl; - const opener = await page.opener().catch(() => null); - if (opener && !isRequestedTarget) return; + if (page.url() !== targetUrl) return; done(page); }; const onPage = (page: PlaywrightPage) => { void tryPage(page); }; diff --git a/src/daemon/server.test.ts b/src/daemon/server.test.ts index 570c9e7a..dc90ac18 100644 --- a/src/daemon/server.test.ts +++ b/src/daemon/server.test.ts @@ -319,14 +319,23 @@ describe('createDaemonServer', () => { it('force-closes a Session while work is active', async () => { let settle!: () => void; let settled = false; + let aborted = false; + const events: string[] = []; const provider = new FakeProvider(); provider.activeSessions.add('session_a'); + provider.closeSession = vi.fn(async (command) => { + events.push('close'); + const session = String(command.session); + const wasActive = provider.activeSessions.delete(session); + return { closed: wasActive, alreadyIdle: !wasActive, session }; + }); provider.dispatchImpl = (command, signal) => new Promise((resolve) => { settle = () => { settled = true; + events.push('settle'); resolve({ id: command.id, ok: true, data: 'done' }); }; - signal?.addEventListener('abort', settle, { once: true }); + signal?.addEventListener('abort', () => { aborted = true; }, { once: true }); }); const { baseUrl } = await start(provider); @@ -347,7 +356,9 @@ describe('createDaemonServer', () => { session: 'session_a', force: true, }); - await vi.waitFor(() => expect(provider.activeSessions).not.toContain('session_a')); + await vi.waitFor(() => expect(aborted).toBe(true)); + expect(provider.closeSession).not.toHaveBeenCalled(); + settle(); const close = await closeRequest; expect(settled).toBe(true); expect(close.status).toBe(200); @@ -361,6 +372,7 @@ describe('createDaemonServer', () => { clearedHandoff: false, }, }); + expect(events).toEqual(['settle', 'close']); } finally { settle(); await active.catch(() => undefined); diff --git a/src/daemon/server.ts b/src/daemon/server.ts index 45e5a59a..07086bcd 100644 --- a/src/daemon/server.ts +++ b/src/daemon/server.ts @@ -374,9 +374,9 @@ export function createDaemonServer(provider: BrowserRuntimeProvider, opts: Daemo return entry.promise; }); forcedRunIds.forEach((runId) => forceClosingRuns.add(runId)); + await Promise.allSettled(forcedCommands); const lifecycleResult = await handleSessionLifecycle(provider, resolvedBody); if (lifecycleResult) { - await Promise.allSettled(forcedCommands); for (const runId of forcedRunIds) { leases.releaseByRunId(runId); forceClosingRuns.delete(runId); diff --git a/src/session-docs-sync.test.ts b/src/session-docs-sync.test.ts index fae1d1d3..a0f61c2a 100644 --- a/src/session-docs-sync.test.ts +++ b/src/session-docs-sync.test.ts @@ -52,6 +52,22 @@ describe('Session documentation sync', () => { } expect(troubleshooting).toContain('webcmd session close --force'); }); + + it('teaches Session lifecycle commands in every agent guide', () => { + const agentDir = path.join(ROOT, 'docs', 'agents'); + const guides = fs.readdirSync(agentDir) + .filter((file) => file.endsWith('.md')) + .filter((file) => file !== 'custom-sdk.md') + .map((file) => path.join(agentDir, file)); + + for (const guide of guides) { + const text = fs.readFileSync(guide, 'utf8'); + expect(text, path.relative(ROOT, guide)).toContain('webcmd session create'); + expect(text, path.relative(ROOT, guide)).toContain('webcmd --session '); + expect(text, path.relative(ROOT, guide)).toContain('webcmd session list'); + expect(text, path.relative(ROOT, guide)).toContain('webcmd session close '); + } + }); }); function findRemovedSessionSyntaxes(text: string): string[] { From 2e28f1c43b2f32263d22cd6adceff45d486456f4 Mon Sep 17 00:00:00 2001 From: beubax Date: Thu, 13 Aug 2026 00:56:04 +0530 Subject: [PATCH 27/27] fix bounded session cancellation --- src/daemon/server.test.ts | 88 ++++++++++++++++++++- src/daemon/server.ts | 60 ++++++++++---- tests/e2e/cloak-session-concurrency.test.ts | 25 ++++++ 3 files changed, 157 insertions(+), 16 deletions(-) diff --git a/src/daemon/server.test.ts b/src/daemon/server.test.ts index dc90ac18..93cc63d7 100644 --- a/src/daemon/server.test.ts +++ b/src/daemon/server.test.ts @@ -145,6 +145,13 @@ describe('createDaemonServer', () => { }); } + async function promiseState(promise: Promise, waitMs = 0): Promise<'pending' | 'settled'> { + return Promise.race([ + promise.then(() => 'settled' as const, () => 'settled' as const), + new Promise<'pending'>((resolve) => setTimeout(() => resolve('pending'), waitMs)), + ]); + } + function persistentWrite( id: string, runId: string, @@ -368,7 +375,7 @@ describe('createDaemonServer', () => { closed: true, alreadyIdle: false, session: 'session_a', - displaced: 1, + displaced: { command: 'browser/run' }, clearedHandoff: false, }, }); @@ -385,6 +392,51 @@ describe('createDaemonServer', () => { expect(admitted.status).toBe(200); }); + it('returns SESSION_BUSY when force-close cancellation does not settle promptly', async () => { + const provider = new FakeProvider(); + provider.activeSessions.add('session_a'); + let aborted = false; + let settle!: () => void; + provider.dispatchImpl = (command, signal) => new Promise((resolve) => { + settle = () => resolve({ id: command.id, ok: true, data: 'done' }); + signal?.addEventListener('abort', () => { aborted = true; }, { once: true }); + }); + const { baseUrl } = await start(provider); + + const active = postCommand(baseUrl, { + id: 'active-force-timeout', + action: 'exec', + surface: 'browser', + session: 'session_a', + runId: 'run_100_1_2', + command: 'browser/run', + }); + try { + await vi.waitFor(() => expect(provider.commands).toHaveLength(1)); + const closeRequest = postCommand(baseUrl, { + id: 'force-close-timeout', + action: 'session-close', + contextId: 'default', + session: 'session_a', + force: true, + }); + await vi.waitFor(() => expect(aborted).toBe(true)); + + expect(await promiseState(closeRequest, 2_500)).toBe('settled'); + const close = await closeRequest; + expect(close.status).toBe(409); + await expect(close.json()).resolves.toMatchObject({ + ok: false, + code: 'session_busy', + holder: { command: 'browser/run', sessionId: 'session_a' }, + }); + expect(provider.activeSessions).toContain('session_a'); + } finally { + settle(); + await active.catch(() => undefined); + } + }); + it('force-closes every site-partitioned lease in an adapter-default Session', async () => { const provider = new FakeProvider(); provider.activeSessions.add('session_default'); @@ -857,6 +909,40 @@ describe('createDaemonServer', () => { expect(provider.commands.map((command) => command.id)).toEqual(['owner', 'next-owner']); }); + it('returns SESSION_BUSY when run-cancel does not settle promptly', async () => { + const provider = new FakeProvider(); + let aborted = false; + let settle!: () => void; + provider.dispatchImpl = (command, signal) => new Promise((resolve) => { + settle = () => resolve({ id: command.id, ok: true, data: 'done' }); + signal?.addEventListener('abort', () => { aborted = true; }, { once: true }); + }); + const { baseUrl } = await start(provider); + const active = postCommand(baseUrl, persistentWrite('owner', 'run_100_1_1')); + try { + await vi.waitFor(() => expect(provider.commands).toHaveLength(1)); + const canceled = postCommand(baseUrl, { + id: 'cancel', + action: 'run-cancel' as BrowserRuntimeCommand['action'], + runId: 'run_100_1_1', + }); + await vi.waitFor(() => expect(aborted).toBe(true)); + + expect(await promiseState(canceled, 2_500)).toBe('settled'); + const response = await canceled; + expect(response.status).toBe(409); + await expect(response.json()).resolves.toMatchObject({ + id: 'cancel', + ok: false, + code: 'session_busy', + holder: { command: 'example write' }, + }); + } finally { + settle(); + await active.catch(() => undefined); + } + }); + it('aborts pending work when the command request disconnects', async () => { const provider = new FakeProvider(); let aborted = false; diff --git a/src/daemon/server.ts b/src/daemon/server.ts index 07086bcd..9844d18f 100644 --- a/src/daemon/server.ts +++ b/src/daemon/server.ts @@ -3,11 +3,12 @@ import { DAEMON_HEADER_NAME, DEFAULT_DAEMON_PORT } from '../constants.js'; import type { BrowserRuntimeCommand, BrowserRuntimeResult } from '../browser/protocol.js'; import type { BrowserRuntimeProvider } from '../browser/runtime/provider.js'; import { buildCommandTimeoutFailure, getResponseCorsHeaders } from '../daemon-utils.js'; -import { getSessionLeaseKey, isSessionLeaseCommand, SessionLeaseRegistry } from '../session-lease.js'; +import { getSessionLeaseKey, isSessionLeaseCommand, type SessionLease, SessionLeaseRegistry } from '../session-lease.js'; import type { BrowserSessionRecord } from '../browser/sessions.js'; const MAX_BODY = 1024 * 1024; const LOG_BUFFER_SIZE = 200; +const CANCEL_SETTLE_TIMEOUT_MS = 2_000; export interface DaemonServerOptions { port?: number; @@ -28,6 +29,33 @@ interface PendingCommand { abortController: AbortController; } +async function cancelAndSettle(entries: PendingCommand[]): Promise { + entries.forEach((entry) => entry.abortController.abort()); + if (entries.length === 0) return true; + let timeout: ReturnType | undefined; + const settled = await Promise.race([ + Promise.allSettled(entries.map((entry) => entry.promise)).then(() => true), + new Promise((resolve) => { + timeout = setTimeout(() => resolve(false), CANCEL_SETTLE_TIMEOUT_MS); + timeout.unref?.(); + }), + ]); + if (timeout) clearTimeout(timeout); + return settled; +} + +function publicSessionHolder(holder: SessionLease | undefined) { + if (!holder) return null; + const { key: _key, runId: _runId, ...publicHolder } = holder; + const [, sessionId, admissionSite] = holder.key.split('␟'); + return { ...publicHolder, ...(sessionId ? { sessionId } : {}), ...(admissionSite ? { admissionSite } : {}) }; +} + +function displacedSessionHolder(holder: SessionLease | undefined): { command: string; pid?: number } | null { + if (!holder) return null; + return { command: holder.command, ...(holder.pid === undefined ? {} : { pid: holder.pid }) }; +} + function commandTimeoutMs(command: BrowserRuntimeCommand): number { return typeof command.deadlineAt === 'number' && command.deadlineAt > 0 ? Math.max(1000, command.deadlineAt - Date.now()) @@ -322,8 +350,14 @@ export function createDaemonServer(provider: BrowserRuntimeProvider, opts: Daemo const matching = body.action === 'run-cancel' && typeof body.runId === 'string' ? [...pending.values()].filter((entry) => entry.runId === body.runId) : []; - matching.forEach((entry) => entry.abortController.abort()); - await Promise.allSettled(matching.map((entry) => entry.promise)); + const canceled = await cancelAndSettle(matching); + if (!canceled) { + const holder = typeof body.runId === 'string' + ? leases.list(hasPendingWork).find((lease) => lease.runId === body.runId) + : undefined; + jsonResponse(res, 409, { id: body.id, ok: false, code: 'session_busy', holder: publicSessionHolder(holder) }); + return; + } const released = typeof body.runId === 'string' ? leases.releaseByRunId(body.runId) : 0; jsonResponse(res, 200, { id: body.id, ok: true, data: { released } }); return; @@ -342,12 +376,6 @@ export function createDaemonServer(provider: BrowserRuntimeProvider, opts: Daemo || lease.key.startsWith(`${sessionKey}␟`) || sessionKey.startsWith(`${lease.key}␟`) )); - const publicSessionHolder = (holder: ReturnType[number] | undefined) => { - if (!holder) return null; - const { key: _key, runId: _runId, ...publicHolder } = holder; - const [, sessionId, admissionSite] = holder.key.split('␟'); - return { ...publicHolder, ...(sessionId ? { sessionId } : {}), ...(admissionSite ? { admissionSite } : {}) }; - }; if (resolved.session && !(resolvedBody.action === 'session-close' && resolvedBody.force === true)) { const paused = handoffPauseResult(resolvedBody, resolved.session); if (paused) { @@ -369,12 +397,14 @@ export function createDaemonServer(provider: BrowserRuntimeProvider, opts: Daemo : new Set(); const forcedCommands = [...pending.values()] .filter((entry) => entry.runId && forcedRunIds.has(entry.runId)) - .map((entry) => { - entry.abortController.abort(); - return entry.promise; - }); + .map((entry) => entry); forcedRunIds.forEach((runId) => forceClosingRuns.add(runId)); - await Promise.allSettled(forcedCommands); + const canceled = await cancelAndSettle(forcedCommands); + if (!canceled) { + forcedRunIds.forEach((runId) => forceClosingRuns.delete(runId)); + jsonResponse(res, 409, { ok: false, code: 'session_busy', holder: publicSessionHolder(holder) }); + return; + } const lifecycleResult = await handleSessionLifecycle(provider, resolvedBody); if (lifecycleResult) { for (const runId of forcedRunIds) { @@ -385,7 +415,7 @@ export function createDaemonServer(provider: BrowserRuntimeProvider, opts: Daemo ...lifecycleResult, data: { ...(lifecycleResult.data as Record), - displaced: forcedRunIds.size, + displaced: displacedSessionHolder(holder), clearedHandoff: Boolean(resolved.session?.handoff), }, } : lifecycleResult); diff --git a/tests/e2e/cloak-session-concurrency.test.ts b/tests/e2e/cloak-session-concurrency.test.ts index faaed816..809f37f2 100644 --- a/tests/e2e/cloak-session-concurrency.test.ts +++ b/tests/e2e/cloak-session-concurrency.test.ts @@ -5,6 +5,8 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { CloakSessionManager } from '../../src/browser/runtime/local-cloak/session-manager.js'; +import { findExactCloakProfileProcesses } from '../../src/browser/runtime/local-cloak/process-matcher.js'; +import { resolveCloakProfileDir } from '../../src/browser/runtime/local-cloak/profiles.js'; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); let server: http.Server; @@ -132,4 +134,27 @@ describe.skipIf(process.env.WEBCMD_LIVE_CLOAK !== '1')('Cloak Session concurrenc await manager.shutdown(); } }, 180_000); + + it('distinguishes work and work-2 Cloak processes from real ps output', async () => { + const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'webcmd-cloak-process-gate-')); + tempDirs.push(configDir); + const manager = new CloakSessionManager({ baseDir: configDir }); + const work = { profileId: 'work', session: 'session_55555555-5555-4555-8555-555555555555', sessionId: 'session_55555555-5555-4555-8555-555555555555', surface: 'browser' as const }; + const work2 = { profileId: 'work-2', session: 'session_66666666-6666-4666-8666-666666666666', sessionId: 'session_66666666-6666-4666-8666-666666666666', surface: 'browser' as const }; + try { + const [workPage, work2Page] = await Promise.all([manager.getPage(work), manager.getPage(work2)]); + await Promise.all([ + workPage.page.goto(`${baseUrl}/work`), + work2Page.page.goto(`${baseUrl}/work-2`), + ]); + + const workProcesses = await findExactCloakProfileProcesses(resolveCloakProfileDir('work', { baseDir: configDir })); + const work2Processes = await findExactCloakProfileProcesses(resolveCloakProfileDir('work-2', { baseDir: configDir })); + expect(workProcesses.length).toBeGreaterThan(0); + expect(work2Processes.length).toBeGreaterThan(0); + expect(workProcesses.every(pid => !work2Processes.includes(pid))).toBe(true); + } finally { + await manager.shutdown(); + } + }, 180_000); });