webhelm 1.0.0 → 1.0.1
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 +9 -1
- package/package.json +1 -1
- package/runtime/admin.mjs +2 -2
- package/runtime/chatgpt/chatgpt-delivery-errors.mjs +1 -1
- package/runtime/chrome.mjs +4 -4
- package/runtime/cli.mjs +4 -1
- package/runtime/config-paths.mjs +14 -0
- package/runtime/migrate.mjs +47 -0
- package/runtime/node-daemon.mjs +5 -4
package/README.md
CHANGED
|
@@ -32,7 +32,7 @@ The Actions checkout is `~/.local/webhelm`, with site modules at `~/.local/webhe
|
|
|
32
32
|
|
|
33
33
|
## Architecture and compatibility
|
|
34
34
|
|
|
35
|
-
The Runtime
|
|
35
|
+
The Runtime stores its managed Chrome profile, persistent FIFO queue, and IPC socket under `~/.config/webhelm/`. Run `webhelm migrate` with the legacy daemon and Chrome stopped to migrate existing `~/.config/browser-workspace/` data without dropping logins or queued turns. The command leaves a compatibility symlink for legacy maintenance scripts. Only one daemon is permitted to own the queue. A CLI package update does not kill a running daemon or close another agent's tabs; upgrade daemons when idle.
|
|
36
36
|
|
|
37
37
|
NeoY and Relay can continue using `webhelm` session commands, `result.stdout` JSON receipts, and `admin_helper` returned by `status`. An `admin_helper` inside the installed Skill is a stable connector to the same Node IPC socket. `webhelm capacity` replaces the legacy Python capacity probe.
|
|
38
38
|
|
|
@@ -45,3 +45,11 @@ npm pack --dry-run
|
|
|
45
45
|
```
|
|
46
46
|
|
|
47
47
|
Keep platform-specific code and tests in `lalalic/webhelm-actions`. Runtime tests should use mock CDP or owned, temporary real Chrome tabs; protect other active Sessions. GitHub Actions runs Runtime/Extension CI. The Skill and Extension remain version-controlled together; npm publishing uses the standalone package at the repository root.
|
|
48
|
+
|
|
49
|
+
### Adding an unsupported platform
|
|
50
|
+
|
|
51
|
+
The WebHelm Skill includes [`skills/webhelm/NEW_PLATFORM.md`](skills/webhelm/NEW_PLATFORM.md). All new platform code belongs in **`~/.local/webhelm/platforms/<slug>`**, checked out from `lalalic/webhelm-actions`, including tests and docs. There is no separate `platforms/` directory in the Skill or npm package. Publishing an Action uses Git, not npm.
|
|
52
|
+
|
|
53
|
+
## npm publishing
|
|
54
|
+
|
|
55
|
+
`.github/workflows/publish.yml` publishes CLI/Runtime changes on `main` using GitHub Actions and npm OIDC trusted publishing. It reuses Agents Relay's bounded-test and publication-report Actions, as the other repositories do. The CI workflow never stores an npm write token or asks for a local 2FA approval. Configure the **webhelm** npm package's Trusted Publisher once: GitHub user `lalalic`, repository `webhelm`, workflow filename `publish.yml` (no environment), with **Allow npm publish** enabled. New configurations otherwise default to staged-only publishing and require individual approvals. A successful publish is verified against the npm registry and reported to Agents Relay. Skill-only and platform Action changes do not trigger npm publication.
|
package/package.json
CHANGED
package/runtime/admin.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import net from 'node:net';
|
|
2
|
-
import
|
|
2
|
+
import { RUNTIME_DIR } from './config-paths.mjs';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
-
const socketPath=()=>process.env.WEBHELM_SOCKET||path.join(process.env.WEBHELM_RUNTIME_DIR||
|
|
4
|
+
const socketPath=()=>process.env.WEBHELM_SOCKET||path.join(process.env.WEBHELM_RUNTIME_DIR||RUNTIME_DIR,'session-node.sock');
|
|
5
5
|
function request(payload){return new Promise((resolve,reject)=>{const s=net.createConnection(socketPath());let buf='';s.setEncoding('utf8');s.on('connect',()=>s.write(JSON.stringify(payload)+'\n'));s.on('data',d=>{buf+=d;if(!buf.includes('\n'))return;s.end();try{const v=JSON.parse(buf);v.error?reject(Error(v.error)):resolve(v)}catch(e){reject(e)}});s.on('error',reject)})}
|
|
6
6
|
export async function ensureWorkspace(name,size=5){if(typeof name!=='string'||!name.trim())throw TypeError('workspace name must be a non-empty string');if(!Number.isInteger(size)||size<1)throw TypeError('workspace size must be a positive integer');return request({op:'workspace-create',name:name.trim(),pool_size:size})}
|
|
7
7
|
export async function deleteWorkspace(name,{force=false}={}){if(typeof name!=='string'||!name.trim())throw TypeError('workspace name must be a non-empty string');return request({op:'workspace-delete',name:name.trim(),force:Boolean(force)})}
|
package/runtime/chrome.mjs
CHANGED
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
-
import os from 'node:os';
|
|
4
3
|
import net from 'node:net';
|
|
5
4
|
import { spawn, execFileSync } from 'node:child_process';
|
|
6
5
|
|
|
7
|
-
|
|
8
|
-
const ROOT =
|
|
9
|
-
const PROFILE =
|
|
6
|
+
import { CONFIG_ROOT, PROFILE_DIR, requireMigratedConfig } from './config-paths.mjs';
|
|
7
|
+
const ROOT = CONFIG_ROOT;
|
|
8
|
+
const PROFILE = PROFILE_DIR;
|
|
10
9
|
const STATE = path.join(ROOT, 'managed-chrome.json');
|
|
11
10
|
const LOCK = path.join(ROOT, 'managed-chrome-node.lock');
|
|
12
11
|
const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
|
|
@@ -95,6 +94,7 @@ export async function ensureManagedChrome(){
|
|
|
95
94
|
// Explicit CDP endpoints also support hermetic mock-browser tests.
|
|
96
95
|
const specified=process.env.WEBHELM_CDP_URL;
|
|
97
96
|
if (specified) return {mode:'override',reused:true,endpoint:specified,profile_dir:PROFILE};
|
|
97
|
+
requireMigratedConfig();
|
|
98
98
|
return lockOwnership(async()=>{
|
|
99
99
|
const existing=await fromState()||await discover();
|
|
100
100
|
if(existing)return {...existing, mode:'managed',reused:true,endpoint:`http://127.0.0.1:${existing.port}`};
|
package/runtime/cli.mjs
CHANGED
|
@@ -7,6 +7,7 @@ import { randomUUID } from 'node:crypto';
|
|
|
7
7
|
import { ensureDaemon, request, handle, SOCKET } from './node-daemon.mjs';
|
|
8
8
|
import { PLATFORMS, listPlatforms, platformProfile, actionPath, validateConfig } from './platforms.mjs';
|
|
9
9
|
import { ACTIONS_DIR, ensureActions, syncActions, publishActions } from './actions-repo.mjs';
|
|
10
|
+
import { migrateConfig } from './migrate.mjs';
|
|
10
11
|
|
|
11
12
|
const ROOT = path.dirname(fileURLToPath(import.meta.url));
|
|
12
13
|
const emit = value => console.log(JSON.stringify(value));
|
|
@@ -20,6 +21,7 @@ function help() {
|
|
|
20
21
|
console.log(`webhelm — browser automation through one Node.js runtime
|
|
21
22
|
|
|
22
23
|
Usage:
|
|
24
|
+
webhelm migrate # after stopping Chrome and the old WebHelm daemon
|
|
23
25
|
webhelm setup
|
|
24
26
|
webhelm status
|
|
25
27
|
webhelm capacity [--workspace NAME]
|
|
@@ -96,6 +98,7 @@ async function runPlatform(args) {
|
|
|
96
98
|
async function main(args = process.argv.slice(2)) {
|
|
97
99
|
const [group, command] = args;
|
|
98
100
|
if (!group || ['--help', '-h', 'help'].includes(group) || has(args, '--help') || has(args, '-h')) return help();
|
|
101
|
+
if (group === 'migrate') return emit(await migrateConfig());
|
|
99
102
|
if (group === 'setup') {
|
|
100
103
|
const repo=await ensureActions();
|
|
101
104
|
const chrome=await ensureDaemon();
|
|
@@ -103,7 +106,7 @@ async function main(args = process.argv.slice(2)) {
|
|
|
103
106
|
}
|
|
104
107
|
if (group === 'status') {
|
|
105
108
|
const daemon = await ensureDaemon();
|
|
106
|
-
return emit({version:'1.0.
|
|
109
|
+
return emit({version:'1.0.1', platform_dir:PLATFORMS, root:ROOT,
|
|
107
110
|
cli:fileURLToPath(import.meta.url),
|
|
108
111
|
admin_helper:process.env.WEBHELM_ADMIN_HELPER || (fs.existsSync(path.join(os.homedir(),'.agents/skills/webhelm/scripts/admin.mjs')) ? path.join(os.homedir(),'.agents/skills/webhelm/scripts/admin.mjs') : path.join(ROOT,'admin.mjs')),
|
|
109
112
|
managed_chrome:daemon.managed_chrome,
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import os from 'node:os';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
|
|
5
|
+
export const CONFIG_ROOT = path.join(os.homedir(), '.config', 'webhelm');
|
|
6
|
+
export const LEGACY_ROOT = path.join(os.homedir(), '.config', 'browser-workspace');
|
|
7
|
+
export const PROFILE_DIR = path.join(CONFIG_ROOT, 'chrome');
|
|
8
|
+
export const RUNTIME_DIR = path.join(CONFIG_ROOT, 'runtime');
|
|
9
|
+
|
|
10
|
+
// Avoid starting a second FIFO dispatcher or empty Chrome profile against legacy data.
|
|
11
|
+
export function requireMigratedConfig() {
|
|
12
|
+
if (fs.existsSync(path.join(LEGACY_ROOT, 'chrome')) && !fs.existsSync(PROFILE_DIR))
|
|
13
|
+
throw Error(`Legacy Chrome profile exists at ${LEGACY_ROOT}. Stop old Chrome and WebHelm daemon, then run webhelm migrate.`);
|
|
14
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { execFileSync } from 'node:child_process';
|
|
4
|
+
import net from 'node:net';
|
|
5
|
+
import { CONFIG_ROOT, LEGACY_ROOT } from './config-paths.mjs';
|
|
6
|
+
|
|
7
|
+
function liveSocket(socketPath) {
|
|
8
|
+
return new Promise(resolve => {
|
|
9
|
+
const socket = net.createConnection(socketPath);
|
|
10
|
+
const finish = result => { socket.destroy(); resolve(result); };
|
|
11
|
+
socket.once('connect', () => finish(true));
|
|
12
|
+
socket.once('error', () => finish(false));
|
|
13
|
+
socket.setTimeout(1200, () => finish(true)); // Fail closed if probing hangs.
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function migrateConfig({ legacyRoot = LEGACY_ROOT, configRoot = CONFIG_ROOT,
|
|
18
|
+
processList = () => execFileSync('ps', ['-axo', 'command='], { encoding: 'utf8' }),
|
|
19
|
+
socketCheck = liveSocket } = {}) {
|
|
20
|
+
const legacy = path.resolve(legacyRoot);
|
|
21
|
+
const target = path.resolve(configRoot);
|
|
22
|
+
if (legacy === target) throw Error('Legacy and new config paths are identical');
|
|
23
|
+
let oldStat;
|
|
24
|
+
try { oldStat = fs.lstatSync(legacy); }
|
|
25
|
+
catch (e) { if (e.code === 'ENOENT') return { status: 'not_needed', config_root: target }; throw e; }
|
|
26
|
+
if (oldStat.isSymbolicLink()) {
|
|
27
|
+
if (fs.existsSync(target) && fs.realpathSync(legacy) === fs.realpathSync(target))
|
|
28
|
+
return { status: 'already_migrated', config_root: target, compatibility_symlink: legacy };
|
|
29
|
+
throw Error(`Legacy path is a different symlink: ${legacy}`);
|
|
30
|
+
}
|
|
31
|
+
if (!oldStat.isDirectory()) throw Error(`Legacy path is not a directory: ${legacy}`);
|
|
32
|
+
if (fs.existsSync(target)) throw Error(`Target already exists; refusing to overwrite: ${target}`);
|
|
33
|
+
const running = processList().split('\n').filter(line =>
|
|
34
|
+
line.includes(`--user-data-dir=${path.join(legacy, 'chrome')}`) ||
|
|
35
|
+
(line.includes('node-daemon.mjs daemon') && !line.includes('migrate.mjs')));
|
|
36
|
+
if (running.length) throw Error('Chrome or WebHelm daemon is still running; stop Sessions, daemon and Chrome before migration');
|
|
37
|
+
const socket = path.join(legacy, 'runtime', 'session-node.sock');
|
|
38
|
+
if (fs.existsSync(socket) && await socketCheck(socket))
|
|
39
|
+
throw Error('Legacy daemon socket is active; refusing migration');
|
|
40
|
+
fs.renameSync(legacy, target); // Same-volume atomic directory move; preserve Chrome data and FIFO WAL.
|
|
41
|
+
try { fs.symlinkSync(target, legacy, 'dir'); }
|
|
42
|
+
catch (error) {
|
|
43
|
+
fs.renameSync(target, legacy);
|
|
44
|
+
throw error;
|
|
45
|
+
}
|
|
46
|
+
return { status: 'migrated', config_root: target, compatibility_symlink: legacy };
|
|
47
|
+
}
|
package/runtime/node-daemon.mjs
CHANGED
|
@@ -9,11 +9,12 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
|
9
9
|
import { invokePlatformAction } from './platforms.mjs';
|
|
10
10
|
import { ChatGPTQueue } from './chatgpt/chatgpt-queue.mjs';
|
|
11
11
|
import { ensureManagedChrome } from './chrome.mjs';
|
|
12
|
+
import { PROFILE_DIR, RUNTIME_DIR, requireMigratedConfig } from './config-paths.mjs';
|
|
12
13
|
|
|
13
14
|
const ROOT = path.dirname(new URL(import.meta.url).pathname);
|
|
14
15
|
const HOME = os.homedir();
|
|
15
|
-
export
|
|
16
|
-
const DEFAULT_RUNTIME =
|
|
16
|
+
export { PROFILE_DIR };
|
|
17
|
+
const DEFAULT_RUNTIME = RUNTIME_DIR;
|
|
17
18
|
const RUNTIME = process.env.WEBHELM_RUNTIME_DIR || DEFAULT_RUNTIME;
|
|
18
19
|
const SOCKET = process.env.WEBHELM_SOCKET || path.join(RUNTIME, 'session-node.sock');
|
|
19
20
|
const LOCK = process.env.WEBHELM_LOCK || path.join(RUNTIME, 'session-node.lock');
|
|
@@ -450,7 +451,7 @@ async function runPlatform(req) {
|
|
|
450
451
|
self_heal_capable:!auto,result};
|
|
451
452
|
}
|
|
452
453
|
|
|
453
|
-
export async function handle(req){switch(req.op){case'ping':return {ok:true,pid:process.pid,version:'1.0.
|
|
454
|
+
export async function handle(req){switch(req.op){case'ping':return {ok:true,pid:process.pid,version:'1.0.1',queue_paused:process.env.WEBHELM_CHATGPT_QUEUE_PAUSED==='1',session_count:sessions.size,managed_chrome:managedChrome||{profile_dir:PROFILE_DIR, endpoint:process.env.WEBHELM_CDP_URL||'http://127.0.0.1:9222',reused:true}};case'list':return {session_count:sessions.size,sessions:[...sessions.values()].map(s=>({session_id:s.session_id,workspace:s.workspace,target_id:s.targetId,last_used_at:s.last_used_at}))};case'start':return createSession(req);case'exec':return execute(req);case'stop':return stopSession(req.session_id);case'stop-all':{const stopped=[];for(const id of [...sessions.keys()])stopped.push(await stopSession(id));return {stopped_count:stopped.length,error_count:0,stopped,errors:[]}}case'capacity':return workspaceCapacity(req);case'workspace-create':case'workspace-delete':return workspaceOp(req);case'chatgpt-new-turn-enqueue':return getChatGPTQueue().enqueue(req.config);case'platform-run':return runPlatform(req);default:throw Error(`unknown op: ${req.op}`)}}
|
|
454
455
|
function serve(conn) {
|
|
455
456
|
let buffer = '';
|
|
456
457
|
conn.setEncoding('utf8');
|
|
@@ -470,6 +471,6 @@ function serve(conn) {
|
|
|
470
471
|
|
|
471
472
|
async function main(){fs.mkdirSync(RUNTIME,{recursive:true,mode:0o700});let lockfd;try{lockfd=fs.openSync(LOCK,'wx',0o600);fs.writeFileSync(lockfd,String(process.pid))}catch{throw Error(`Node pilot lock exists: ${LOCK}`)}if(fs.existsSync(SOCKET)){try{const c=net.createConnection(SOCKET);await new Promise((res,rej)=>{c.once('connect',res);c.once('error',rej)});c.destroy();fs.closeSync(lockfd);fs.unlinkSync(LOCK);throw Error(`Node daemon socket already active: ${SOCKET}`)}catch(e){if(e.message.includes('already active'))throw e;fs.unlinkSync(SOCKET)}}socket=net.createServer(serve);await new Promise((res,rej)=>socket.listen(SOCKET,res).once('error',rej));fs.chmodSync(SOCKET,0o600);connectBrowser().catch(()=>{});getChatGPTQueue();const cleanup=async()=>{if(shuttingDown)return;shuttingDown=true;if(chatgptQueue)await chatgptQueue.shutdown().catch(()=>{});await handle({op:'stop-all'}).catch(()=>{});socket?.close();try{if(fs.existsSync(SOCKET))fs.unlinkSync(SOCKET)}catch{}try{fs.closeSync(lockfd);fs.unlinkSync(LOCK)}catch{}process.exit(0)};process.on('SIGTERM',cleanup);process.on('SIGINT',cleanup)}
|
|
472
473
|
async function request(payload,ensure=true){if(ensure)await ensureDaemon();return new Promise((resolve,reject)=>{const c=net.createConnection(SOCKET);let data='';c.setEncoding('utf8');c.on('connect',()=>c.write(JSON.stringify(payload)+'\n'));c.on('data',x=>{data+=x;if(!data.includes('\n'))return;c.end();try{const r=JSON.parse(data);r.error?reject(Error(r.error)):resolve(r)}catch(e){reject(e)}});c.on('error',reject)})}
|
|
473
|
-
export async function ensureDaemon(){try{const p=await request({op:'ping'},false);if(p.ok)return p}catch{}fs.mkdirSync(RUNTIME,{recursive:true,mode:0o700});if(fs.existsSync(LOCK)){let owner=0;try{owner=Number(fs.readFileSync(LOCK,'utf8').trim())}catch{};let alive=false;if(owner>0)try{process.kill(owner,0);alive=true}catch{};if(alive)throw Error(`daemon owner lock exists but socket is unreachable; refusing duplicate startup (${LOCK})`);fs.unlinkSync(LOCK)}const child=await import('node:child_process');const log=fs.openSync(path.join(RUNTIME,'session-node.log'),'a');child.spawn(process.execPath,[fileURLToPath(import.meta.url),'daemon'],{detached:true,stdio:['ignore',log,log],env:process.env}).unref();for(let i=0;i<100;i++){await delay(100);try{const p=await request({op:'ping'},false);if(p.ok)return p}catch{}}throw Error(`WebHelm Node daemon did not start; see ${path.join(RUNTIME,'session-node.log')}`)}
|
|
474
|
+
export async function ensureDaemon(){requireMigratedConfig();try{const p=await request({op:'ping'},false);if(p.ok)return p}catch{}fs.mkdirSync(RUNTIME,{recursive:true,mode:0o700});if(fs.existsSync(LOCK)){let owner=0;try{owner=Number(fs.readFileSync(LOCK,'utf8').trim())}catch{};let alive=false;if(owner>0)try{process.kill(owner,0);alive=true}catch{};if(alive)throw Error(`daemon owner lock exists but socket is unreachable; refusing duplicate startup (${LOCK})`);fs.unlinkSync(LOCK)}const child=await import('node:child_process');const log=fs.openSync(path.join(RUNTIME,'session-node.log'),'a');child.spawn(process.execPath,[fileURLToPath(import.meta.url),'daemon'],{detached:true,stdio:['ignore',log,log],env:process.env}).unref();for(let i=0;i<100;i++){await delay(100);try{const p=await request({op:'ping'},false);if(p.ok)return p}catch{}}throw Error(`WebHelm Node daemon did not start; see ${path.join(RUNTIME,'session-node.log')}`)}
|
|
474
475
|
if(process.argv[2]==='daemon')main().catch(e=>{console.error(e);process.exit(1)});
|
|
475
476
|
export {request,SOCKET,RUNTIME};
|