webhelm 1.0.5 → 1.0.6

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webhelm",
3
- "version": "1.0.5",
3
+ "version": "1.0.6",
4
4
  "description": "Agent browser automation in a visible managed Chrome with persistent JavaScript sessions",
5
5
  "type": "module",
6
6
  "bin": {
package/runtime/cli.mjs CHANGED
@@ -8,7 +8,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
10
  import { migrateConfig, migrateChromeProfile } from './migrate.mjs';
11
- import { installLoginGuard } from './login-guard.mjs';
11
+ import { installLoginGuard, stageRuntimeForLoginGuard } from './login-guard.mjs';
12
12
 
13
13
  const ROOT = path.dirname(fileURLToPath(import.meta.url));
14
14
  const emit = value => console.log(JSON.stringify(value));
@@ -25,6 +25,7 @@ Usage:
25
25
  webhelm migrate # after stopping Chrome and the old WebHelm daemon
26
26
  webhelm setup
27
27
  webhelm status
28
+ webhelm ensure # verify/recover managed Chrome and Harness, not just daemon ping
28
29
  webhelm capacity [--workspace NAME]
29
30
  webhelm session start [--url URL]
30
31
  webhelm session list
@@ -109,9 +110,11 @@ async function main(args = process.argv.slice(2)) {
109
110
  if (group === 'setup') {
110
111
  const repo=await ensureActions();
111
112
  const chrome=await ensureDaemon();
112
- const login_guard=installLoginGuard();
113
+ const persisted=stageRuntimeForLoginGuard();
114
+ const login_guard=installLoginGuard({cliPath:path.join(persisted.path,'bin/webhelm.mjs'),nodePath:process.execPath});
113
115
  return emit({status:'ready',...repo,daemon_pid:chrome.pid,managed_chrome:chrome.managed_chrome,login_guard});
114
116
  }
117
+ if (group === 'ensure') return emit(await request({op:'guard-check'}));
115
118
  if (group === 'status') {
116
119
  const daemon = await ensureDaemon();
117
120
  return emit({version:daemon.version, platform_dir:PLATFORMS, root:ROOT,
@@ -2,9 +2,9 @@ import os from 'node:os';
2
2
  import path from 'node:path';
3
3
  import fs from 'node:fs';
4
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 LOCAL_ROOT = path.join(os.homedir(), '.local', 'webhelm');
5
+ export const CONFIG_ROOT = path.resolve(process.env.WEBHELM_CONFIG_ROOT || path.join(os.homedir(), '.config', 'webhelm'));
6
+ export const LEGACY_ROOT = path.resolve(process.env.WEBHELM_LEGACY_ROOT || (process.env.WEBHELM_CONFIG_ROOT ? path.join(CONFIG_ROOT,'legacy-browser-workspace') : path.join(os.homedir(), '.config', 'browser-workspace')));
7
+ export const LOCAL_ROOT = path.resolve(process.env.WEBHELM_LOCAL_ROOT || path.join(os.homedir(), '.local', 'webhelm'));
8
8
  export const OLD_PROFILE_DIR = path.join(CONFIG_ROOT, 'chrome');
9
9
  export const PROFILE_DIR = path.join(LOCAL_ROOT, 'chrome');
10
10
  export const RUNTIME_DIR = path.join(CONFIG_ROOT, 'runtime');
@@ -3,9 +3,10 @@ import os from 'node:os';
3
3
  import path from 'node:path';
4
4
  import crypto from 'node:crypto';
5
5
  import {fileURLToPath} from 'node:url';
6
+ import {LOCAL_ROOT} from './config-paths.mjs';
6
7
 
7
8
  export const EXTENSION_ID='kkkpgmhpoeceojalkpfdidffdfnlgdog';
8
- export const EXTENSION_HOME=path.resolve(process.env.WEBHELM_EXTENSION_HOME || path.join(os.homedir(),'.local/webhelm/extension'));
9
+ export const EXTENSION_HOME=path.resolve(process.env.WEBHELM_EXTENSION_HOME || path.join(LOCAL_ROOT,'extension'));
9
10
  const SOURCE=fileURLToPath(new URL('./extension/',import.meta.url));
10
11
  const FILES=['manifest.json','service-worker.mjs','workspace-manager.mjs','workspace.html','workspace-page.mjs'];
11
12
 
@@ -2,23 +2,57 @@ import fs from 'node:fs';
2
2
  import os from 'node:os';
3
3
  import path from 'node:path';
4
4
  import {execFileSync} from 'node:child_process';
5
+ import {fileURLToPath} from 'node:url';
5
6
 
6
7
  const LABEL='com.lalalic.webhelm.keepalive';
8
+ // launchd must never depend on npm's removable npx cache, or on the Skill
9
+ // launcher being present. Stage an immutable, package-backed runtime locally.
10
+ export function stageRuntimeForLoginGuard({home=os.homedir(),source=fileURLToPath(new URL('../',import.meta.url)),yamlSource=null}={}){
11
+ const manifest=JSON.parse(fs.readFileSync(path.join(source,'package.json'),'utf8'));
12
+ if(manifest.name!=='webhelm'||!/^\d+\.\d+\.\d+$/.test(manifest.version))throw Error('Invalid WebHelm runtime package');
13
+ const parent=path.join(home,'.local/share/webhelm');
14
+ const target=path.join(parent,`runtime-${manifest.version}`);
15
+ const keyfiles=['package.json','bin/webhelm.mjs','runtime/cli.mjs','runtime/node-daemon.mjs','runtime/login-guard.mjs'];
16
+ const equal=()=>keyfiles.every(name=>fs.existsSync(path.join(target,name))&&fs.readFileSync(path.join(target,name)).equals(fs.readFileSync(path.join(source,name))))&&fs.existsSync(path.join(target,'node_modules/yaml/package.json'));
17
+ if(equal())return {path:target,version:manifest.version,updated:false};
18
+ const yamlDir=yamlSource||path.resolve(fileURLToPath(import.meta.resolve('yaml')),'../..');
19
+ if(!fs.existsSync(path.join(yamlDir,'package.json')))throw Error('Bundled Runtime yaml dependency missing');
20
+ fs.mkdirSync(parent,{recursive:true,mode:0o700});
21
+ const tmp=fs.mkdtempSync(path.join(parent,'.webhelm-runtime-stage-'));
22
+ try{
23
+ for(const dirname of ['bin','runtime'])fs.cpSync(path.join(source,dirname),path.join(tmp,dirname),{recursive:true});
24
+ fs.copyFileSync(path.join(source,'package.json'),path.join(tmp,'package.json'));
25
+ fs.mkdirSync(path.join(tmp,'node_modules'),{recursive:true});
26
+ fs.cpSync(yamlDir,path.join(tmp,'node_modules/yaml'),{recursive:true});
27
+ let old=null;
28
+ if(fs.existsSync(target)){
29
+ old=path.join(parent,`.webhelm-runtime-old-${process.pid}-${Date.now()}`);
30
+ fs.renameSync(target,old);
31
+ }
32
+ try{fs.renameSync(tmp,target);}catch(error){if(old)fs.renameSync(old,target);throw error;}
33
+ if(old)fs.rmSync(old,{recursive:true,force:true});
34
+ return {path:target,version:manifest.version,updated:true};
35
+ }finally{if(fs.existsSync(tmp))fs.rmSync(tmp,{recursive:true,force:true});}
36
+ }
37
+
7
38
  const escapeXml=s=>String(s).replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&apos;'}[c]));
8
- export function loginGuardDefinition(home=os.homedir()) {
9
- const cli=path.join(home,'.local/bin/webhelm');
39
+ export function loginGuardDefinition(home=os.homedir(),{cliPath=path.join(home,'.local/bin/webhelm'),nodePath=null}={}) {
40
+ const cli=cliPath;
41
+ const program=nodePath||cli;
42
+ const args=nodePath?`<string>${escapeXml(cli)}</string><string>ensure</string>`:'<string>ensure</string>';
10
43
  const environment=[path.join(home,'.local/bin'),path.join(home,'.local/opt/node22/bin'),
11
44
  '/opt/homebrew/bin','/usr/local/bin','/usr/bin','/bin'].join(':');
12
- // The short-lived status check starts a missing daemon, and is a no-op if
13
- // it already owns the IPC socket. Node daemon independently repairs Chrome.
14
- return `<?xml version="1.0" encoding="UTF-8"?>\n<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n<plist version="1.0"><dict>\n<key>Label</key><string>${LABEL}</string>\n<key>ProgramArguments</key><array><string>${escapeXml(cli)}</string><string>status</string></array>\n<key>EnvironmentVariables</key><dict><key>PATH</key><string>${escapeXml(environment)}</string><key>HOME</key><string>${escapeXml(home)}</string></dict>\n<key>RunAtLoad</key><true/>\n<key>StartInterval</key><integer>300</integer>\n<key>StandardOutPath</key><string>/dev/null</string>\n<key>StandardErrorPath</key><string>/dev/null</string>\n</dict></plist>\n`;
45
+ // Use the cached, tested npm Runtime directly to avoid registry/network
46
+ // dependency at login or when Chrome has just auto-updated. This must call
47
+ // 'ensure', not 'status': a live daemon does not imply a live Chrome.
48
+ return `<?xml version="1.0" encoding="UTF-8"?>\n<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n<plist version="1.0"><dict>\n<key>Label</key><string>${LABEL}</string>\n<key>ProgramArguments</key><array><string>${escapeXml(program)}</string>${args}</array>\n<key>EnvironmentVariables</key><dict><key>PATH</key><string>${escapeXml(environment)}</string><key>HOME</key><string>${escapeXml(home)}</string></dict>\n<key>RunAtLoad</key><true/>\n<key>StartInterval</key><integer>30</integer>\n<key>StandardOutPath</key><string>/dev/null</string>\n<key>StandardErrorPath</key><string>/dev/null</string>\n</dict></plist>\n`;
15
49
  }
16
- export function installLoginGuard({home=os.homedir(), platform=process.platform, apply=true}={}){
50
+ export function installLoginGuard({home=os.homedir(), platform=process.platform, apply=true,cliPath,nodePath}={}){
17
51
  if(platform!=='darwin')return {status:'unsupported',label:LABEL};
18
- const cli=path.join(home,'.local/bin/webhelm');
19
- if(!fs.existsSync(cli))return {status:'skipped',reason:'webhelm Skill launcher missing',label:LABEL};
52
+ const cli=cliPath||path.join(home,'.local/bin/webhelm');
53
+ if(!fs.existsSync(cli))return {status:'skipped',reason:'WebHelm runtime launcher missing',label:LABEL};
20
54
  const dest=path.join(home,'Library/LaunchAgents',`${LABEL}.plist`);
21
- const content=loginGuardDefinition(home);
55
+ const content=loginGuardDefinition(home,{cliPath,nodePath});
22
56
  fs.mkdirSync(path.dirname(dest),{recursive:true});
23
57
  if(fs.existsSync(dest)){
24
58
  const old=fs.readFileSync(dest,'utf8');
@@ -2,15 +2,18 @@
2
2
  // close a user's Chrome windows to recover the managed browser.
3
3
  export function createManagedChromeGuard(check, {intervalMs=15000, onError=()=>{}}={}) {
4
4
  if(typeof check!=='function')throw TypeError('health check must be a function');
5
- let timer=null, running=false, stopped=false;
5
+ let timer=null, inFlight=null, stopped=false;
6
6
  let last={status:'not_checked'};
7
- const tick=async()=>{
8
- if(running||stopped)return last;
9
- running=true;
10
- try{last={status:'healthy',...(await check())};}
11
- catch(error){last={status:'degraded',error:String(error?.message||error)};onError(error);}
12
- finally{running=false;}
13
- return last;
7
+ const tick=()=>{
8
+ if(stopped)return Promise.resolve(last);
9
+ if(inFlight)return inFlight;
10
+ inFlight=(async()=>{
11
+ try{last={status:'healthy',...(await check())};}
12
+ catch(error){last={status:'degraded',error:String(error?.message||error)};onError(error);}
13
+ finally{inFlight=null;}
14
+ return last;
15
+ })();
16
+ return inFlight;
14
17
  };
15
18
  return {
16
19
  tick,
@@ -575,7 +575,7 @@ async function runPlatform(req) {
575
575
  self_heal_capable:!auto,result};
576
576
  }
577
577
 
578
- export async function handle(req){switch(req.op){case'ping':return {ok:true,pid:process.pid,version:PACKAGE_VERSION,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},chrome_guard:managedChromeGuard.status()};case'heartbeat':return heartbeatSession(req.session_id);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}`)}}
578
+ export async function handle(req){switch(req.op){case'ping':return {ok:true,pid:process.pid,version:PACKAGE_VERSION,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},chrome_guard:managedChromeGuard.status()};case'guard-check':{let status=await managedChromeGuard.tick();for(let retry=0;retry<6 && status.status!=='healthy' && !status.skipped;retry++){await delay(800);status=await managedChromeGuard.tick();}if(status.status!=='healthy' && !status.skipped)throw Error(`WebHelm Chrome health degraded: ${status.error||status.status}`);return {ok:true,chrome_guard:status};}case'heartbeat':return heartbeatSession(req.session_id);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}`)}}
579
579
  function serve(conn) {
580
580
  let buffer = '';
581
581
  conn.setEncoding('utf8');