@askjo/camofox-browser 1.5.1 → 1.6.0
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/Dockerfile +17 -2
- package/README.md +20 -0
- package/camofox.config.json +10 -0
- package/lib/auth.js +71 -0
- package/lib/config.js +1 -0
- package/lib/cookies.js +38 -1
- package/lib/downloads.js +10 -2
- package/lib/inflight.js +16 -0
- package/lib/metrics.js +29 -0
- package/lib/persistence.js +89 -0
- package/lib/plugins.js +174 -0
- package/lib/tmp-cleanup.js +40 -0
- package/openclaw.plugin.json +1 -1
- package/package.json +5 -1
- package/plugin.ts +8 -1
- package/plugins/persistence/AGENTS.md +37 -0
- package/plugins/persistence/README.md +48 -0
- package/plugins/persistence/index.js +120 -0
- package/plugins/persistence/persistence.test.js +117 -0
- package/plugins/persistence/plugin.test.js +98 -0
- package/plugins/vnc/AGENTS.md +42 -0
- package/plugins/vnc/README.md +165 -0
- package/plugins/vnc/apt.txt +7 -0
- package/plugins/vnc/index.js +142 -0
- package/plugins/vnc/vnc-launcher.js +64 -0
- package/plugins/vnc/vnc-watcher.sh +82 -0
- package/plugins/vnc/vnc.test.js +204 -0
- package/plugins/youtube/AGENTS.md +25 -0
- package/plugins/youtube/apt.txt +1 -0
- package/plugins/youtube/index.js +206 -0
- package/plugins/youtube/post-install.sh +5 -0
- package/plugins/youtube/youtube.test.js +41 -0
- package/scripts/install-plugin-deps.sh +63 -0
- package/scripts/plugin.js +342 -0
- package/scripts/plugin.test.js +117 -0
- package/server.js +286 -328
- /package/{lib → plugins/youtube}/youtube.js +0 -0
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* VNC plugin for camofox-browser.
|
|
3
|
+
*
|
|
4
|
+
* Exposes Camoufox's virtual display via noVNC so a human can interact with
|
|
5
|
+
* the browser visually — log into sites, solve CAPTCHAs, approve OAuth prompts.
|
|
6
|
+
* After interactive login, export the storage state via the API endpoint this
|
|
7
|
+
* plugin registers.
|
|
8
|
+
*
|
|
9
|
+
* Architecture:
|
|
10
|
+
* Plugin replaces the default 1x1 Xvfb with a 1920x1080 display (via
|
|
11
|
+
* ctx.createVirtualDisplay factory override). vnc-watcher.sh detects the
|
|
12
|
+
* Xvfb process, attaches x11vnc, and noVNC (websockify) proxies it to a
|
|
13
|
+
* web UI on port 6080.
|
|
14
|
+
*
|
|
15
|
+
* Configuration (camofox.config.json):
|
|
16
|
+
* {
|
|
17
|
+
* "plugins": {
|
|
18
|
+
* "vnc": {
|
|
19
|
+
* "enabled": true,
|
|
20
|
+
* "resolution": "1920x1080",
|
|
21
|
+
* "password": "",
|
|
22
|
+
* "viewOnly": false,
|
|
23
|
+
* "vncPort": 5900,
|
|
24
|
+
* "novncPort": 6080
|
|
25
|
+
* }
|
|
26
|
+
* }
|
|
27
|
+
* }
|
|
28
|
+
*
|
|
29
|
+
* Or via environment variables (override config):
|
|
30
|
+
* ENABLE_VNC=1 Enable the plugin
|
|
31
|
+
* VNC_RESOLUTION=1920x1080
|
|
32
|
+
* VNC_PASSWORD=secret Optional password for x11vnc
|
|
33
|
+
* VIEW_ONLY=1 View-only mode (no mouse/keyboard input)
|
|
34
|
+
* VNC_PORT=5900 x11vnc listen port
|
|
35
|
+
* NOVNC_PORT=6080 noVNC web UI port
|
|
36
|
+
*
|
|
37
|
+
* Registers:
|
|
38
|
+
* GET /sessions/:userId/storage_state — export Playwright storageState as JSON
|
|
39
|
+
*
|
|
40
|
+
* Events emitted:
|
|
41
|
+
* vnc:watcher:started { pid }
|
|
42
|
+
* vnc:watcher:stopped { code, signal }
|
|
43
|
+
* vnc:storage:exported { userId, cookies, origins }
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
import { resolveVncConfig, startWatcher } from './vnc-launcher.js';
|
|
47
|
+
import { requireAuth } from '../../lib/auth.js';
|
|
48
|
+
|
|
49
|
+
export async function register(app, ctx, pluginConfig = {}) {
|
|
50
|
+
const { events, config, log, sessions, VirtualDisplay, safeError } = ctx;
|
|
51
|
+
|
|
52
|
+
// Resolve all config (env vars + pluginConfig) via the launcher module
|
|
53
|
+
const vncConfig = resolveVncConfig(pluginConfig);
|
|
54
|
+
|
|
55
|
+
if (!vncConfig.enabled) {
|
|
56
|
+
log('info', 'vnc plugin: disabled (set ENABLE_VNC=1 or plugins.vnc.enabled=true)');
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// --- Override Xvfb resolution ---
|
|
61
|
+
const { resolution } = vncConfig;
|
|
62
|
+
|
|
63
|
+
class VncVirtualDisplay extends VirtualDisplay {
|
|
64
|
+
get xvfb_args() {
|
|
65
|
+
const args = super.xvfb_args;
|
|
66
|
+
const idx = args.indexOf('0');
|
|
67
|
+
if (idx > 0 && args[idx - 1] === '-screen') {
|
|
68
|
+
const patched = [...args];
|
|
69
|
+
patched[idx + 1] = resolution;
|
|
70
|
+
return patched;
|
|
71
|
+
}
|
|
72
|
+
return args;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
ctx.createVirtualDisplay = () => new VncVirtualDisplay();
|
|
77
|
+
log('info', 'vnc plugin: overriding Xvfb resolution', { resolution });
|
|
78
|
+
|
|
79
|
+
// --- VNC watcher process ---
|
|
80
|
+
log('info', 'vnc plugin enabled', {
|
|
81
|
+
resolution,
|
|
82
|
+
novncPort: vncConfig.novncPort,
|
|
83
|
+
vncPort: vncConfig.vncPort,
|
|
84
|
+
viewOnly: vncConfig.viewOnly,
|
|
85
|
+
passwordProtected: !!vncConfig.vncPassword,
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
const watcher = startWatcher({
|
|
89
|
+
resolution: vncConfig.resolution,
|
|
90
|
+
vncPassword: vncConfig.vncPassword,
|
|
91
|
+
viewOnly: vncConfig.viewOnly,
|
|
92
|
+
vncPort: vncConfig.vncPort,
|
|
93
|
+
novncPort: vncConfig.novncPort,
|
|
94
|
+
log,
|
|
95
|
+
events,
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// Clean up watcher on server shutdown
|
|
99
|
+
events.on('server:shutdown', () => {
|
|
100
|
+
if (watcher.exitCode === null) {
|
|
101
|
+
log('info', 'killing vnc watcher on shutdown');
|
|
102
|
+
watcher.kill('SIGTERM');
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// --- HTTP endpoint: GET /sessions/:userId/storage_state ---
|
|
107
|
+
const authMiddleware = requireAuth(config);
|
|
108
|
+
|
|
109
|
+
app.get('/sessions/:userId/storage_state', authMiddleware, async (req, res) => {
|
|
110
|
+
try {
|
|
111
|
+
const userId = req.params.userId;
|
|
112
|
+
const session = sessions.get(String(userId));
|
|
113
|
+
if (!session) {
|
|
114
|
+
return res.status(404).json({ error: `No active session for userId="${userId}"` });
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const state = await session.context.storageState();
|
|
118
|
+
|
|
119
|
+
log('info', 'storage_state exported', {
|
|
120
|
+
reqId: req.reqId,
|
|
121
|
+
userId: String(userId),
|
|
122
|
+
cookies: state.cookies?.length || 0,
|
|
123
|
+
origins: state.origins?.length || 0,
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
events.emit('vnc:storage:exported', {
|
|
127
|
+
userId: String(userId),
|
|
128
|
+
cookies: state.cookies?.length || 0,
|
|
129
|
+
origins: state.origins?.length || 0,
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
events.emit('session:storage:export', { userId: String(userId) });
|
|
133
|
+
|
|
134
|
+
res.json(state);
|
|
135
|
+
} catch (err) {
|
|
136
|
+
log('error', 'storage_state export failed', { reqId: req.reqId, error: err.message });
|
|
137
|
+
res.status(500).json({ error: safeError(err) });
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
log('info', 'vnc plugin: registered GET /sessions/:userId/storage_state');
|
|
142
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* VNC launcher — owns all child_process spawning and process.env reads.
|
|
3
|
+
* Isolated from route handlers for OpenClaw scanner compliance.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { spawn } from 'node:child_process';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
|
|
10
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Resolve VNC configuration from pluginConfig + env var fallbacks.
|
|
14
|
+
* All process.env reads live here — callers get a plain config object.
|
|
15
|
+
*/
|
|
16
|
+
export function resolveVncConfig(pluginConfig = {}) {
|
|
17
|
+
const enabled = process.env.ENABLE_VNC === '1' || pluginConfig.enabled === true;
|
|
18
|
+
|
|
19
|
+
const rawResolution = process.env.VNC_RESOLUTION || pluginConfig.resolution || '1920x1080';
|
|
20
|
+
const resolution = rawResolution.includes('x', rawResolution.indexOf('x') + 1)
|
|
21
|
+
? rawResolution
|
|
22
|
+
: `${rawResolution}x24`;
|
|
23
|
+
|
|
24
|
+
const vncPassword = process.env.VNC_PASSWORD || pluginConfig.password || '';
|
|
25
|
+
const viewOnly = process.env.VIEW_ONLY === '1' || pluginConfig.viewOnly === true;
|
|
26
|
+
const vncPort = process.env.VNC_PORT || pluginConfig.vncPort || '5900';
|
|
27
|
+
const novncPort = process.env.NOVNC_PORT || pluginConfig.novncPort || '6080';
|
|
28
|
+
|
|
29
|
+
return { enabled, resolution, vncPassword, viewOnly, vncPort, novncPort };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Start the vnc-watcher.sh child process.
|
|
34
|
+
* Returns the spawned ChildProcess.
|
|
35
|
+
*/
|
|
36
|
+
export function startWatcher({ resolution, vncPassword, viewOnly, vncPort, novncPort, log, events }) {
|
|
37
|
+
const watcherPath = path.join(__dirname, 'vnc-watcher.sh');
|
|
38
|
+
const watcher = spawn('sh', [watcherPath], {
|
|
39
|
+
env: {
|
|
40
|
+
...process.env,
|
|
41
|
+
VNC_PASSWORD: vncPassword,
|
|
42
|
+
VNC_RESOLUTION: resolution,
|
|
43
|
+
VIEW_ONLY: viewOnly ? '1' : '0',
|
|
44
|
+
VNC_PORT: String(vncPort),
|
|
45
|
+
NOVNC_PORT: String(novncPort),
|
|
46
|
+
},
|
|
47
|
+
stdio: ['ignore', 'inherit', 'inherit'],
|
|
48
|
+
detached: false,
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
watcher.on('error', (err) => {
|
|
52
|
+
log('error', 'vnc watcher failed to start', { error: err.message });
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
watcher.on('exit', (code, signal) => {
|
|
56
|
+
log('warn', 'vnc watcher exited', { code, signal });
|
|
57
|
+
events.emit('vnc:watcher:stopped', { code, signal });
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
log('info', 'vnc watcher started', { pid: watcher.pid });
|
|
61
|
+
events.emit('vnc:watcher:started', { pid: watcher.pid });
|
|
62
|
+
|
|
63
|
+
return watcher;
|
|
64
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# VNC watcher: detects Camoufox's dynamically-assigned Xvfb display and attaches
|
|
3
|
+
# x11vnc + noVNC to it. Handles browser restarts (re-attaches on display change).
|
|
4
|
+
#
|
|
5
|
+
# Called by the VNC plugin via child_process.spawn. Not meant to run standalone.
|
|
6
|
+
#
|
|
7
|
+
# Env vars (set by the plugin):
|
|
8
|
+
# VNC_PASSWORD If set, x11vnc requires this password
|
|
9
|
+
# VIEW_ONLY "1" for view-only mode
|
|
10
|
+
# VNC_PORT VNC port (default: 5900)
|
|
11
|
+
# NOVNC_PORT noVNC websocket port (default: 6080)
|
|
12
|
+
|
|
13
|
+
set -e
|
|
14
|
+
|
|
15
|
+
VNC_PORT="${VNC_PORT:-5900}"
|
|
16
|
+
NOVNC_PORT="${NOVNC_PORT:-6080}"
|
|
17
|
+
VNC_RESOLUTION="${VNC_RESOLUTION:-1920x1080x24}"
|
|
18
|
+
|
|
19
|
+
log() { printf '[vnc-watcher] %s\n' "$*" >&2; }
|
|
20
|
+
|
|
21
|
+
CURRENT_DISPLAY=""
|
|
22
|
+
X11VNC_PID=""
|
|
23
|
+
|
|
24
|
+
# Prepare password file if requested
|
|
25
|
+
PASSFILE=""
|
|
26
|
+
if [ -n "${VNC_PASSWORD:-}" ]; then
|
|
27
|
+
mkdir -p /tmp/.vnc
|
|
28
|
+
x11vnc -storepasswd "$VNC_PASSWORD" /tmp/.vnc/passwd >/dev/null 2>&1
|
|
29
|
+
PASSFILE="/tmp/.vnc/passwd"
|
|
30
|
+
log "x11vnc: password protected"
|
|
31
|
+
else
|
|
32
|
+
log "x11vnc: NO password (bind $NOVNC_PORT to 127.0.0.1 on host + SSH tunnel)"
|
|
33
|
+
fi
|
|
34
|
+
|
|
35
|
+
# Start noVNC (websockify) — proxies to x11vnc regardless of whether it's up yet
|
|
36
|
+
NOVNC_DIR="/usr/share/novnc"
|
|
37
|
+
if [ ! -d "$NOVNC_DIR" ]; then
|
|
38
|
+
log "ERROR: $NOVNC_DIR not found; noVNC cannot start"
|
|
39
|
+
exit 1
|
|
40
|
+
fi
|
|
41
|
+
VNC_BIND="${VNC_BIND:-127.0.0.1}"
|
|
42
|
+
log "Starting noVNC (websockify) on $VNC_BIND:$NOVNC_PORT -> 127.0.0.1:$VNC_PORT"
|
|
43
|
+
websockify --web "$NOVNC_DIR" "$VNC_BIND:$NOVNC_PORT" "127.0.0.1:$VNC_PORT" >/var/log/novnc.log 2>&1 &
|
|
44
|
+
|
|
45
|
+
log "VNC watcher started — will attach x11vnc when Camoufox's Xvfb appears"
|
|
46
|
+
|
|
47
|
+
while true; do
|
|
48
|
+
# Find Xvfb with our patched resolution
|
|
49
|
+
FOUND=$(ps -eo args= 2>/dev/null | awk -v res="$VNC_RESOLUTION" '
|
|
50
|
+
/\/Xvfb :[0-9]+/ && index($0, res) {
|
|
51
|
+
for (i=1;i<=NF;i++) if ($i ~ /^:[0-9]+$/) { print $i; exit }
|
|
52
|
+
}
|
|
53
|
+
' | head -1)
|
|
54
|
+
|
|
55
|
+
if [ -n "$FOUND" ] && [ "$FOUND" != "$CURRENT_DISPLAY" ]; then
|
|
56
|
+
# New or changed display — (re)attach x11vnc
|
|
57
|
+
if [ -n "$X11VNC_PID" ] && kill -0 "$X11VNC_PID" 2>/dev/null; then
|
|
58
|
+
log "Camoufox display changed ($CURRENT_DISPLAY -> $FOUND), restarting x11vnc"
|
|
59
|
+
kill "$X11VNC_PID" 2>/dev/null || true
|
|
60
|
+
sleep 0.5
|
|
61
|
+
fi
|
|
62
|
+
|
|
63
|
+
CURRENT_DISPLAY="$FOUND"
|
|
64
|
+
log "Attaching x11vnc to DISPLAY=$CURRENT_DISPLAY"
|
|
65
|
+
|
|
66
|
+
X11VNC_ARGS="-display $CURRENT_DISPLAY -forever -shared -rfbport $VNC_PORT -noxdamage -quiet -bg -o /var/log/x11vnc.log"
|
|
67
|
+
[ "${VIEW_ONLY:-0}" = "1" ] && X11VNC_ARGS="$X11VNC_ARGS -viewonly"
|
|
68
|
+
if [ -n "$PASSFILE" ]; then
|
|
69
|
+
X11VNC_ARGS="$X11VNC_ARGS -rfbauth $PASSFILE"
|
|
70
|
+
else
|
|
71
|
+
X11VNC_ARGS="$X11VNC_ARGS -nopw"
|
|
72
|
+
fi
|
|
73
|
+
|
|
74
|
+
# shellcheck disable=SC2086
|
|
75
|
+
x11vnc $X11VNC_ARGS
|
|
76
|
+
sleep 1
|
|
77
|
+
X11VNC_PID=$(pgrep -f "x11vnc.*-display $CURRENT_DISPLAY" | head -1)
|
|
78
|
+
log "x11vnc running (pid=$X11VNC_PID) on DISPLAY=$CURRENT_DISPLAY"
|
|
79
|
+
fi
|
|
80
|
+
|
|
81
|
+
sleep 2
|
|
82
|
+
done
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { EventEmitter } from 'node:events';
|
|
2
|
+
import { jest } from '@jest/globals';
|
|
3
|
+
|
|
4
|
+
// Mock the launcher module — index.js no longer imports child_process directly
|
|
5
|
+
const mockWatcher = () => {
|
|
6
|
+
const proc = new EventEmitter();
|
|
7
|
+
proc.pid = 12345;
|
|
8
|
+
proc.exitCode = null;
|
|
9
|
+
proc.kill = jest.fn();
|
|
10
|
+
return proc;
|
|
11
|
+
};
|
|
12
|
+
const mockStartWatcher = jest.fn(mockWatcher);
|
|
13
|
+
const mockResolveVncConfig = jest.fn((pluginConfig = {}) => ({
|
|
14
|
+
enabled: pluginConfig.enabled || false,
|
|
15
|
+
resolution: pluginConfig.resolution
|
|
16
|
+
? (pluginConfig.resolution.split('x').length > 2 ? pluginConfig.resolution : `${pluginConfig.resolution}x24`)
|
|
17
|
+
: '1920x1080x24',
|
|
18
|
+
vncPassword: pluginConfig.password || '',
|
|
19
|
+
viewOnly: pluginConfig.viewOnly || false,
|
|
20
|
+
vncPort: pluginConfig.vncPort || '5900',
|
|
21
|
+
novncPort: pluginConfig.novncPort || '6080',
|
|
22
|
+
}));
|
|
23
|
+
|
|
24
|
+
jest.unstable_mockModule('./vnc-launcher.js', () => ({
|
|
25
|
+
resolveVncConfig: mockResolveVncConfig,
|
|
26
|
+
startWatcher: mockStartWatcher,
|
|
27
|
+
}));
|
|
28
|
+
|
|
29
|
+
// Mock auth middleware
|
|
30
|
+
jest.unstable_mockModule('../../lib/auth.js', () => ({
|
|
31
|
+
requireAuth: () => (_req, _res, next) => next(),
|
|
32
|
+
}));
|
|
33
|
+
|
|
34
|
+
// Minimal VirtualDisplay mock (real class has side-effects that break in test)
|
|
35
|
+
class MockVirtualDisplay {
|
|
36
|
+
get xvfb_args() {
|
|
37
|
+
return ['-screen', '0', '1x1x24', '-ac', '-nolisten', 'tcp'];
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const { register } = await import('./index.js');
|
|
42
|
+
|
|
43
|
+
describe('vnc plugin', () => {
|
|
44
|
+
let events, ctx, mockApp, routes;
|
|
45
|
+
|
|
46
|
+
beforeEach(() => {
|
|
47
|
+
events = new EventEmitter();
|
|
48
|
+
events.setMaxListeners(50);
|
|
49
|
+
routes = {};
|
|
50
|
+
mockApp = {
|
|
51
|
+
get: jest.fn((path, ...handlers) => { routes[`GET ${path}`] = handlers; }),
|
|
52
|
+
};
|
|
53
|
+
ctx = {
|
|
54
|
+
events,
|
|
55
|
+
config: {},
|
|
56
|
+
log: jest.fn(),
|
|
57
|
+
sessions: new Map(),
|
|
58
|
+
safeError: (err) => typeof err === 'string' ? err : (err?.message || 'Internal error'),
|
|
59
|
+
VirtualDisplay: MockVirtualDisplay,
|
|
60
|
+
createVirtualDisplay: () => new MockVirtualDisplay(),
|
|
61
|
+
};
|
|
62
|
+
mockStartWatcher.mockClear();
|
|
63
|
+
mockStartWatcher.mockImplementation(mockWatcher);
|
|
64
|
+
mockResolveVncConfig.mockClear();
|
|
65
|
+
mockResolveVncConfig.mockImplementation((pluginConfig = {}) => ({
|
|
66
|
+
enabled: pluginConfig.enabled || false,
|
|
67
|
+
resolution: pluginConfig.resolution
|
|
68
|
+
? (pluginConfig.resolution.split('x').length > 2 ? pluginConfig.resolution : `${pluginConfig.resolution}x24`)
|
|
69
|
+
: '1920x1080x24',
|
|
70
|
+
vncPassword: pluginConfig.password || '',
|
|
71
|
+
viewOnly: pluginConfig.viewOnly || false,
|
|
72
|
+
vncPort: pluginConfig.vncPort || '5900',
|
|
73
|
+
novncPort: pluginConfig.novncPort || '6080',
|
|
74
|
+
}));
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('does not register when disabled', async () => {
|
|
78
|
+
await register(mockApp, ctx, {});
|
|
79
|
+
expect(mockStartWatcher).not.toHaveBeenCalled();
|
|
80
|
+
expect(mockApp.get).not.toHaveBeenCalled();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('registers when pluginConfig.enabled is true', async () => {
|
|
84
|
+
await register(mockApp, ctx, { enabled: true });
|
|
85
|
+
expect(mockStartWatcher).toHaveBeenCalled();
|
|
86
|
+
expect(mockApp.get).toHaveBeenCalledWith(
|
|
87
|
+
'/sessions/:userId/storage_state',
|
|
88
|
+
expect.any(Function),
|
|
89
|
+
expect.any(Function),
|
|
90
|
+
);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('passes resolved config to startWatcher', async () => {
|
|
94
|
+
await register(mockApp, ctx, { enabled: true, password: 'secret', vncPort: 5901 });
|
|
95
|
+
expect(mockStartWatcher).toHaveBeenCalledWith(
|
|
96
|
+
expect.objectContaining({
|
|
97
|
+
vncPassword: 'secret',
|
|
98
|
+
vncPort: 5901,
|
|
99
|
+
log: ctx.log,
|
|
100
|
+
events,
|
|
101
|
+
}),
|
|
102
|
+
);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('overrides createVirtualDisplay with custom resolution', async () => {
|
|
106
|
+
await register(mockApp, ctx, { enabled: true, resolution: '1280x720' });
|
|
107
|
+
|
|
108
|
+
const vd = ctx.createVirtualDisplay();
|
|
109
|
+
const args = vd.xvfb_args;
|
|
110
|
+
const screenIdx = args.indexOf('0');
|
|
111
|
+
expect(args[screenIdx + 1]).toBe('1280x720x24');
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test('appends x24 depth to WxH resolution', async () => {
|
|
115
|
+
await register(mockApp, ctx, { enabled: true, resolution: '1920x1080' });
|
|
116
|
+
|
|
117
|
+
const vd = ctx.createVirtualDisplay();
|
|
118
|
+
const args = vd.xvfb_args;
|
|
119
|
+
const screenIdx = args.indexOf('0');
|
|
120
|
+
expect(args[screenIdx + 1]).toBe('1920x1080x24');
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test('preserves explicit depth in resolution', async () => {
|
|
124
|
+
await register(mockApp, ctx, { enabled: true, resolution: '1920x1080x32' });
|
|
125
|
+
|
|
126
|
+
const vd = ctx.createVirtualDisplay();
|
|
127
|
+
const args = vd.xvfb_args;
|
|
128
|
+
const screenIdx = args.indexOf('0');
|
|
129
|
+
expect(args[screenIdx + 1]).toBe('1920x1080x32');
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test('storage_state endpoint returns 404 for unknown user', async () => {
|
|
133
|
+
await register(mockApp, ctx, { enabled: true });
|
|
134
|
+
|
|
135
|
+
const handler = routes['GET /sessions/:userId/storage_state'].at(-1);
|
|
136
|
+
const req = { params: { userId: 'unknown' }, reqId: 'test' };
|
|
137
|
+
const res = { status: jest.fn().mockReturnThis(), json: jest.fn() };
|
|
138
|
+
|
|
139
|
+
await handler(req, res);
|
|
140
|
+
expect(res.status).toHaveBeenCalledWith(404);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test('storage_state endpoint returns state for active session', async () => {
|
|
144
|
+
await register(mockApp, ctx, { enabled: true });
|
|
145
|
+
|
|
146
|
+
const mockState = { cookies: [{ name: 'sid', value: 'abc' }], origins: [] };
|
|
147
|
+
ctx.sessions.set('user-1', {
|
|
148
|
+
context: { storageState: jest.fn(async () => mockState) },
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
const handler = routes['GET /sessions/:userId/storage_state'].at(-1);
|
|
152
|
+
const req = { params: { userId: 'user-1' }, reqId: 'test' };
|
|
153
|
+
const res = { json: jest.fn() };
|
|
154
|
+
|
|
155
|
+
await handler(req, res);
|
|
156
|
+
expect(res.json).toHaveBeenCalledWith(mockState);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test('storage_state endpoint uses safeError on failure', async () => {
|
|
160
|
+
await register(mockApp, ctx, { enabled: true });
|
|
161
|
+
|
|
162
|
+
ctx.sessions.set('user-1', {
|
|
163
|
+
context: { storageState: jest.fn(async () => { throw new Error('context destroyed'); }) },
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
const handler = routes['GET /sessions/:userId/storage_state'].at(-1);
|
|
167
|
+
const req = { params: { userId: 'user-1' }, reqId: 'test' };
|
|
168
|
+
const res = { status: jest.fn().mockReturnThis(), json: jest.fn() };
|
|
169
|
+
|
|
170
|
+
await handler(req, res);
|
|
171
|
+
expect(res.status).toHaveBeenCalledWith(500);
|
|
172
|
+
// safeError returns the message string — not the raw Error object
|
|
173
|
+
expect(res.json).toHaveBeenCalledWith({ error: 'context destroyed' });
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test('emits vnc:storage:exported and session:storage:export on export', async () => {
|
|
177
|
+
await register(mockApp, ctx, { enabled: true });
|
|
178
|
+
|
|
179
|
+
ctx.sessions.set('user-1', {
|
|
180
|
+
context: { storageState: jest.fn(async () => ({ cookies: [], origins: [] })) },
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
const exported = [];
|
|
184
|
+
events.on('vnc:storage:exported', (e) => exported.push(e));
|
|
185
|
+
events.on('session:storage:export', (e) => exported.push(e));
|
|
186
|
+
|
|
187
|
+
const handler = routes['GET /sessions/:userId/storage_state'].at(-1);
|
|
188
|
+
await handler(
|
|
189
|
+
{ params: { userId: 'user-1' }, reqId: 'test' },
|
|
190
|
+
{ json: jest.fn() },
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
expect(exported).toHaveLength(2);
|
|
194
|
+
expect(exported[0]).toMatchObject({ userId: 'user-1' });
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test('watcher is killed on server:shutdown', async () => {
|
|
198
|
+
await register(mockApp, ctx, { enabled: true });
|
|
199
|
+
|
|
200
|
+
const proc = mockStartWatcher.mock.results[0].value;
|
|
201
|
+
events.emit('server:shutdown');
|
|
202
|
+
expect(proc.kill).toHaveBeenCalledWith('SIGTERM');
|
|
203
|
+
});
|
|
204
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# YouTube Plugin — Agent Guide
|
|
2
|
+
|
|
3
|
+
Extracts video transcripts via yt-dlp (preferred) with Playwright browser fallback.
|
|
4
|
+
|
|
5
|
+
## Endpoint
|
|
6
|
+
|
|
7
|
+
`POST /youtube/transcript` — unauthenticated by default (set `"auth": true` in plugin config to require auth).
|
|
8
|
+
|
|
9
|
+
## Key Files
|
|
10
|
+
|
|
11
|
+
- `index.js` — route handler + browser fallback logic
|
|
12
|
+
- `youtube.js` — yt-dlp process management + transcript parsing (`child_process` isolated here)
|
|
13
|
+
- `youtube.test.js` — parser unit tests
|
|
14
|
+
- `apt.txt` — system deps (python3-minimal for yt-dlp)
|
|
15
|
+
- `post-install.sh` — downloads yt-dlp binary
|
|
16
|
+
|
|
17
|
+
## Scanner Compliance
|
|
18
|
+
|
|
19
|
+
`child_process` is in `youtube.js`, route handlers are in `index.js` — separate files per OpenClaw scanner rules.
|
|
20
|
+
|
|
21
|
+
## Maintainers
|
|
22
|
+
|
|
23
|
+
- [@pradeepe](https://github.com/pradeepe) — extracted from core into plugin system
|
|
24
|
+
|
|
25
|
+
For PRs touching this plugin, tag the maintainers above for review.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
python3-minimal
|