@askjo/camofox-browser 1.5.2 → 1.7.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 +138 -8
- package/camofox.config.json +18 -0
- package/lib/auth.js +71 -0
- package/lib/config.js +27 -1
- package/lib/cookies.js +38 -1
- package/lib/downloads.js +10 -2
- package/lib/extract.js +74 -0
- package/lib/inflight.js +16 -0
- package/lib/metrics.js +29 -0
- package/lib/openapi.js +100 -0
- package/lib/persistence.js +89 -0
- package/lib/plugins.js +175 -0
- package/lib/reporter.js +751 -0
- package/lib/tmp-cleanup.js +40 -0
- package/lib/tracing.js +137 -0
- package/openclaw.plugin.json +1 -1
- package/package.json +8 -2
- package/plugins/persistence/AGENTS.md +37 -0
- package/plugins/persistence/README.md +48 -0
- package/plugins/persistence/index.js +124 -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/spawn.js +8 -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/exec.js +8 -0
- package/scripts/generate-openapi.js +24 -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 +2124 -355
- /package/{lib → plugins/youtube}/youtube.js +0 -0
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
|
|
4
|
+
const ORPHAN_PATTERNS = [
|
|
5
|
+
/^\.fea5[a-f0-9]+\.so$/,
|
|
6
|
+
/^\.5ef7[a-f0-9]+\.node$/,
|
|
7
|
+
];
|
|
8
|
+
|
|
9
|
+
export function cleanupOrphanedTempFiles({ tmpDir, minAgeMs = 5 * 60 * 1000, now = Date.now() } = {}) {
|
|
10
|
+
const result = { scanned: 0, removed: 0, bytes: 0, skipped: 0 };
|
|
11
|
+
if (!tmpDir) return result;
|
|
12
|
+
|
|
13
|
+
let entries;
|
|
14
|
+
try {
|
|
15
|
+
entries = fs.readdirSync(tmpDir);
|
|
16
|
+
} catch {
|
|
17
|
+
return result;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
for (const name of entries) {
|
|
21
|
+
if (!ORPHAN_PATTERNS.some((re) => re.test(name))) continue;
|
|
22
|
+
result.scanned++;
|
|
23
|
+
const full = path.join(tmpDir, name);
|
|
24
|
+
try {
|
|
25
|
+
const st = fs.statSync(full);
|
|
26
|
+
if (!st.isFile()) continue;
|
|
27
|
+
if (now - st.mtimeMs < minAgeMs) {
|
|
28
|
+
result.skipped++;
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
fs.unlinkSync(full);
|
|
32
|
+
result.removed++;
|
|
33
|
+
result.bytes += st.size;
|
|
34
|
+
} catch {
|
|
35
|
+
// file vanished, permission denied, or race with another process - skip silently
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return result;
|
|
40
|
+
}
|
package/lib/tracing.js
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import fsp from 'fs/promises';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import crypto from 'crypto';
|
|
5
|
+
|
|
6
|
+
function hashUserId(userId) {
|
|
7
|
+
return crypto.createHash('sha256').update(String(userId)).digest('hex').slice(0, 16);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function userTracesDir(baseDir, userId) {
|
|
11
|
+
return path.join(baseDir, hashUserId(userId));
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function ensureTracesDir(baseDir, userId) {
|
|
15
|
+
const dir = userTracesDir(baseDir, userId);
|
|
16
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
17
|
+
return dir;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function makeTraceFilename() {
|
|
21
|
+
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
|
22
|
+
const suffix = crypto.randomBytes(3).toString('hex');
|
|
23
|
+
return `trace-${ts}-${suffix}.zip`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function tracePathFor(baseDir, userId, filename) {
|
|
27
|
+
return path.join(ensureTracesDir(baseDir, userId), filename);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function resolveTracePath(baseDir, userId, filename) {
|
|
31
|
+
if (!filename || filename.includes('/') || filename.includes('\\') || filename.includes('..') || filename.startsWith('.')) {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
const userDir = userTracesDir(baseDir, userId);
|
|
35
|
+
const full = path.join(userDir, filename);
|
|
36
|
+
const resolved = path.resolve(full);
|
|
37
|
+
if (!resolved.startsWith(path.resolve(userDir) + path.sep)) return null;
|
|
38
|
+
return resolved;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function listUserTraces(baseDir, userId) {
|
|
42
|
+
const dir = userTracesDir(baseDir, userId);
|
|
43
|
+
let names;
|
|
44
|
+
try {
|
|
45
|
+
names = await fsp.readdir(dir);
|
|
46
|
+
} catch {
|
|
47
|
+
return [];
|
|
48
|
+
}
|
|
49
|
+
const out = [];
|
|
50
|
+
for (const name of names) {
|
|
51
|
+
if (!name.endsWith('.zip')) continue;
|
|
52
|
+
const full = path.join(dir, name);
|
|
53
|
+
try {
|
|
54
|
+
const st = await fsp.stat(full);
|
|
55
|
+
if (!st.isFile()) continue;
|
|
56
|
+
out.push({
|
|
57
|
+
filename: name,
|
|
58
|
+
sizeBytes: st.size,
|
|
59
|
+
createdAt: st.birthtimeMs || st.ctimeMs,
|
|
60
|
+
modifiedAt: st.mtimeMs,
|
|
61
|
+
});
|
|
62
|
+
} catch {
|
|
63
|
+
// vanished mid-scan
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
out.sort((a, b) => b.modifiedAt - a.modifiedAt);
|
|
67
|
+
return out;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function statTrace(fullPath) {
|
|
71
|
+
try {
|
|
72
|
+
const st = await fsp.stat(fullPath);
|
|
73
|
+
if (!st.isFile()) return null;
|
|
74
|
+
return st;
|
|
75
|
+
} catch {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export async function deleteTrace(fullPath) {
|
|
81
|
+
await fsp.unlink(fullPath);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function sweepOldTraces({ baseDir, ttlMs, maxBytesPerFile, now = Date.now() } = {}) {
|
|
85
|
+
const result = { scanned: 0, removedTtl: 0, removedOversized: 0, bytes: 0 };
|
|
86
|
+
if (!baseDir) return result;
|
|
87
|
+
|
|
88
|
+
let userDirs;
|
|
89
|
+
try {
|
|
90
|
+
userDirs = fs.readdirSync(baseDir);
|
|
91
|
+
} catch {
|
|
92
|
+
return result;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
for (const userDir of userDirs) {
|
|
96
|
+
const dir = path.join(baseDir, userDir);
|
|
97
|
+
let st;
|
|
98
|
+
try {
|
|
99
|
+
st = fs.statSync(dir);
|
|
100
|
+
} catch {
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
if (!st.isDirectory()) continue;
|
|
104
|
+
|
|
105
|
+
let files;
|
|
106
|
+
try {
|
|
107
|
+
files = fs.readdirSync(dir);
|
|
108
|
+
} catch {
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
for (const name of files) {
|
|
113
|
+
if (!name.endsWith('.zip')) continue;
|
|
114
|
+
result.scanned++;
|
|
115
|
+
const full = path.join(dir, name);
|
|
116
|
+
try {
|
|
117
|
+
const fst = fs.statSync(full);
|
|
118
|
+
if (!fst.isFile()) continue;
|
|
119
|
+
const tooOld = ttlMs && (now - fst.mtimeMs) > ttlMs;
|
|
120
|
+
const tooBig = maxBytesPerFile && fst.size > maxBytesPerFile;
|
|
121
|
+
if (tooOld) {
|
|
122
|
+
fs.unlinkSync(full);
|
|
123
|
+
result.removedTtl++;
|
|
124
|
+
result.bytes += fst.size;
|
|
125
|
+
} else if (tooBig) {
|
|
126
|
+
fs.unlinkSync(full);
|
|
127
|
+
result.removedOversized++;
|
|
128
|
+
result.bytes += fst.size;
|
|
129
|
+
}
|
|
130
|
+
} catch {
|
|
131
|
+
// vanished or permission denied
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return result;
|
|
137
|
+
}
|
package/openclaw.plugin.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@askjo/camofox-browser",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.7.0",
|
|
4
4
|
"description": "Headless browser automation server and OpenClaw plugin for AI agents - anti-detection, element refs, and session isolation",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "server.js",
|
|
@@ -37,6 +37,8 @@
|
|
|
37
37
|
"files": [
|
|
38
38
|
"server.js",
|
|
39
39
|
"lib/",
|
|
40
|
+
"plugins/",
|
|
41
|
+
"camofox.config.json",
|
|
40
42
|
"plugin.ts",
|
|
41
43
|
"openclaw.plugin.json",
|
|
42
44
|
"scripts/",
|
|
@@ -54,8 +56,11 @@
|
|
|
54
56
|
"start": "node server.js",
|
|
55
57
|
"test": "NODE_OPTIONS='--experimental-vm-modules' jest --runInBand --forceExit",
|
|
56
58
|
"test:e2e": "NODE_OPTIONS='--experimental-vm-modules' jest --runInBand --forceExit tests/e2e",
|
|
59
|
+
"test:plugins": "NODE_OPTIONS='--experimental-vm-modules' jest --forceExit plugins/",
|
|
57
60
|
"test:live": "RUN_LIVE_TESTS=1 NODE_OPTIONS='--experimental-vm-modules' jest --runInBand --forceExit tests/live",
|
|
58
61
|
"test:debug": "DEBUG_SERVER=1 NODE_OPTIONS='--experimental-vm-modules' jest --runInBand --forceExit",
|
|
62
|
+
"plugin": "node scripts/plugin.js",
|
|
63
|
+
"generate-openapi": "node scripts/generate-openapi.js",
|
|
59
64
|
"version:sync": "node scripts/sync-version.js",
|
|
60
65
|
"version": "node scripts/sync-version.js && git add openclaw.plugin.json",
|
|
61
66
|
"postinstall": "npx camoufox-js fetch || true"
|
|
@@ -67,7 +72,8 @@
|
|
|
67
72
|
"playwright-core": "^1.58.0",
|
|
68
73
|
"playwright-extra": "^4.3.6",
|
|
69
74
|
"prom-client": "^15.1.3",
|
|
70
|
-
"puppeteer-extra-plugin-stealth": "^2.11.2"
|
|
75
|
+
"puppeteer-extra-plugin-stealth": "^2.11.2",
|
|
76
|
+
"swagger-jsdoc": "^6.2.8"
|
|
71
77
|
},
|
|
72
78
|
"devDependencies": {
|
|
73
79
|
"jest": "^29.7.0",
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# Persistence Plugin — Agent Guide
|
|
2
|
+
|
|
3
|
+
Saves and restores per-user browser storage state (cookies + localStorage) across session restarts using Playwright's `storageState` API. Enabled by default — profiles persist to `~/.camofox/profiles/`.
|
|
4
|
+
|
|
5
|
+
## How It Works
|
|
6
|
+
|
|
7
|
+
- `session:creating` hook → loads saved `storage_state.json` into `contextOptions.storageState`
|
|
8
|
+
- `session:created` hook → imports bootstrap cookies if no persisted state exists
|
|
9
|
+
- `session:cookies:import` / `session:destroyed` / `server:shutdown` → checkpoints state to disk
|
|
10
|
+
|
|
11
|
+
All hooks are async and awaited via `emitAsync()` — storage state is guaranteed loaded before the context is created.
|
|
12
|
+
|
|
13
|
+
## Key Files
|
|
14
|
+
|
|
15
|
+
- `index.js` — lifecycle hooks (no routes, no `child_process`)
|
|
16
|
+
- `persistence.test.js` — unit tests for `lib/persistence.js` helpers
|
|
17
|
+
- `plugin.test.js` — integration tests for plugin lifecycle hooks
|
|
18
|
+
|
|
19
|
+
## Storage Layout
|
|
20
|
+
|
|
21
|
+
```
|
|
22
|
+
~/.camofox/profiles/
|
|
23
|
+
└── <sha256(userId)>/
|
|
24
|
+
└── storage_state.json
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Configuration
|
|
28
|
+
|
|
29
|
+
Enabled by default. Override profile directory with `CAMOFOX_PROFILE_DIR` env var or `"profileDir"` in plugin config. To disable: `"persistence": { "enabled": false }` in `camofox.config.json`.
|
|
30
|
+
|
|
31
|
+
## Original Contributors
|
|
32
|
+
|
|
33
|
+
- [@company8](https://github.com/company8) — original persistence concept ([PR #62](https://github.com/jo-inc/camofox-browser/pull/62))
|
|
34
|
+
- [@eddieoz](https://github.com/eddieoz) — cookie auto-load on startup ([PR #55](https://github.com/jo-inc/camofox-browser/pull/55))
|
|
35
|
+
- [@pradeepe](https://github.com/pradeepe) — plugin system integration, atomic writes, inflight coalescing
|
|
36
|
+
|
|
37
|
+
For PRs touching this plugin, tag the contributors above for review.
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# persistence
|
|
2
|
+
|
|
3
|
+
Optional per-user browser storage state persistence for camofox-browser.
|
|
4
|
+
|
|
5
|
+
Saves and restores cookies + localStorage across session restarts, container deploys, and idle timeouts using Playwright's `storageState` API.
|
|
6
|
+
|
|
7
|
+
## Configuration
|
|
8
|
+
|
|
9
|
+
In `camofox.config.json`:
|
|
10
|
+
|
|
11
|
+
```json
|
|
12
|
+
{
|
|
13
|
+
"plugins": {
|
|
14
|
+
"persistence": {
|
|
15
|
+
"enabled": true,
|
|
16
|
+
"profileDir": "/data/profiles"
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Or override via environment variable:
|
|
23
|
+
|
|
24
|
+
```
|
|
25
|
+
CAMOFOX_PROFILE_DIR=/data/profiles
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## How it works
|
|
29
|
+
|
|
30
|
+
- **Session create**: If a persisted `storageState` exists for the `userId`, it's restored into the new Playwright context.
|
|
31
|
+
- **First run**: If no persisted state exists, bootstrap cookies from `CAMOFOX_COOKIES_DIR/cookies.txt` are imported (if present).
|
|
32
|
+
- **Cookie import / session close / shutdown**: Storage state is checkpointed to disk via atomic tmp-write + rename.
|
|
33
|
+
- **User isolation**: Each `userId` maps to a deterministic SHA256-hashed subdirectory under `profileDir`, so arbitrary userIds are path-safe.
|
|
34
|
+
|
|
35
|
+
## Docker
|
|
36
|
+
|
|
37
|
+
When running with Docker, mount the profile directory as a volume:
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
docker run -d \
|
|
41
|
+
-p 9377:9377 \
|
|
42
|
+
-v /host/profiles:/data/profiles \
|
|
43
|
+
camofox-browser
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Credits
|
|
47
|
+
|
|
48
|
+
Based on PR #62 by [company8](https://github.com/company8).
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Persistence plugin for camofox-browser.
|
|
3
|
+
*
|
|
4
|
+
* Saves and restores per-user browser storage state (cookies + localStorage)
|
|
5
|
+
* across session restarts using Playwright's storageState API.
|
|
6
|
+
*
|
|
7
|
+
* Configuration (camofox.config.json):
|
|
8
|
+
* {
|
|
9
|
+
* "plugins": {
|
|
10
|
+
* "persistence": {
|
|
11
|
+
* "enabled": true,
|
|
12
|
+
* "profileDir": "/data/profiles"
|
|
13
|
+
* }
|
|
14
|
+
* }
|
|
15
|
+
* }
|
|
16
|
+
*
|
|
17
|
+
* Or via environment variables (overrides config file):
|
|
18
|
+
* CAMOFOX_PROFILE_DIR=/data/profiles
|
|
19
|
+
*
|
|
20
|
+
* Each userId gets a deterministic SHA256-hashed subdirectory under profileDir.
|
|
21
|
+
* Storage state is checkpointed on cookie import, session close, and shutdown.
|
|
22
|
+
* On session creation, saved state is restored into the new Playwright context
|
|
23
|
+
* via the session:creating hook (mutates contextOptions.storageState).
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import {
|
|
27
|
+
getUserPersistencePaths,
|
|
28
|
+
loadPersistedStorageState,
|
|
29
|
+
persistStorageState,
|
|
30
|
+
} from '../../lib/persistence.js';
|
|
31
|
+
import { importBootstrapCookies } from '../../lib/cookies.js';
|
|
32
|
+
|
|
33
|
+
export async function register(app, ctx, pluginConfig = {}) {
|
|
34
|
+
const { events, config, log } = ctx;
|
|
35
|
+
|
|
36
|
+
// Resolve profileDir: env var > plugin config > global config default (~/.camofox/profiles)
|
|
37
|
+
const profileDir = process.env.CAMOFOX_PROFILE_DIR || pluginConfig.profileDir || config.profileDir;
|
|
38
|
+
if (!profileDir) {
|
|
39
|
+
log('warn', 'persistence plugin: no profileDir configured, plugin disabled');
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const logger = {
|
|
44
|
+
warn: (msg, fields = {}) => log('warn', msg, fields),
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
log('info', 'persistence plugin enabled', { profileDir });
|
|
48
|
+
|
|
49
|
+
// Track active sessions for checkpoint on close
|
|
50
|
+
const activeSessions = new Map(); // userId -> context
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Checkpoint storage state to disk for a userId.
|
|
54
|
+
*/
|
|
55
|
+
async function checkpoint(userId, context, reason) {
|
|
56
|
+
if (!context) return;
|
|
57
|
+
const result = await persistStorageState({ profileDir, userId, context, logger });
|
|
58
|
+
if (result.persisted) {
|
|
59
|
+
log('info', 'storage state persisted', { userId, reason, path: result.storageStatePath });
|
|
60
|
+
}
|
|
61
|
+
return result;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// --- Lifecycle hooks ---
|
|
65
|
+
|
|
66
|
+
// Before session context is created: inject storageState if we have one saved
|
|
67
|
+
events.on('session:creating', async ({ userId, contextOptions }) => {
|
|
68
|
+
const storageStatePath = await loadPersistedStorageState(profileDir, userId, logger);
|
|
69
|
+
if (storageStatePath) {
|
|
70
|
+
contextOptions.storageState = storageStatePath;
|
|
71
|
+
log('info', 'restoring persisted storage state', { userId, storageStatePath });
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// After session is created: import bootstrap cookies if no persisted state,
|
|
76
|
+
// and track the context for later checkpointing
|
|
77
|
+
events.on('session:created', async ({ userId, context }) => {
|
|
78
|
+
activeSessions.set(userId, context);
|
|
79
|
+
|
|
80
|
+
// If no persisted state was restored, try bootstrap cookies
|
|
81
|
+
const existingState = await loadPersistedStorageState(profileDir, userId, logger);
|
|
82
|
+
if (!existingState) {
|
|
83
|
+
const result = await importBootstrapCookies({
|
|
84
|
+
cookiesDir: config.cookiesDir,
|
|
85
|
+
context,
|
|
86
|
+
logger,
|
|
87
|
+
});
|
|
88
|
+
if (result.imported > 0) {
|
|
89
|
+
log('info', 'bootstrap cookies imported', { userId, count: result.imported, source: result.source });
|
|
90
|
+
await checkpoint(userId, context, 'bootstrap_cookies');
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// On cookie import: checkpoint
|
|
96
|
+
events.on('session:cookies:import', async ({ userId }) => {
|
|
97
|
+
const context = activeSessions.get(userId);
|
|
98
|
+
if (context) {
|
|
99
|
+
await checkpoint(userId, context, 'cookie_import');
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
// On session destroying (pre-close): checkpoint while context is still alive
|
|
104
|
+
events.on('session:destroying', async ({ userId, reason }) => {
|
|
105
|
+
const context = activeSessions.get(userId);
|
|
106
|
+
if (context) {
|
|
107
|
+
await checkpoint(userId, context, reason).catch(() => {});
|
|
108
|
+
activeSessions.delete(userId);
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// On session destroyed (post-close): cleanup tracking if not already done
|
|
113
|
+
events.on('session:destroyed', async ({ userId }) => {
|
|
114
|
+
activeSessions.delete(userId);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// On shutdown: checkpoint all remaining sessions
|
|
118
|
+
events.on('server:shutdown', async () => {
|
|
119
|
+
for (const [userId, context] of activeSessions) {
|
|
120
|
+
await checkpoint(userId, context, 'shutdown').catch(() => {});
|
|
121
|
+
}
|
|
122
|
+
activeSessions.clear();
|
|
123
|
+
});
|
|
124
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { jest } from '@jest/globals';
|
|
5
|
+
import {
|
|
6
|
+
getUserPersistencePaths,
|
|
7
|
+
loadPersistedStorageState,
|
|
8
|
+
persistStorageState,
|
|
9
|
+
} from '../../lib/persistence.js';
|
|
10
|
+
|
|
11
|
+
describe('profile persistence helpers', () => {
|
|
12
|
+
let tmpDir;
|
|
13
|
+
|
|
14
|
+
beforeEach(async () => {
|
|
15
|
+
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'camofox-persistence-'));
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
afterEach(async () => {
|
|
19
|
+
if (tmpDir) {
|
|
20
|
+
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test('getUserPersistencePaths is deterministic and stays under root', () => {
|
|
25
|
+
const first = getUserPersistencePaths(tmpDir, 'agent/profile:default');
|
|
26
|
+
const second = getUserPersistencePaths(tmpDir, 'agent/profile:default');
|
|
27
|
+
|
|
28
|
+
expect(first).toEqual(second);
|
|
29
|
+
expect(first.userDir.startsWith(tmpDir)).toBe(true);
|
|
30
|
+
expect(first.storageStatePath.startsWith(first.userDir)).toBe(true);
|
|
31
|
+
expect(first.metaPath.startsWith(first.userDir)).toBe(true);
|
|
32
|
+
expect(path.basename(first.userDir)).not.toContain('/');
|
|
33
|
+
expect(path.basename(first.userDir)).not.toContain(':');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('loadPersistedStorageState returns undefined when no state exists', async () => {
|
|
37
|
+
await expect(loadPersistedStorageState(tmpDir, 'user-1')).resolves.toBeUndefined();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('persistStorageState writes storage state and metadata, then load returns the storage path', async () => {
|
|
41
|
+
const storageState = {
|
|
42
|
+
cookies: [{ name: 'session', value: 'abc', domain: '.example.com', path: '/' }],
|
|
43
|
+
origins: [{ origin: 'https://app.example.com', localStorage: [{ name: 'foo', value: 'bar' }] }],
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const context = {
|
|
47
|
+
storageState: jest.fn(async ({ path: targetPath }) => {
|
|
48
|
+
await fs.writeFile(targetPath, JSON.stringify(storageState, null, 2));
|
|
49
|
+
}),
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const result = await persistStorageState({
|
|
53
|
+
profileDir: tmpDir,
|
|
54
|
+
userId: 'user-1',
|
|
55
|
+
context,
|
|
56
|
+
logger: { warn: jest.fn() },
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
expect(result.persisted).toBe(true);
|
|
60
|
+
expect(context.storageState).toHaveBeenCalledTimes(1);
|
|
61
|
+
|
|
62
|
+
const loadedPath = await loadPersistedStorageState(tmpDir, 'user-1');
|
|
63
|
+
expect(loadedPath).toBe(result.storageStatePath);
|
|
64
|
+
|
|
65
|
+
const meta = JSON.parse(await fs.readFile(result.metaPath, 'utf8'));
|
|
66
|
+
expect(meta.userId).toBe('user-1');
|
|
67
|
+
expect(meta.storageStatePath).toBe(result.storageStatePath);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('loadPersistedStorageState ignores invalid JSON files', async () => {
|
|
71
|
+
const { storageStatePath } = getUserPersistencePaths(tmpDir, 'user-2');
|
|
72
|
+
await fs.mkdir(path.dirname(storageStatePath), { recursive: true });
|
|
73
|
+
await fs.writeFile(storageStatePath, '{not-json');
|
|
74
|
+
|
|
75
|
+
await expect(loadPersistedStorageState(tmpDir, 'user-2', { warn: jest.fn() })).resolves.toBeUndefined();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('a failed persist leaves the previous storage-state intact and cleans up tmp files', async () => {
|
|
79
|
+
const originalState = {
|
|
80
|
+
cookies: [{ name: 'orig', value: 'v1', domain: '.example.com', path: '/' }],
|
|
81
|
+
};
|
|
82
|
+
const goodContext = {
|
|
83
|
+
storageState: jest.fn(async ({ path: targetPath }) => {
|
|
84
|
+
await fs.writeFile(targetPath, JSON.stringify(originalState, null, 2));
|
|
85
|
+
}),
|
|
86
|
+
};
|
|
87
|
+
const first = await persistStorageState({
|
|
88
|
+
profileDir: tmpDir,
|
|
89
|
+
userId: 'user-3',
|
|
90
|
+
context: goodContext,
|
|
91
|
+
logger: { warn: jest.fn() },
|
|
92
|
+
});
|
|
93
|
+
expect(first.persisted).toBe(true);
|
|
94
|
+
|
|
95
|
+
const failingContext = {
|
|
96
|
+
storageState: jest.fn(async () => {
|
|
97
|
+
throw new Error('simulated crash mid-write');
|
|
98
|
+
}),
|
|
99
|
+
};
|
|
100
|
+
const second = await persistStorageState({
|
|
101
|
+
profileDir: tmpDir,
|
|
102
|
+
userId: 'user-3',
|
|
103
|
+
context: failingContext,
|
|
104
|
+
logger: { warn: jest.fn() },
|
|
105
|
+
});
|
|
106
|
+
expect(second.persisted).toBe(false);
|
|
107
|
+
|
|
108
|
+
const { userDir, storageStatePath } = getUserPersistencePaths(tmpDir, 'user-3');
|
|
109
|
+
const loaded = await loadPersistedStorageState(tmpDir, 'user-3');
|
|
110
|
+
expect(loaded).toBe(storageStatePath);
|
|
111
|
+
const parsed = JSON.parse(await fs.readFile(storageStatePath, 'utf8'));
|
|
112
|
+
expect(parsed).toEqual(originalState);
|
|
113
|
+
|
|
114
|
+
const leftovers = (await fs.readdir(userDir)).filter((name) => name.includes('.tmp-'));
|
|
115
|
+
expect(leftovers).toEqual([]);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { jest } from '@jest/globals';
|
|
5
|
+
import { createPluginEvents } from '../../lib/plugins.js';
|
|
6
|
+
import { register } from './index.js';
|
|
7
|
+
|
|
8
|
+
describe('persistence plugin', () => {
|
|
9
|
+
let tmpDir, events, ctx, mockApp;
|
|
10
|
+
|
|
11
|
+
beforeEach(async () => {
|
|
12
|
+
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'camofox-persist-plugin-'));
|
|
13
|
+
events = createPluginEvents();
|
|
14
|
+
mockApp = {};
|
|
15
|
+
ctx = {
|
|
16
|
+
events,
|
|
17
|
+
config: { cookiesDir: path.join(tmpDir, 'cookies') },
|
|
18
|
+
log: jest.fn(),
|
|
19
|
+
};
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
afterEach(async () => {
|
|
23
|
+
if (tmpDir) await fs.rm(tmpDir, { recursive: true, force: true });
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('skips registration when no profileDir configured', async () => {
|
|
27
|
+
await register(mockApp, ctx, {});
|
|
28
|
+
expect(ctx.log).toHaveBeenCalledWith('warn', expect.stringContaining('no profileDir'));
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('restores persisted state on session:creating', async () => {
|
|
32
|
+
await register(mockApp, ctx, { profileDir: tmpDir });
|
|
33
|
+
|
|
34
|
+
// Simulate a prior persisted state
|
|
35
|
+
const { getUserPersistencePaths } = await import('../../lib/persistence.js');
|
|
36
|
+
const { userDir, storageStatePath } = getUserPersistencePaths(tmpDir, 'user-1');
|
|
37
|
+
await fs.mkdir(userDir, { recursive: true });
|
|
38
|
+
await fs.writeFile(storageStatePath, JSON.stringify({
|
|
39
|
+
cookies: [{ name: 'sid', value: 'abc', domain: '.example.com', path: '/' }],
|
|
40
|
+
origins: [],
|
|
41
|
+
}));
|
|
42
|
+
|
|
43
|
+
const contextOptions = { viewport: { width: 1280, height: 720 } };
|
|
44
|
+
await events.emitAsync('session:creating', { userId: 'user-1', contextOptions });
|
|
45
|
+
|
|
46
|
+
expect(contextOptions.storageState).toBe(storageStatePath);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('checkpoints on session:cookies:import', async () => {
|
|
50
|
+
await register(mockApp, ctx, { profileDir: tmpDir });
|
|
51
|
+
|
|
52
|
+
const mockContext = {
|
|
53
|
+
storageState: jest.fn(async ({ path: p }) => {
|
|
54
|
+
await fs.writeFile(p, JSON.stringify({ cookies: [{ name: 'x', value: 'y', domain: '.test.com', path: '/' }] }));
|
|
55
|
+
}),
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
// Simulate session created then cookie import
|
|
59
|
+
await events.emitAsync('session:created', { userId: 'user-2', context: mockContext });
|
|
60
|
+
await events.emitAsync('session:cookies:import', { userId: 'user-2' });
|
|
61
|
+
|
|
62
|
+
expect(mockContext.storageState).toHaveBeenCalled();
|
|
63
|
+
|
|
64
|
+
// Verify file was written
|
|
65
|
+
const { getUserPersistencePaths } = await import('../../lib/persistence.js');
|
|
66
|
+
const { storageStatePath } = getUserPersistencePaths(tmpDir, 'user-2');
|
|
67
|
+
const saved = JSON.parse(await fs.readFile(storageStatePath, 'utf8'));
|
|
68
|
+
expect(saved.cookies[0].name).toBe('x');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('checkpoints on session:destroying', async () => {
|
|
72
|
+
await register(mockApp, ctx, { profileDir: tmpDir });
|
|
73
|
+
|
|
74
|
+
const mockContext = {
|
|
75
|
+
storageState: jest.fn(async ({ path: p }) => {
|
|
76
|
+
await fs.writeFile(p, JSON.stringify({ cookies: [], origins: [] }));
|
|
77
|
+
}),
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
await events.emitAsync('session:created', { userId: 'user-3', context: mockContext });
|
|
81
|
+
await events.emitAsync('session:destroying', { userId: 'user-3', reason: 'test' });
|
|
82
|
+
|
|
83
|
+
expect(mockContext.storageState).toHaveBeenCalled();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('env var CAMOFOX_PROFILE_DIR overrides pluginConfig', async () => {
|
|
87
|
+
const envDir = path.join(tmpDir, 'env-override');
|
|
88
|
+
const orig = process.env.CAMOFOX_PROFILE_DIR;
|
|
89
|
+
process.env.CAMOFOX_PROFILE_DIR = envDir;
|
|
90
|
+
try {
|
|
91
|
+
await register(mockApp, ctx, { profileDir: '/should/not/use' });
|
|
92
|
+
expect(ctx.log).toHaveBeenCalledWith('info', 'persistence plugin enabled', { profileDir: envDir });
|
|
93
|
+
} finally {
|
|
94
|
+
if (orig === undefined) delete process.env.CAMOFOX_PROFILE_DIR;
|
|
95
|
+
else process.env.CAMOFOX_PROFILE_DIR = orig;
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
});
|