@agentrhq/webcmd 0.3.1 → 0.3.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -6
- package/cli-manifest.json +11 -11
- package/clis/producthunt/browse.js +9 -2
- package/clis/producthunt/browser-commands.test.js +66 -0
- package/clis/producthunt/hot.js +3 -3
- package/clis/producthunt/utils.js +27 -0
- package/clis/producthunt/utils.test.js +8 -0
- package/clis/spotify/spotify.js +11 -11
- package/dist/src/browser/daemon-client.d.ts +1 -0
- package/dist/src/browser/daemon-client.js +25 -1
- package/dist/src/browser/daemon-client.test.js +86 -1
- package/dist/src/browser/daemon-transport.d.ts +2 -0
- package/dist/src/browser/protocol.d.ts +12 -1
- package/dist/src/browser/runtime/local-cloak/actions.d.ts +1 -0
- package/dist/src/browser/runtime/local-cloak/actions.js +9 -9
- package/dist/src/browser/runtime/local-cloak/provider.d.ts +1 -0
- package/dist/src/browser/runtime/local-cloak/provider.js +6 -3
- package/dist/src/browser/runtime/local-cloak/provider.test.js +1 -0
- package/dist/src/browser/runtime/local-cloak/session-manager.d.ts +5 -0
- package/dist/src/browser/runtime/local-cloak/session-manager.js +95 -19
- package/dist/src/browser/runtime/local-cloak/session-manager.test.js +235 -0
- package/dist/src/browser/runtime/provider.d.ts +1 -0
- package/dist/src/cli.js +36 -12
- package/dist/src/cli.test.js +5 -0
- package/dist/src/daemon/server.js +65 -24
- package/dist/src/daemon/server.test.js +211 -2
- package/dist/src/docs-sync-review-cli.test.js +1 -1
- package/dist/src/errors.d.ts +5 -0
- package/dist/src/errors.js +23 -0
- package/dist/src/errors.test.js +58 -1
- package/dist/src/execution.js +57 -6
- package/dist/src/execution.test.js +426 -2
- package/dist/src/hosted/availability.test.js +12 -1
- package/dist/src/hosted/client.js +3 -1
- package/dist/src/hosted/client.test.js +62 -0
- package/dist/src/hosted/main-lifecycle.test.js +66 -5
- package/dist/src/hosted/types.d.ts +1 -0
- package/dist/src/main.js +4 -0
- package/dist/src/package-bin-process.d.ts +13 -0
- package/dist/src/package-bin-process.js +24 -0
- package/dist/src/package-bin-process.test.d.ts +1 -0
- package/dist/src/package-bin-process.test.js +22 -0
- package/dist/src/session-lease.d.ts +82 -0
- package/dist/src/session-lease.js +163 -0
- package/dist/src/session-lease.test.d.ts +1 -0
- package/dist/src/session-lease.test.js +217 -0
- package/dist/src/skills.d.ts +8 -4
- package/dist/src/skills.js +30 -1
- package/dist/src/skills.test.js +40 -12
- package/hosted-contract.json +45 -34
- package/package.json +3 -2
- package/scripts/check-package-bin.mjs +7 -6
- package/scripts/collect-ci-diagnostics.ps1 +274 -0
- package/scripts/docs-sync-review.ts +1 -1
- package/skills/webcmd-adapter-author/SKILL.md +1 -1
- package/skills/webcmd-adapter-author/references/adapter-template.md +2 -6
package/README.md
CHANGED
|
@@ -8,6 +8,9 @@
|
|
|
8
8
|
<a href="https://www.npmjs.com/package/@agentrhq/webcmd">
|
|
9
9
|
<img alt="NPM downloads" src="https://img.shields.io/npm/dt/@agentrhq/webcmd.svg?style=for-the-badge&color=1E88E5&labelColor=000000">
|
|
10
10
|
</a>
|
|
11
|
+
<a href="https://docs.webcmd.dev">
|
|
12
|
+
<img alt="Documentation" src="https://img.shields.io/badge/docs-webcmd.dev-1E88E5.svg?style=for-the-badge&labelColor=000000">
|
|
13
|
+
</a>
|
|
11
14
|
<a href="https://github.com/agentrhq/webcmd/blob/main/LICENSE">
|
|
12
15
|
<img alt="License" src="https://img.shields.io/badge/license-Apache%202.0-1E88E5.svg?style=for-the-badge&labelColor=000000">
|
|
13
16
|
</a>
|
|
@@ -25,6 +28,8 @@
|
|
|
25
28
|
|
|
26
29
|
WebCMD learns the navigational context of websites as agents use them, then compiles that knowledge into deterministic commands for faster, cheaper, more reliable browser automation. The goal is simple: stop making agents rediscover the same sites on every run and cut browser-agent token spend by up to 90%.
|
|
27
30
|
|
|
31
|
+
📖 **Full documentation: [docs.webcmd.dev](https://docs.webcmd.dev)**
|
|
32
|
+
|
|
28
33
|
On top of live browser control, WebCMD adds 3 layers of learnings. Each layer collapses cost and variance for the layer above it.
|
|
29
34
|
|
|
30
35
|
| Layer | Scenario | What Webcmd Helps With |
|
|
@@ -123,15 +128,17 @@ Agents usually want `-f json`; humans usually want table (default) or yaml.
|
|
|
123
128
|
|
|
124
129
|
Webcmd is designed to be driven by coding agents such as Codex, Claude Code, Cursor, and similar tools.
|
|
125
130
|
|
|
126
|
-
##
|
|
131
|
+
## Add skills (also refreshes existing links)
|
|
127
132
|
|
|
128
|
-
|
|
133
|
+
Add Webcmd skills to your agent environment:
|
|
129
134
|
|
|
130
135
|
```bash
|
|
131
|
-
webcmd skills
|
|
136
|
+
webcmd skills add
|
|
132
137
|
```
|
|
133
138
|
|
|
134
|
-
The
|
|
139
|
+
The command asks whether to add globally or locally, then asks for the coding agent (`agents`, `codex`, `claude`) or a custom skills path. For scripts, pass flags such as `--scope project --provider codex` or `--path ./my-skills`.
|
|
140
|
+
|
|
141
|
+
Remove Webcmd skill symlinks from all supported global and current-project locations with `webcmd skills remove`. Pass `--path ./my-skills` to include a custom directory.
|
|
135
142
|
|
|
136
143
|
### Which skill to use
|
|
137
144
|
|
|
@@ -312,8 +319,8 @@ Common paths:
|
|
|
312
319
|
| `~/.webcmd/clis/` | Private adapters. |
|
|
313
320
|
| `~/.webcmd/cache/browser-network/` | Cached browser network captures. |
|
|
314
321
|
| `~/.webcmd/external-clis.yaml` | User external CLI registry entries. |
|
|
315
|
-
| `~/.agents/skills/` | Common global skills
|
|
316
|
-
| `.agents/skills/` | Common workspace-local skills
|
|
322
|
+
| `~/.agents/skills/` | Common global skills directory for agent skill managers. |
|
|
323
|
+
| `.agents/skills/` | Common workspace-local skills directory. |
|
|
317
324
|
|
|
318
325
|
## Troubleshooting
|
|
319
326
|
|
package/cli-manifest.json
CHANGED
|
@@ -21317,7 +21317,7 @@
|
|
|
21317
21317
|
"name": "auth",
|
|
21318
21318
|
"description": "Authenticate with Spotify (OAuth — run once)",
|
|
21319
21319
|
"access": "write",
|
|
21320
|
-
"strategy": "
|
|
21320
|
+
"strategy": "local",
|
|
21321
21321
|
"browser": false,
|
|
21322
21322
|
"args": [],
|
|
21323
21323
|
"columns": [
|
|
@@ -21332,7 +21332,7 @@
|
|
|
21332
21332
|
"name": "next",
|
|
21333
21333
|
"description": "Skip to next track",
|
|
21334
21334
|
"access": "write",
|
|
21335
|
-
"strategy": "
|
|
21335
|
+
"strategy": "local",
|
|
21336
21336
|
"browser": false,
|
|
21337
21337
|
"args": [],
|
|
21338
21338
|
"columns": [
|
|
@@ -21347,7 +21347,7 @@
|
|
|
21347
21347
|
"name": "pause",
|
|
21348
21348
|
"description": "Pause playback",
|
|
21349
21349
|
"access": "write",
|
|
21350
|
-
"strategy": "
|
|
21350
|
+
"strategy": "local",
|
|
21351
21351
|
"browser": false,
|
|
21352
21352
|
"args": [],
|
|
21353
21353
|
"columns": [
|
|
@@ -21362,7 +21362,7 @@
|
|
|
21362
21362
|
"name": "play",
|
|
21363
21363
|
"description": "Resume playback or search and play a track/artist",
|
|
21364
21364
|
"access": "write",
|
|
21365
|
-
"strategy": "
|
|
21365
|
+
"strategy": "local",
|
|
21366
21366
|
"browser": false,
|
|
21367
21367
|
"args": [
|
|
21368
21368
|
{
|
|
@@ -21388,7 +21388,7 @@
|
|
|
21388
21388
|
"name": "prev",
|
|
21389
21389
|
"description": "Skip to previous track",
|
|
21390
21390
|
"access": "write",
|
|
21391
|
-
"strategy": "
|
|
21391
|
+
"strategy": "local",
|
|
21392
21392
|
"browser": false,
|
|
21393
21393
|
"args": [],
|
|
21394
21394
|
"columns": [
|
|
@@ -21403,7 +21403,7 @@
|
|
|
21403
21403
|
"name": "queue",
|
|
21404
21404
|
"description": "Add a track to the playback queue",
|
|
21405
21405
|
"access": "write",
|
|
21406
|
-
"strategy": "
|
|
21406
|
+
"strategy": "local",
|
|
21407
21407
|
"browser": false,
|
|
21408
21408
|
"args": [
|
|
21409
21409
|
{
|
|
@@ -21428,7 +21428,7 @@
|
|
|
21428
21428
|
"name": "repeat",
|
|
21429
21429
|
"description": "Set repeat mode (off / track / context)",
|
|
21430
21430
|
"access": "write",
|
|
21431
|
-
"strategy": "
|
|
21431
|
+
"strategy": "local",
|
|
21432
21432
|
"browser": false,
|
|
21433
21433
|
"args": [
|
|
21434
21434
|
{
|
|
@@ -21457,7 +21457,7 @@
|
|
|
21457
21457
|
"name": "search",
|
|
21458
21458
|
"description": "Search for tracks",
|
|
21459
21459
|
"access": "read",
|
|
21460
|
-
"strategy": "
|
|
21460
|
+
"strategy": "local",
|
|
21461
21461
|
"browser": false,
|
|
21462
21462
|
"args": [
|
|
21463
21463
|
{
|
|
@@ -21490,7 +21490,7 @@
|
|
|
21490
21490
|
"name": "shuffle",
|
|
21491
21491
|
"description": "Toggle shuffle on/off",
|
|
21492
21492
|
"access": "write",
|
|
21493
|
-
"strategy": "
|
|
21493
|
+
"strategy": "local",
|
|
21494
21494
|
"browser": false,
|
|
21495
21495
|
"args": [
|
|
21496
21496
|
{
|
|
@@ -21518,7 +21518,7 @@
|
|
|
21518
21518
|
"name": "status",
|
|
21519
21519
|
"description": "Show current playback status",
|
|
21520
21520
|
"access": "read",
|
|
21521
|
-
"strategy": "
|
|
21521
|
+
"strategy": "local",
|
|
21522
21522
|
"browser": false,
|
|
21523
21523
|
"args": [],
|
|
21524
21524
|
"columns": [
|
|
@@ -21537,7 +21537,7 @@
|
|
|
21537
21537
|
"name": "volume",
|
|
21538
21538
|
"description": "Set playback volume (0-100)",
|
|
21539
21539
|
"access": "write",
|
|
21540
|
-
"strategy": "
|
|
21540
|
+
"strategy": "local",
|
|
21541
21541
|
"browser": false,
|
|
21542
21542
|
"args": [
|
|
21543
21543
|
{
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { cli, Strategy } from '@agentrhq/webcmd/registry';
|
|
8
8
|
import { CliError } from '@agentrhq/webcmd/errors';
|
|
9
|
-
import { PRODUCTHUNT_CATEGORY_SLUGS } from './utils.js';
|
|
9
|
+
import { assertProductHuntAccessible, PRODUCTHUNT_CATEGORY_SLUGS } from './utils.js';
|
|
10
10
|
cli({
|
|
11
11
|
site: 'producthunt',
|
|
12
12
|
name: 'browse',
|
|
@@ -30,7 +30,14 @@ cli({
|
|
|
30
30
|
const slug = String(args.category || '').trim().toLowerCase();
|
|
31
31
|
await page.installInterceptor('producthunt.com');
|
|
32
32
|
await page.goto(`https://www.producthunt.com/categories/${slug}`);
|
|
33
|
-
|
|
33
|
+
try {
|
|
34
|
+
await page.waitForCapture(5);
|
|
35
|
+
}
|
|
36
|
+
catch (error) {
|
|
37
|
+
await assertProductHuntAccessible(page);
|
|
38
|
+
throw error;
|
|
39
|
+
}
|
|
40
|
+
await assertProductHuntAccessible(page);
|
|
34
41
|
const domItems = await page.evaluate(`
|
|
35
42
|
(() => {
|
|
36
43
|
const seen = new Set();
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { getRegistry } from '@agentrhq/webcmd/registry';
|
|
3
|
+
import './hot.js';
|
|
4
|
+
import './browse.js';
|
|
5
|
+
|
|
6
|
+
function pageMock({ evaluations, captureError } = {}) {
|
|
7
|
+
const queue = [...(evaluations ?? [])];
|
|
8
|
+
return {
|
|
9
|
+
goto: async () => undefined,
|
|
10
|
+
wait: async () => undefined,
|
|
11
|
+
installInterceptor: async () => undefined,
|
|
12
|
+
waitForCapture: async () => {
|
|
13
|
+
if (captureError) throw captureError;
|
|
14
|
+
},
|
|
15
|
+
evaluate: async () => queue.shift(),
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const challenge = {
|
|
20
|
+
title: 'Just a moment...',
|
|
21
|
+
body: 'Performing security verification before proceeding',
|
|
22
|
+
cloudflare: true,
|
|
23
|
+
};
|
|
24
|
+
const accessible = {
|
|
25
|
+
title: 'Product Hunt',
|
|
26
|
+
body: 'Discover your next favorite thing',
|
|
27
|
+
cloudflare: false,
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
describe('Product Hunt browser commands', () => {
|
|
31
|
+
it.each([
|
|
32
|
+
['producthunt/hot', {}],
|
|
33
|
+
['producthunt/browse', { category: 'developer-tools' }],
|
|
34
|
+
])('%s reports verification pages as SITE_BLOCKED', async (name, args) => {
|
|
35
|
+
const command = getRegistry().get(name);
|
|
36
|
+
|
|
37
|
+
await expect(command.func(pageMock({ evaluations: [challenge] }), args)).rejects.toMatchObject({
|
|
38
|
+
code: 'SITE_BLOCKED',
|
|
39
|
+
exitCode: 69,
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('reports a challenge when category capture fails on the verification page', async () => {
|
|
44
|
+
const command = getRegistry().get('producthunt/browse');
|
|
45
|
+
const captureError = new Error('No network capture within 5s');
|
|
46
|
+
|
|
47
|
+
await expect(command.func(pageMock({ evaluations: [challenge], captureError }), {
|
|
48
|
+
category: 'developer-tools',
|
|
49
|
+
})).rejects.toMatchObject({
|
|
50
|
+
code: 'SITE_BLOCKED',
|
|
51
|
+
exitCode: 69,
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it.each([
|
|
56
|
+
['producthunt/hot', {}, 'Could not retrieve Product Hunt top posts'],
|
|
57
|
+
['producthunt/browse', { category: 'developer-tools' }, 'No products found for category'],
|
|
58
|
+
])('%s preserves NO_DATA for an accessible empty page', async (name, args, message) => {
|
|
59
|
+
const command = getRegistry().get(name);
|
|
60
|
+
|
|
61
|
+
await expect(command.func(pageMock({ evaluations: [accessible, []] }), args)).rejects.toMatchObject({
|
|
62
|
+
code: 'NO_DATA',
|
|
63
|
+
message: expect.stringContaining(message),
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
});
|
package/clis/producthunt/hot.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { cli, Strategy } from '@agentrhq/webcmd/registry';
|
|
7
7
|
import { CliError } from '@agentrhq/webcmd/errors';
|
|
8
|
-
import { pickVoteCount } from './utils.js';
|
|
8
|
+
import { assertProductHuntAccessible, pickVoteCount } from './utils.js';
|
|
9
9
|
cli({
|
|
10
10
|
site: 'producthunt',
|
|
11
11
|
name: 'hot',
|
|
@@ -19,9 +19,9 @@ cli({
|
|
|
19
19
|
columns: ['rank', 'name', 'votes', 'url'],
|
|
20
20
|
func: async (page, args) => {
|
|
21
21
|
const count = Math.min(Number(args.limit) || 20, 50);
|
|
22
|
-
await page.installInterceptor('producthunt.com');
|
|
23
22
|
await page.goto('https://www.producthunt.com');
|
|
24
|
-
await page.
|
|
23
|
+
await page.wait(2);
|
|
24
|
+
await assertProductHuntAccessible(page);
|
|
25
25
|
const domItems = await page.evaluate(`
|
|
26
26
|
(() => {
|
|
27
27
|
const seen = new Set();
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Product Hunt shared helpers.
|
|
3
3
|
*/
|
|
4
|
+
import { CliError, EXIT_CODES } from '@agentrhq/webcmd/errors';
|
|
5
|
+
|
|
4
6
|
export const PRODUCTHUNT_CATEGORY_SLUGS = [
|
|
5
7
|
'ai-agents',
|
|
6
8
|
'ai-coding-agents',
|
|
@@ -19,6 +21,31 @@ export const PRODUCTHUNT_CATEGORY_SLUGS = [
|
|
|
19
21
|
'engineering-development',
|
|
20
22
|
];
|
|
21
23
|
const UA = 'Mozilla/5.0 (compatible; webcmd/1.0)';
|
|
24
|
+
|
|
25
|
+
export function isProductHuntVerificationPage(value) {
|
|
26
|
+
const title = String(value?.title ?? '');
|
|
27
|
+
const body = String(value?.body ?? '');
|
|
28
|
+
return value?.cloudflare === true
|
|
29
|
+
|| /just a moment|security verification/i.test(title)
|
|
30
|
+
|| /performing security verification|verify you are human|enable javascript and cookies/i.test(body);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function assertProductHuntAccessible(page) {
|
|
34
|
+
const state = await page.evaluate(`(() => ({
|
|
35
|
+
title: document.title || '',
|
|
36
|
+
body: (document.body?.innerText || '').slice(0, 2000),
|
|
37
|
+
cloudflare: !!document.querySelector('[id^="cf-"], [class*="cf-challenge"]')
|
|
38
|
+
}))()`);
|
|
39
|
+
if (!isProductHuntVerificationPage(state))
|
|
40
|
+
return;
|
|
41
|
+
throw new CliError(
|
|
42
|
+
'SITE_BLOCKED',
|
|
43
|
+
'Product Hunt served a security verification page',
|
|
44
|
+
'Complete permitted verification in the active browser when available, then retry. Webcmd will not bypass site verification.',
|
|
45
|
+
EXIT_CODES.SERVICE_UNAVAIL,
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
22
49
|
/**
|
|
23
50
|
* Fetch Product Hunt Atom RSS feed.
|
|
24
51
|
* @param category Optional category slug (e.g. "ai", "developer-tools")
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest';
|
|
2
2
|
import { parseFeed, pickVoteCount, PRODUCTHUNT_CATEGORY_SLUGS } from './utils.js';
|
|
3
|
+
import * as productHuntUtils from './utils.js';
|
|
3
4
|
const SAMPLE_ATOM = `<?xml version="1.0" encoding="UTF-8"?>
|
|
4
5
|
<feed xmlns="http://www.w3.org/2005/Atom">
|
|
5
6
|
<title>Product Hunt</title>
|
|
@@ -61,4 +62,11 @@ describe('parseFeed', () => {
|
|
|
61
62
|
expect(PRODUCTHUNT_CATEGORY_SLUGS).toContain('developer-tools');
|
|
62
63
|
expect(PRODUCTHUNT_CATEGORY_SLUGS).toContain('ai-agents');
|
|
63
64
|
});
|
|
65
|
+
it('recognizes explicit Product Hunt verification signals', () => {
|
|
66
|
+
expect(productHuntUtils.isProductHuntVerificationPage).toBeTypeOf('function');
|
|
67
|
+
expect(productHuntUtils.isProductHuntVerificationPage({ title: 'Just a moment...', body: '', cloudflare: false })).toBe(true);
|
|
68
|
+
expect(productHuntUtils.isProductHuntVerificationPage({ title: 'Product Hunt', body: 'Performing security verification', cloudflare: false })).toBe(true);
|
|
69
|
+
expect(productHuntUtils.isProductHuntVerificationPage({ title: 'Product Hunt', body: '', cloudflare: true })).toBe(true);
|
|
70
|
+
expect(productHuntUtils.isProductHuntVerificationPage({ title: 'Product Hunt', body: 'Discover your next favorite thing', cloudflare: false })).toBe(false);
|
|
71
|
+
});
|
|
64
72
|
});
|
package/clis/spotify/spotify.js
CHANGED
|
@@ -108,7 +108,7 @@ cli({
|
|
|
108
108
|
name: 'auth',
|
|
109
109
|
access: 'write',
|
|
110
110
|
description: 'Authenticate with Spotify (OAuth — run once)',
|
|
111
|
-
strategy: Strategy.
|
|
111
|
+
strategy: Strategy.LOCAL,
|
|
112
112
|
browser: false,
|
|
113
113
|
args: [],
|
|
114
114
|
columns: ['status'],
|
|
@@ -175,7 +175,7 @@ cli({
|
|
|
175
175
|
name: 'status',
|
|
176
176
|
access: 'read',
|
|
177
177
|
description: 'Show current playback status',
|
|
178
|
-
strategy: Strategy.
|
|
178
|
+
strategy: Strategy.LOCAL,
|
|
179
179
|
browser: false,
|
|
180
180
|
args: [],
|
|
181
181
|
columns: ['track', 'artist', 'album', 'status', 'progress'],
|
|
@@ -197,7 +197,7 @@ cli({
|
|
|
197
197
|
name: 'play',
|
|
198
198
|
access: 'write',
|
|
199
199
|
description: 'Resume playback or search and play a track/artist',
|
|
200
|
-
strategy: Strategy.
|
|
200
|
+
strategy: Strategy.LOCAL,
|
|
201
201
|
browser: false,
|
|
202
202
|
args: [{ name: 'query', type: 'str', default: '', positional: true, help: 'Track or artist to play (optional)' }],
|
|
203
203
|
columns: ['track', 'artist', 'status'],
|
|
@@ -216,7 +216,7 @@ cli({
|
|
|
216
216
|
name: 'pause',
|
|
217
217
|
access: 'write',
|
|
218
218
|
description: 'Pause playback',
|
|
219
|
-
strategy: Strategy.
|
|
219
|
+
strategy: Strategy.LOCAL,
|
|
220
220
|
browser: false,
|
|
221
221
|
args: [],
|
|
222
222
|
columns: ['status'],
|
|
@@ -227,7 +227,7 @@ cli({
|
|
|
227
227
|
name: 'next',
|
|
228
228
|
access: 'write',
|
|
229
229
|
description: 'Skip to next track',
|
|
230
|
-
strategy: Strategy.
|
|
230
|
+
strategy: Strategy.LOCAL,
|
|
231
231
|
browser: false,
|
|
232
232
|
args: [],
|
|
233
233
|
columns: ['status'],
|
|
@@ -238,7 +238,7 @@ cli({
|
|
|
238
238
|
name: 'prev',
|
|
239
239
|
access: 'write',
|
|
240
240
|
description: 'Skip to previous track',
|
|
241
|
-
strategy: Strategy.
|
|
241
|
+
strategy: Strategy.LOCAL,
|
|
242
242
|
browser: false,
|
|
243
243
|
args: [],
|
|
244
244
|
columns: ['status'],
|
|
@@ -249,7 +249,7 @@ cli({
|
|
|
249
249
|
name: 'volume',
|
|
250
250
|
access: 'write',
|
|
251
251
|
description: 'Set playback volume (0-100)',
|
|
252
|
-
strategy: Strategy.
|
|
252
|
+
strategy: Strategy.LOCAL,
|
|
253
253
|
browser: false,
|
|
254
254
|
args: [{ name: 'level', type: 'int', default: 50, positional: true, required: true, help: 'Volume 0–100' }],
|
|
255
255
|
columns: ['volume'],
|
|
@@ -266,7 +266,7 @@ cli({
|
|
|
266
266
|
name: 'search',
|
|
267
267
|
access: 'read',
|
|
268
268
|
description: 'Search for tracks',
|
|
269
|
-
strategy: Strategy.
|
|
269
|
+
strategy: Strategy.LOCAL,
|
|
270
270
|
browser: false,
|
|
271
271
|
args: [
|
|
272
272
|
{ name: 'query', type: 'str', required: true, positional: true, help: 'Search query' },
|
|
@@ -287,7 +287,7 @@ cli({
|
|
|
287
287
|
name: 'queue',
|
|
288
288
|
access: 'write',
|
|
289
289
|
description: 'Add a track to the playback queue',
|
|
290
|
-
strategy: Strategy.
|
|
290
|
+
strategy: Strategy.LOCAL,
|
|
291
291
|
browser: false,
|
|
292
292
|
args: [{ name: 'query', type: 'str', required: true, positional: true, help: 'Track to add to queue' }],
|
|
293
293
|
columns: ['track', 'artist', 'status'],
|
|
@@ -302,7 +302,7 @@ cli({
|
|
|
302
302
|
name: 'shuffle',
|
|
303
303
|
access: 'write',
|
|
304
304
|
description: 'Toggle shuffle on/off',
|
|
305
|
-
strategy: Strategy.
|
|
305
|
+
strategy: Strategy.LOCAL,
|
|
306
306
|
browser: false,
|
|
307
307
|
args: [{ name: 'state', type: 'str', default: 'on', positional: true, choices: ['on', 'off'], help: 'on or off' }],
|
|
308
308
|
columns: ['shuffle'],
|
|
@@ -316,7 +316,7 @@ cli({
|
|
|
316
316
|
name: 'repeat',
|
|
317
317
|
access: 'write',
|
|
318
318
|
description: 'Set repeat mode (off / track / context)',
|
|
319
|
-
strategy: Strategy.
|
|
319
|
+
strategy: Strategy.LOCAL,
|
|
320
320
|
browser: false,
|
|
321
321
|
args: [{ name: 'mode', type: 'str', default: 'context', positional: true, choices: ['off', 'track', 'context'], help: 'off / track / context' }],
|
|
322
322
|
columns: ['repeat'],
|
|
@@ -26,6 +26,7 @@ export declare function sendCommandFull(action: DaemonCommand['action'], params?
|
|
|
26
26
|
data: unknown;
|
|
27
27
|
page?: string;
|
|
28
28
|
}>;
|
|
29
|
+
export declare function releaseSiteSessionLease(runId: string): Promise<void>;
|
|
29
30
|
export declare function bindTab(session: string, opts?: {
|
|
30
31
|
contextId?: string;
|
|
31
32
|
preferredContextId?: string;
|
|
@@ -4,8 +4,9 @@
|
|
|
4
4
|
* Provides a typed send() function that posts a Command and returns a Result.
|
|
5
5
|
*/
|
|
6
6
|
import { sleep } from '../utils.js';
|
|
7
|
-
import { BrowserConnectError } from '../errors.js';
|
|
7
|
+
import { BrowserConnectError, SessionBusyError } from '../errors.js';
|
|
8
8
|
import { COMMAND_RESULT_UNKNOWN_CODE, COMMAND_RESULT_UNKNOWN_HINT } from '../daemon-utils.js';
|
|
9
|
+
import { getDaemonRunContext } from '../session-lease.js';
|
|
9
10
|
import { classifyBrowserError } from './errors.js';
|
|
10
11
|
import { profileRouteParams, resolveProfileSelection } from './profile.js';
|
|
11
12
|
import { DEFAULT_BROWSER_CONNECT_TIMEOUT } from './config.js';
|
|
@@ -112,6 +113,7 @@ async function sendCommandRaw(action, params) {
|
|
|
112
113
|
throw new BrowserCommandError('Browser command deadline exhausted across transport retries.', COMMAND_RESULT_UNKNOWN_CODE, COMMAND_RESULT_UNKNOWN_HINT);
|
|
113
114
|
}
|
|
114
115
|
const remainingMs = Math.max(1000, deadlineAt - Date.now());
|
|
116
|
+
const run = action === 'lease-release' ? undefined : getDaemonRunContext();
|
|
115
117
|
const command = {
|
|
116
118
|
id,
|
|
117
119
|
action,
|
|
@@ -121,6 +123,12 @@ async function sendCommandRaw(action, params) {
|
|
|
121
123
|
...(contextId && { contextId }),
|
|
122
124
|
...(preferredContextId && { preferredContextId }),
|
|
123
125
|
...(windowMode && { windowMode }),
|
|
126
|
+
...(run && {
|
|
127
|
+
runId: run.runId,
|
|
128
|
+
command: run.command,
|
|
129
|
+
access: run.access,
|
|
130
|
+
pid: process.pid,
|
|
131
|
+
}),
|
|
124
132
|
};
|
|
125
133
|
try {
|
|
126
134
|
const res = await requestDaemon('/command', {
|
|
@@ -132,6 +140,9 @@ async function sendCommandRaw(action, params) {
|
|
|
132
140
|
const result = (await res.json());
|
|
133
141
|
if (result.ok)
|
|
134
142
|
return result;
|
|
143
|
+
if (res.status === 409 && result.code === 'session_busy' && result.holder) {
|
|
144
|
+
throw new SessionBusyError(result.holder);
|
|
145
|
+
}
|
|
135
146
|
if (result.errorCode && UNKNOWN_OUTCOME_CODES.has(result.errorCode)) {
|
|
136
147
|
throw new BrowserCommandError(result.error ?? 'Browser command result is unknown', result.errorCode, result.errorHint);
|
|
137
148
|
}
|
|
@@ -187,6 +198,19 @@ export async function sendCommandFull(action, params = {}) {
|
|
|
187
198
|
const result = await sendCommandRaw(action, params);
|
|
188
199
|
return { data: result.data, page: result.page };
|
|
189
200
|
}
|
|
201
|
+
export async function releaseSiteSessionLease(runId) {
|
|
202
|
+
const command = {
|
|
203
|
+
id: generateId(),
|
|
204
|
+
action: 'lease-release',
|
|
205
|
+
runId,
|
|
206
|
+
};
|
|
207
|
+
await requestDaemon('/command', {
|
|
208
|
+
method: 'POST',
|
|
209
|
+
headers: { 'Content-Type': 'application/json' },
|
|
210
|
+
body: JSON.stringify(command),
|
|
211
|
+
timeout: 2_000,
|
|
212
|
+
}).catch(() => undefined);
|
|
213
|
+
}
|
|
190
214
|
export async function bindTab(session, opts = {}) {
|
|
191
215
|
return sendCommand('bind', { session, surface: 'browser', ...opts });
|
|
192
216
|
}
|
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
-
import { fetchDaemonStatus, getDaemonHealth, requestDaemonShutdown, sendCommand, setDaemonCommandTimeoutSeconds, } from './daemon-client.js';
|
|
2
|
+
import { fetchDaemonStatus, getDaemonHealth, releaseSiteSessionLease, requestDaemonShutdown, sendCommand, setDaemonCommandTimeoutSeconds, } from './daemon-client.js';
|
|
3
3
|
import * as daemonLifecycle from './daemon-lifecycle.js';
|
|
4
|
+
import { clearDaemonRunContext, getDaemonRunContext, setDaemonRunContext } from '../session-lease.js';
|
|
4
5
|
describe('daemon-client', () => {
|
|
5
6
|
beforeEach(() => {
|
|
6
7
|
vi.stubGlobal('fetch', vi.fn());
|
|
7
8
|
});
|
|
8
9
|
afterEach(() => {
|
|
10
|
+
const run = getDaemonRunContext();
|
|
11
|
+
if (run)
|
|
12
|
+
clearDaemonRunContext(run.runId);
|
|
9
13
|
if (typeof setDaemonCommandTimeoutSeconds === 'function') {
|
|
10
14
|
setDaemonCommandTimeoutSeconds(null);
|
|
11
15
|
}
|
|
@@ -142,6 +146,25 @@ describe('daemon-client', () => {
|
|
|
142
146
|
expect(ids[1]).toMatch(new RegExp(`^cmd_${process.pid}_1763000000000_\\d+$`));
|
|
143
147
|
expect(ids[0]).not.toBe(ids[1]);
|
|
144
148
|
});
|
|
149
|
+
it('sendCommand binds the active logical run metadata to each daemon operation', async () => {
|
|
150
|
+
setDaemonRunContext({
|
|
151
|
+
runId: 'run_4242_1000_1',
|
|
152
|
+
command: 'example write',
|
|
153
|
+
access: 'write',
|
|
154
|
+
});
|
|
155
|
+
vi.mocked(fetch).mockResolvedValue({
|
|
156
|
+
status: 200,
|
|
157
|
+
json: () => Promise.resolve({ id: 'server', ok: true, data: 'ok' }),
|
|
158
|
+
});
|
|
159
|
+
await sendCommand('exec', { code: '1' });
|
|
160
|
+
const body = JSON.parse(String(vi.mocked(fetch).mock.calls[0][1]?.body));
|
|
161
|
+
expect(body).toMatchObject({
|
|
162
|
+
runId: 'run_4242_1000_1',
|
|
163
|
+
command: 'example write',
|
|
164
|
+
access: 'write',
|
|
165
|
+
pid: process.pid,
|
|
166
|
+
});
|
|
167
|
+
});
|
|
145
168
|
it('sendCommand forwards WEBCMD_PROFILE as a hard contextId requirement', async () => {
|
|
146
169
|
vi.stubEnv('WEBCMD_PROFILE', 'work');
|
|
147
170
|
vi.spyOn(Date, 'now').mockReturnValue(1_763_000_000_000);
|
|
@@ -200,6 +223,68 @@ describe('daemon-client', () => {
|
|
|
200
223
|
});
|
|
201
224
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
202
225
|
});
|
|
226
|
+
it('maps daemon session_busy conflicts to SessionBusyError without retrying', async () => {
|
|
227
|
+
const fetchMock = vi.mocked(fetch);
|
|
228
|
+
fetchMock.mockResolvedValueOnce({
|
|
229
|
+
ok: false,
|
|
230
|
+
status: 409,
|
|
231
|
+
json: () => Promise.resolve({
|
|
232
|
+
ok: false,
|
|
233
|
+
code: 'session_busy',
|
|
234
|
+
holder: {
|
|
235
|
+
command: 'other write',
|
|
236
|
+
pid: 4242,
|
|
237
|
+
acquiredAt: 1_000,
|
|
238
|
+
heartbeatAt: 2_000,
|
|
239
|
+
},
|
|
240
|
+
}),
|
|
241
|
+
});
|
|
242
|
+
await expect(sendCommand('exec', { code: '1' })).rejects.toMatchObject({
|
|
243
|
+
name: 'SessionBusyError',
|
|
244
|
+
code: 'SESSION_BUSY',
|
|
245
|
+
message: expect.stringContaining('other write'),
|
|
246
|
+
});
|
|
247
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
248
|
+
});
|
|
249
|
+
it('releaseSiteSessionLease makes one best-effort POST without starting or retrying the daemon', async () => {
|
|
250
|
+
setDaemonRunContext({
|
|
251
|
+
runId: 'run_9999_newer_2',
|
|
252
|
+
command: 'newer write',
|
|
253
|
+
access: 'write',
|
|
254
|
+
});
|
|
255
|
+
const ensureSpy = vi.spyOn(daemonLifecycle, 'ensureBrowserBridgeReady');
|
|
256
|
+
vi.mocked(fetch).mockRejectedValueOnce(new TypeError('fetch failed'));
|
|
257
|
+
await expect(releaseSiteSessionLease('run_4242_1000_1')).resolves.toBeUndefined();
|
|
258
|
+
expect(fetch).toHaveBeenCalledTimes(1);
|
|
259
|
+
expect(ensureSpy).not.toHaveBeenCalled();
|
|
260
|
+
const body = JSON.parse(String(vi.mocked(fetch).mock.calls[0][1]?.body));
|
|
261
|
+
expect(body).toMatchObject({
|
|
262
|
+
action: 'lease-release',
|
|
263
|
+
runId: 'run_4242_1000_1',
|
|
264
|
+
});
|
|
265
|
+
});
|
|
266
|
+
it('releaseSiteSessionLease aborts its best-effort POST after two seconds', async () => {
|
|
267
|
+
vi.useFakeTimers();
|
|
268
|
+
try {
|
|
269
|
+
let aborted = false;
|
|
270
|
+
vi.mocked(fetch).mockImplementationOnce((_url, init) => new Promise((_, reject) => {
|
|
271
|
+
init?.signal?.addEventListener('abort', () => {
|
|
272
|
+
aborted = true;
|
|
273
|
+
reject(Object.assign(new Error('This operation was aborted'), { name: 'AbortError' }));
|
|
274
|
+
});
|
|
275
|
+
}));
|
|
276
|
+
const pending = releaseSiteSessionLease('run_4242_1000_1');
|
|
277
|
+
await vi.advanceTimersByTimeAsync(1_999);
|
|
278
|
+
expect(aborted).toBe(false);
|
|
279
|
+
await vi.advanceTimersByTimeAsync(1);
|
|
280
|
+
expect(aborted).toBe(true);
|
|
281
|
+
await expect(pending).resolves.toBeUndefined();
|
|
282
|
+
expect(fetch).toHaveBeenCalledTimes(1);
|
|
283
|
+
}
|
|
284
|
+
finally {
|
|
285
|
+
vi.useRealTimers();
|
|
286
|
+
}
|
|
287
|
+
});
|
|
203
288
|
it('sendCommand does not retry command_result_unknown even when the message looks transient', async () => {
|
|
204
289
|
const fetchMock = vi.mocked(fetch);
|
|
205
290
|
fetchMock.mockResolvedValue({
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { SessionLeaseStatus } from '../session-lease.js';
|
|
1
2
|
export interface DaemonStatus {
|
|
2
3
|
ok: boolean;
|
|
3
4
|
pid: number;
|
|
@@ -12,6 +13,7 @@ export interface DaemonStatus {
|
|
|
12
13
|
profiles?: BrowserProfileStatus[];
|
|
13
14
|
pending: number;
|
|
14
15
|
commandResultUnknown?: number;
|
|
16
|
+
sessionLeases?: SessionLeaseStatus[];
|
|
15
17
|
memoryMB: number;
|
|
16
18
|
port: number;
|
|
17
19
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
|
|
1
|
+
import type { SessionLeaseStatus } from '../session-lease.js';
|
|
2
|
+
export type BrowserRuntimeAction = 'exec' | 'navigate' | 'tabs' | 'cookies' | 'screenshot' | 'close-window' | 'set-file-input' | 'insert-text' | 'bind' | 'network-capture-start' | 'network-capture-read' | 'wait-download' | 'cdp' | 'frames' | 'lease-release';
|
|
2
3
|
export type BrowserSurface = 'browser' | 'adapter';
|
|
3
4
|
export type SiteSessionMode = 'ephemeral' | 'persistent';
|
|
4
5
|
export type BrowserWindowMode = 'foreground' | 'background';
|
|
@@ -38,6 +39,14 @@ export interface BrowserRuntimeCommand {
|
|
|
38
39
|
contextId?: string;
|
|
39
40
|
preferredContextId?: string;
|
|
40
41
|
profileId?: string;
|
|
42
|
+
/** Stable identity for the complete logical CLI command run. */
|
|
43
|
+
runId?: string;
|
|
44
|
+
/** Human-readable canonical command that owns the logical run. */
|
|
45
|
+
command?: string;
|
|
46
|
+
/** Access classification used by daemon-local lease arbitration. */
|
|
47
|
+
access?: 'read' | 'write';
|
|
48
|
+
/** Originating CLI process, used only for actionable local busy guidance. */
|
|
49
|
+
pid?: number;
|
|
41
50
|
}
|
|
42
51
|
export interface BrowserRuntimeResult {
|
|
43
52
|
id: string;
|
|
@@ -65,4 +74,6 @@ export interface BrowserRuntimeStatus {
|
|
|
65
74
|
profiles: BrowserRuntimeProfileStatus[];
|
|
66
75
|
pending: number;
|
|
67
76
|
commandResultUnknown?: number;
|
|
77
|
+
/** Active local leases with internal run ownership tokens removed. */
|
|
78
|
+
sessionLeases?: SessionLeaseStatus[];
|
|
68
79
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
1
|
import type { BrowserRuntimeCommand, BrowserRuntimeResult } from '../../protocol.js';
|
|
2
2
|
import type { CloakSessionManager } from './session-manager.js';
|
|
3
|
+
export declare function resolveCloakCommandProfileId(manager: CloakSessionManager, command: BrowserRuntimeCommand): string;
|
|
3
4
|
export declare function dispatchCloakAction(manager: CloakSessionManager, command: BrowserRuntimeCommand): Promise<BrowserRuntimeResult>;
|