webhelm 1.0.1 → 1.0.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.
@@ -0,0 +1,105 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import crypto from 'node:crypto';
5
+ import {fileURLToPath} from 'node:url';
6
+
7
+ 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
+ const SOURCE=fileURLToPath(new URL('./extension/',import.meta.url));
10
+ const FILES=['manifest.json','service-worker.mjs','workspace-manager.mjs','workspace.html','workspace-page.mjs'];
11
+
12
+ function manifest(dir){
13
+ const data=JSON.parse(fs.readFileSync(path.join(dir,'manifest.json'),'utf8'));
14
+ const digest=crypto.createHash('sha256').update(Buffer.from(data.key,'base64')).digest('hex').slice(0,32);
15
+ const id=[...digest].map(c=>String.fromCharCode(97+parseInt(c,16))).join('');
16
+ if(id!==EXTENSION_ID || data.manifest_version!==3 || !/^\d+\.\d+\.\d+$/.test(data.version))
17
+ throw Error('Unexpected WebHelm extension identity, manifest version, or version number');
18
+ return data;
19
+ }
20
+ export const extensionVersion=manifest(SOURCE).version;
21
+ function identical(dir){
22
+ return fs.existsSync(path.join(dir,'manifest.json'))&&
23
+ JSON.stringify(fs.readdirSync(dir).sort())===JSON.stringify([...FILES].sort())&&
24
+ FILES.every(name=>fs.readFileSync(path.join(dir,name)).equals(fs.readFileSync(path.join(SOURCE,name))));
25
+ }
26
+ // Actions checkout contains this self-managed directory, but Git must never publish it.
27
+ function excludeFromActionsGit(dir){
28
+ const root=path.dirname(dir), info=path.join(root,'.git/info/exclude');
29
+ if(!fs.existsSync(info))return;
30
+ const contents=fs.readFileSync(info,'utf8');
31
+ const additions=['/chrome/','/extension/','/.webhelm-extension-stage-*/','/.webhelm-extension-backup-*/'].filter(rule=>!contents.split(/\r?\n/).includes(rule));
32
+ if(additions.length)fs.appendFileSync(info,'\n'+additions.join('\n')+'\n');
33
+ }
34
+ export function stageExtension(dir=EXTENSION_HOME){
35
+ const target=path.resolve(dir);
36
+ const bundled=manifest(SOURCE);
37
+ if(fs.existsSync(target)&&fs.lstatSync(target).isSymbolicLink())throw Error('Refusing to replace extension symlink');
38
+ if(fs.existsSync(target)&&identical(target))return {path:target,version:bundled.version,updated:false};
39
+ if(fs.existsSync(target))manifest(target); // Never replace unrelated content.
40
+ const root=path.dirname(target);
41
+ fs.mkdirSync(root,{recursive:true,mode:0o700});
42
+ excludeFromActionsGit(target);
43
+ const stage=fs.mkdtempSync(path.join(root,'.webhelm-extension-stage-'));
44
+ let backup;
45
+ try {
46
+ for(const filename of FILES)fs.copyFileSync(path.join(SOURCE,filename),path.join(stage,filename));
47
+ if(!identical(stage))throw Error('WebHelm extension staged content mismatch');
48
+ if(fs.existsSync(target)){
49
+ backup=path.join(root,`.webhelm-extension-backup-${process.pid}-${crypto.randomBytes(4).toString('hex')}`);
50
+ fs.renameSync(target,backup);
51
+ }
52
+ try {fs.renameSync(stage,target);}catch(error){if(backup)fs.renameSync(backup,target);throw error;}
53
+ if(backup)fs.rmSync(backup,{recursive:true,force:true});
54
+ return {path:target,version:bundled.version,updated:true};
55
+ }finally{if(fs.existsSync(stage))fs.rmSync(stage,{recursive:true,force:true});}
56
+ }
57
+
58
+ // Modern Chrome exposes this browser-level protocol. Older Chrome (including
59
+ // Big Sur's Chrome 138) requires the managed startup flags instead.
60
+ export async function ensureExtensionLoaded(browser, {skip=false,extensionDir=EXTENSION_HOME,allowUpdate=true}={}){
61
+ if(skip)return {skipped:true};
62
+ if(!allowUpdate && (!fs.existsSync(path.join(extensionDir,'manifest.json')) || !identical(extensionDir)))
63
+ throw Error('Extension update deferred: active browser Sessions must finish before staging new content');
64
+ const {path:dir,version}=allowUpdate?stageExtension(extensionDir):{path:extensionDir,version:extensionVersion};
65
+ let modern=false, installed;
66
+ try{
67
+ const state=await browser.send('Extensions.getExtensions');
68
+ modern=true;installed=state.extensions?.find(e=>e.id===EXTENSION_ID);
69
+ if(!installed || !installed.enabled || installed.version!==version || path.resolve(installed.path||'')!==dir){
70
+ if(!allowUpdate)throw Error('Extension reload deferred: active browser Sessions must finish');
71
+ const loaded=await browser.send('Extensions.loadUnpacked',{path:dir});
72
+ if(loaded.id!==EXTENSION_ID)throw Error('Chrome loaded an unexpected extension ID');
73
+ }
74
+ }catch(error){
75
+ if(modern || !/Method not available|wasn't found|Method not found|not supported/i.test(String(error.message)))
76
+ throw Error(`WebHelm extension install failed: ${error.message}`);
77
+ }
78
+ // Inspect the real extension context. An installed-but-asleep MV3 worker may
79
+ // need a temporary extension page, which is never a user's allocated tab.
80
+ const {targetInfos=[]}=await browser.send('Target.getTargets');
81
+ const prefix=`chrome-extension://${EXTENSION_ID}/`;
82
+ let target=targetInfos.find(t=>t.type==='service_worker'&&t.url.startsWith(prefix));
83
+ let temporary=false;
84
+ if(!target){
85
+ const created=await browser.send('Target.createTarget',{url:`${prefix}workspace.html?webhelm_wakeup=1`,background:true});
86
+ target={targetId:created.targetId};temporary=true;
87
+ }
88
+ let attached;
89
+ try{
90
+ ({sessionId:attached}=await browser.send('Target.attachToTarget',{targetId:target.targetId,flatten:true}));
91
+ for(let i=0;i<30;i++){
92
+ try{
93
+ const reply=await browser.send('Runtime.evaluate',{expression:'chrome.runtime?.getManifest?.().version',returnByValue:true},attached);
94
+ if(reply.result?.value===version)return {path:dir,version,method:modern?'cdp':'startup-flags'};
95
+ if(reply.result?.value && reply.result.value!==version)
96
+ throw Error(`Chrome has extension ${reply.result.value}; expected ${version}`);
97
+ }catch(error){if(/Chrome has extension/.test(error.message))throw error;}
98
+ await new Promise(r=>setTimeout(r,100));
99
+ }
100
+ throw Error(`Chrome did not initialize managed Extension ${version}. Restart managed Chrome when idle with local extension launch flags.`);
101
+ }finally{
102
+ if(attached)await browser.send('Target.detachFromTarget',{sessionId:attached}).catch(()=>{});
103
+ if(temporary)await browser.send('Target.closeTarget',{targetId:target.targetId}).catch(()=>{});
104
+ }
105
+ }
@@ -2,7 +2,7 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { execFileSync } from 'node:child_process';
4
4
  import net from 'node:net';
5
- import { CONFIG_ROOT, LEGACY_ROOT } from './config-paths.mjs';
5
+ import { CONFIG_ROOT, LEGACY_ROOT, OLD_PROFILE_DIR, PROFILE_DIR } from './config-paths.mjs';
6
6
 
7
7
  function liveSocket(socketPath) {
8
8
  return new Promise(resolve => {
@@ -45,3 +45,36 @@ export async function migrateConfig({ legacyRoot = LEGACY_ROOT, configRoot = CON
45
45
  }
46
46
  return { status: 'migrated', config_root: target, compatibility_symlink: legacy };
47
47
  }
48
+
49
+ // Move only the Chrome Profile. Durable FIFO and logs stay under ~/.config/webhelm/runtime.
50
+ // Chrome must be stopped because its SingletonLock and keychain-bound profile
51
+ // resources cannot be safely relocated while the browser is still running.
52
+ export async function migrateChromeProfile({oldProfile=OLD_PROFILE_DIR,newProfile=PROFILE_DIR,
53
+ processList=()=>execFileSync('ps',['-axo','command='],{encoding:'utf8'}),
54
+ socketCheck=liveSocket}={}){
55
+ const old=path.resolve(oldProfile),target=path.resolve(newProfile);
56
+ if(old===target)throw Error('Chrome source and destination paths must differ');
57
+ if(fs.existsSync(target)){
58
+ if(!fs.existsSync(old))return {status:'already_migrated',profile_dir:target};
59
+ if(fs.realpathSync(old)===fs.realpathSync(target))return {status:'already_migrated',profile_dir:target,compatibility_symlink:old};
60
+ throw Error(`Different Chrome Profiles at ${old} and ${target}; refusing to overwrite`);
61
+ }
62
+ if(!fs.existsSync(old))return {status:'not_needed',profile_dir:target};
63
+ if(fs.lstatSync(old).isSymbolicLink() || !fs.statSync(old).isDirectory())
64
+ throw Error('Old Chrome Profile is not a regular directory');
65
+ const running=processList().split('\n').filter(s=>s.includes(`--user-data-dir=${old}`)||s.includes(`--user-data-dir=${target}`)||s.includes('node-daemon.mjs daemon') && !s.includes('migrate.mjs'));
66
+ if(running.length)throw Error('Managed Chrome or daemon is running; stop and drain before migrating Profile');
67
+ const socket=path.join(path.dirname(old),'runtime/session-node.sock');
68
+ if(fs.existsSync(socket)&&await socketCheck(socket))throw Error('Session daemon still accepting requests');
69
+ if(!fs.existsSync(path.join(old,'Default')))throw Error('Old Chrome Profile lacks Default; refusing to migrate');
70
+ const parent=path.dirname(target);
71
+ if(fs.existsSync(path.join(parent,'.git'))){
72
+ const info=path.join(parent,'.git/info/exclude');
73
+ const current=fs.existsSync(info)?fs.readFileSync(info,'utf8'):'';
74
+ if(!current.split(/\r?\n/).includes('/chrome/'))fs.appendFileSync(info,'\n/chrome/\n');
75
+ }
76
+ fs.mkdirSync(parent,{recursive:true});
77
+ fs.renameSync(old,target);
78
+ try{fs.symlinkSync(target,old,'dir');}catch(error){fs.renameSync(target,old);throw error;}
79
+ return {status:'migrated',profile_dir:target,compatibility_symlink:old};
80
+ }
@@ -8,10 +8,13 @@ import crypto from 'node:crypto';
8
8
  import { fileURLToPath, pathToFileURL } from 'node:url';
9
9
  import { invokePlatformAction } from './platforms.mjs';
10
10
  import { ChatGPTQueue } from './chatgpt/chatgpt-queue.mjs';
11
+ import { idleUrlFromIdentity, sameIdleUrl, staleSession } from './session-lifecycle.mjs';
11
12
  import { ensureManagedChrome } from './chrome.mjs';
13
+ import { ensureExtensionLoaded } from './extension-manager.mjs';
12
14
  import { PROFILE_DIR, RUNTIME_DIR, requireMigratedConfig } from './config-paths.mjs';
13
15
 
14
- const ROOT = path.dirname(new URL(import.meta.url).pathname);
16
+ const ROOT = path.dirname(fileURLToPath(import.meta.url));
17
+ const PACKAGE_VERSION=JSON.parse(fs.readFileSync(new URL('../package.json',import.meta.url),'utf8')).version;
15
18
  const HOME = os.homedir();
16
19
  export { PROFILE_DIR };
17
20
  const DEFAULT_RUNTIME = RUNTIME_DIR;
@@ -49,6 +52,7 @@ function leaseMutex(workspace) {
49
52
  }
50
53
  async function acquireIdentityLease(session) {
51
54
  return leaseMutex(session.workspace).run(async()=>{
55
+ await reconcileWorkspaceSessions(session.workspace);
52
56
  const lease=await extensionCall(session,'workspace.acquireIdentity',{name:session.workspace});
53
57
  const targetId=await targetForUrl(lease.identityUrl);
54
58
  if(!targetId){
@@ -56,9 +60,10 @@ async function acquireIdentityLease(session) {
56
60
  throw Error('Workspace extension leased a tab without a matching CDP target');
57
61
  }
58
62
  if(targets.has(targetId)){
59
- // Never release a tab owned by another active Session.
63
+ // Do not navigate/release a tab still owned by another Session.
60
64
  throw Error('Workspace extension returned a target already owned by another Session');
61
65
  }
66
+ lease.idleUrl ||= idleUrlFromIdentity(lease.identityUrl, lease.tabId);
62
67
  return {lease,targetId};
63
68
  });
64
69
  }
@@ -95,6 +100,8 @@ async function connectBrowser() {
95
100
  }
96
101
  });
97
102
  await browser.send('Target.setDiscoverTargets',{discover:true});
103
+ try{await ensureExtensionLoaded(browser,{skip:process.env.WEBHELM_NO_PROFILE_EXTENSION_PROBE==='1',allowUpdate:sessions.size===0});}
104
+ catch(error){browserWs=null;browser.ws?.close();throw error;}
98
105
  connected=true;
99
106
  if(!reconnecting){
100
107
  reconnecting=true;
@@ -131,6 +138,7 @@ function makeHelpers(s){
131
138
  if(s.workspaceSupported){
132
139
  ({lease,targetId}=await acquireIdentityLease(s));
133
140
  s.leasedTabs.set(targetId,lease.tabId);
141
+ s.idleUrls.set(targetId,lease.idleUrl);
134
142
  }else ({targetId}=await browser.send('Target.createTarget',{url:'about:blank'}));
135
143
  await attach(s,targetId);
136
144
  s.targetId=targetId;
@@ -139,21 +147,20 @@ function makeHelpers(s){
139
147
  return targetId;
140
148
  },
141
149
  async close_tab(target) {
142
- const targetId=typeof target==='string'?target:(target?.targetId||target?.target_id)||s.targetId;
143
- if(!s.targets.has(targetId))throw Error('cannot close a tab outside the active WebHelm Session');
144
- const lease=s.leasedTabs.get(targetId);
145
- if(lease){
146
- try{await extensionCall(s,'workspace.release',{name:s.workspace,tabId:lease});}
147
- catch{await connectBrowser().then(b=>b.send('Target.closeTarget',{targetId}));}
148
- }else await connectBrowser().then(b=>b.send('Target.closeTarget',{targetId}));
149
- s.leasedTabs.delete(targetId);
150
- s.targets.delete(targetId);
151
- s.attachments.delete(targetId);
152
- for(const dispose of s.eventDisposers.get(targetId)||[])dispose();
153
- s.eventDisposers.delete(targetId);
154
- targets.delete(targetId);
155
- if(s.targetId===targetId)s.targetId=s.targets.values().next().value;
156
- return {closed:true,target_id:targetId};
150
+ const tid=typeof target==='string'?target:(target?.targetId||target?.target_id)||s.targetId;
151
+ if(!s.targets.has(tid))throw Error('cannot close a tab outside the active WebHelm Session');
152
+ return leaseMutex(s.workspace).run(async()=>{
153
+ const browser=await connectBrowser();
154
+ if(s.leasedTabs.has(tid))await navigateIdle(browser,s,tid,s.idleUrls.get(tid));
155
+ else await browser.send('Target.closeTarget',{targetId:tid}); // Popup without pool slot.
156
+ const attachment=s.attachments.get(tid);
157
+ if(attachment)await browser.send('Target.detachFromTarget',{sessionId:attachment}).catch(()=>{});
158
+ s.leasedTabs.delete(tid);s.idleUrls.delete(tid);s.targets.delete(tid);s.attachments.delete(tid);
159
+ for(const dispose of s.eventDisposers.get(tid)||[])dispose();
160
+ s.eventDisposers.delete(tid);targets.delete(tid);
161
+ if(s.targetId===tid)s.targetId=s.targets.values().next().value;
162
+ return {closed:true,target_id:tid,returned_to_idle:true};
163
+ });
157
164
  },
158
165
  async page_info(){return evaluate(s,'({url:location.href,title:document.title,readyState:document.readyState})')},
159
166
  async wait_for_load(timeout=15000){const end=Date.now()+timeout;while(Date.now()<end){try{if(await evaluate(s,'document.readyState')==='complete')return true}catch{}await delay(100)}throw Error('page load timed out')},
@@ -291,14 +298,17 @@ async function createSession(req={}) {
291
298
  const sid=crypto.randomBytes(8).toString('hex');
292
299
  const s={session_id:sid, workspace:req.workspace||'Harness', targetId:null,
293
300
  rootTargetId:null, workspaceSupported:false,targets:new Set(), attachments:new Map(), eventDisposers:new Map(),
294
- leasedTabs:new Map(), mutex:new Mutex(), events:[], namespace:null,context:null,last_used_at:Date.now()};
301
+ leasedTabs:new Map(), idleUrls:new Map(), mutex:new Mutex(), events:[], namespace:null,context:null,
302
+ inFlight:0, closing:false, last_used_at:Date.now()};
295
303
  let targetId,lease;
296
304
  try {
297
305
  if(await extensionSupported()) {
298
306
  ({lease,targetId}=await acquireIdentityLease(s));
299
307
  s.leasedTabs.set(targetId,lease.tabId);
308
+ s.idleUrls.set(targetId,lease.idleUrl);
300
309
  s.workspaceSupported=true;
301
310
  } else {
311
+ if(process.env.WEBHELM_NO_PROFILE_EXTENSION_PROBE!=='1')throw Error('Managed WebHelm Extension unavailable; refusing unmanaged Tab allocation');
302
312
  ({targetId}=await browser.send('Target.createTarget',{url:'about:blank'}));
303
313
  }
304
314
  s.targetId=targetId;
@@ -335,35 +345,110 @@ async function extensionSupported() {
335
345
  throw Error(`Browser Workspace extension installed but RPC unavailable: ${error?.message||'unknown'}`);
336
346
  }
337
347
 
338
- async function execute(req){const s=sessions.get(req.session_id);if(!s)throw Error(`unknown session_id: ${req.session_id}`);return s.mutex.run(async()=>{s.last_used_at=Date.now();const logs=[],errors=[];const sandbox={...s.namespace};sandbox.console={log:(...x)=>logs.push(x.map(v=>typeof v==='string'?v:JSON.stringify(v)).join(' ')),error:(...x)=>errors.push(x.map(v=>typeof v==='string'?v:JSON.stringify(v)).join(' ')),warn:(...x)=>errors.push(x.map(v=>typeof v==='string'?v:JSON.stringify(v)).join(' '))};s.context=vm.createContext(sandbox);try{let source=String(req.code||'').replace(/(^|\n)\s*(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=/g,'$1globalThis.$2 =');const lines=source.trimEnd().split('\n');const last=lines.at(-1)?.trim()||'';if(last&&!last.endsWith(';')&&!/^(return|throw|if|for|while|function|class|\}|\/\/)/.test(last)){lines[lines.length-1]=`return (${last})`}source=lines.join('\n');const script=new vm.Script(`(async()=>{${source}\n})()`,{filename:'<webhelm-session>'});const result=await script.runInContext(s.context,{timeout:30000});Object.assign(s.namespace,sandbox);const out={session_id:s.session_id,workspace:s.workspace,target_id:s.targetId,ok:true,stdout:logs.join('\n'),stderr:errors.join('\n')};if(result!==undefined)Object.assign(out,jsonSafe(result));return out}catch(e){return {session_id:s.session_id,workspace:s.workspace,target_id:s.targetId,ok:false,stdout:logs.join('\n'),stderr:errors.join('\n'),error:`${e.name}: ${e.message}`}}})}
348
+ async function execute(req) {
349
+ const s=sessions.get(req.session_id);
350
+ if(!s || s.closing)throw Error(`unknown session_id: ${req.session_id} (missing or closing)`);
351
+ s.inFlight++;
352
+ try {return await s.mutex.run(async()=>{
353
+ if(s.closing)throw Error('session closing');
354
+ s.last_used_at=Date.now();
355
+ const logs=[],errors=[];
356
+ const sandbox={...s.namespace};
357
+ sandbox.console={log:(...x)=>logs.push(x.map(v=>typeof v==='string'?v:JSON.stringify(v)).join(' ')),error:(...x)=>errors.push(x.map(v=>typeof v==='string'?v:JSON.stringify(v)).join(' ')),warn:(...x)=>errors.push(x.map(v=>typeof v==='string'?v:JSON.stringify(v)).join(' '))};
358
+ s.context=vm.createContext(sandbox);
359
+ try {
360
+ let source=String(req.code||'').replace(/(^|\n)\s*(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=/g,'$1globalThis.$2 =');
361
+ const lines=source.trimEnd().split('\n');
362
+ const last=lines.at(-1)?.trim()||'';
363
+ if(last&&!last.endsWith(';')&&!/^(return|throw|if|for|while|function|class|\}|\/\/)/.test(last))lines[lines.length-1]=`return (${last})`;
364
+ source=lines.join('\n');
365
+ const script=new vm.Script(`(async()=>{${source}\n})()`,{filename:'<webhelm-session>'});
366
+ const result=await script.runInContext(s.context,{timeout:30000});
367
+ Object.assign(s.namespace,sandbox);
368
+ const out={session_id:s.session_id,workspace:s.workspace,target_id:s.targetId,ok:true,stdout:logs.join('\n'),stderr:errors.join('\n')};
369
+ if(result!==undefined)Object.assign(out,jsonSafe(result));
370
+ return out;
371
+ }catch(e){return {session_id:s.session_id,workspace:s.workspace,target_id:s.targetId,ok:false,stdout:logs.join('\n'),stderr:errors.join('\n'),error:`${e.name}: ${e.message}`}}
372
+ });} finally {s.inFlight--;s.last_used_at=Date.now();}
373
+ }
374
+
375
+ async function targetUrl(browser,targetId) {
376
+ const reply=await browser.send('Target.getTargetInfo',{targetId});
377
+ return reply.targetInfo?.url||'';
378
+ }
379
+
380
+ async function navigateIdle(browser,s,targetId,idleUrl){
381
+ if(!idleUrl)throw Error('missing idle URL for owned tab');
382
+ if(sameIdleUrl(await targetUrl(browser,targetId),idleUrl))return;
383
+ await browser.send('Page.navigate',{url:idleUrl},sidFor(s,targetId));
384
+ for(let i=0;i<60;i++){
385
+ if(sameIdleUrl(await targetUrl(browser,targetId),idleUrl))return;
386
+ await delay(50);
387
+ }
388
+ throw Error('idle navigation not confirmed; Session retained');
389
+ }
390
+
391
+ // Must be invoked while the workspace's allocation mutex is held.
392
+ async function closeSessionOwned(s){
393
+ if(sessions.get(s.session_id)!==s)return {session_id:s.session_id,already_closed:true};
394
+ s.closing=true;
395
+ let release_error=null;
396
+ try {
397
+ const count=s.targets.size;
398
+ await s.mutex.run(async()=>{
399
+ const browser=await connectBrowser();
400
+ // Return managed tabs to their original Extension pool; keep their CDP targets.
401
+ for(const tid of [...s.targets]){
402
+ if(s.leasedTabs.has(tid)){
403
+ await navigateIdle(browser,s,tid,s.idleUrls.get(tid));
404
+ } else {
405
+ // Unleased child popup targets have no managed pool slot.
406
+ await browser.send('Target.closeTarget',{targetId:tid});
407
+ }
408
+ }
409
+ for(const [,attachment] of s.attachments)await browser.send('Target.detachFromTarget',{sessionId:attachment}).catch(()=>{});
410
+ for(const [tid,disposers] of s.eventDisposers){for(const dispose of disposers)dispose();targets.delete(tid);}
411
+ for(const tid of s.targets)targets.delete(tid);
412
+ s.targets.clear();s.attachments.clear();s.leasedTabs.clear();s.idleUrls.clear();s.eventDisposers.clear();
413
+ sessions.delete(s.session_id);
414
+ });
415
+ return {session_id:s.session_id,workspace:s.workspace,target_id:s.rootTargetId,closed_tabs:count,returned_to_idle:true,release_error};
416
+ }catch(error){
417
+ s.closing=false; // Keep ownership for a future retry; never reassign on unconfirmed navigation.
418
+ throw error;
419
+ }
420
+ }
421
+
422
+ async function reconcileWorkspaceSessions(workspace){
423
+ const browser=await connectBrowser();
424
+ for(const s of [...sessions.values()]){
425
+ if(s.workspace!==workspace||s.closing||s.inFlight>0)continue;
426
+ let alreadyIdle=false;
427
+ for(const [tid,idleUrl] of s.idleUrls){
428
+ try {if(sameIdleUrl(await targetUrl(browser,tid),idleUrl)){alreadyIdle=true;break;}}
429
+ catch {alreadyIdle=true;break;} // Lost target means this Session is no longer valid.
430
+ }
431
+ if(!alreadyIdle&&!staleSession(s))continue;
432
+ try {await closeSessionOwned(s);}
433
+ catch(error){console.error(JSON.stringify({component:'session-gc',session_id:s.session_id,error:String(error)}));}
434
+ }
435
+ }
436
+
339
437
  async function stopSession(sid){
340
438
  const s=sessions.get(sid);
341
439
  if(!s)throw Error(`unknown session_id: ${sid}`);
342
- const count=s.targets.size;
343
- let release_error=null, recovered_tab_closes=0;
344
- await s.mutex.run(async()=>{
345
- const browser=await connectBrowser();
346
- for(const tid of [...s.targets]){
347
- try{
348
- const leasedTabId=s.leasedTabs.get(tid);
349
- if(leasedTabId) {
350
- try{await extensionCall(s,'workspace.release',{name:s.workspace,tabId:leasedTabId});}
351
- catch(error){
352
- // If MV3 lost its transient ownership map, never leave an orphan.
353
- // The CDP Target is owned exclusively by this Session.
354
- await browser.send('Target.closeTarget',{targetId:tid});
355
- recovered_tab_closes++;
356
- }
357
- }else await browser.send('Target.closeTarget',{targetId:tid});
358
- }catch(error){release_error=release_error||String(error);}
359
- s.leasedTabs.delete(tid);targets.delete(tid);
360
- for(const dispose of s.eventDisposers.get(tid)||[])dispose();
361
- s.eventDisposers.delete(tid);
362
- }
363
- for(const [,attachment] of s.attachments)await browser.send('Target.detachFromTarget',{sessionId:attachment}).catch(()=>{});
364
- sessions.delete(sid);
365
- });
366
- return {session_id:sid,workspace:s.workspace,target_id:s.rootTargetId,closed_tabs:count,recovered_tab_closes,release_error};
440
+ s.closing=true;
441
+ // Do not hold the workspace allocation lock while an in-flight operation
442
+ // might be acquiring a new tab: that would deadlock its Session mutex.
443
+ await s.mutex.run(async()=>{});
444
+ return leaseMutex(s.workspace).run(()=>closeSessionOwned(s));
445
+ }
446
+
447
+ function heartbeatSession(sid){
448
+ const s=sessions.get(sid);
449
+ if(!s||s.closing)throw Error(`unknown session_id: ${sid} (missing or closing)`);
450
+ s.last_used_at=Date.now();
451
+ return {session_id:sid,alive:true,last_used_at:s.last_used_at};
367
452
  }
368
453
 
369
454
  async function workspaceOp(req){const h=makeHelpers({workspace:req.name||'Harness',targetId:'',targets:new Set(),attachments:new Map()});if(req.op==='workspace-create')return h.workspace_create(req.name,req.pool_size);if(req.op==='workspace-delete')return h.workspace_delete(req.name,req.force);throw Error(`unknown op: ${req.op}`)}
@@ -418,6 +503,7 @@ async function runPlatform(req) {
418
503
  const session=sessions.get(sid);
419
504
  if (!session) throw Error(`unknown session_id: ${sid}`);
420
505
  let result, cleanup;
506
+ session.inFlight++;
421
507
  try {
422
508
  result=await session.mutex.run(async()=>{
423
509
  const logs=[];
@@ -438,6 +524,7 @@ async function runPlatform(req) {
438
524
  }
439
525
  });
440
526
  } finally {
527
+ session.inFlight--;session.last_used_at=Date.now();
441
528
  cleanup=auto ? await stopSession(sid).catch(e=>({release_error:e.message})) : {caller_owned_session:true};
442
529
  }
443
530
  result.cleanup=cleanup;
@@ -451,7 +538,7 @@ async function runPlatform(req) {
451
538
  self_heal_capable:!auto,result};
452
539
  }
453
540
 
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}`)}}
541
+ 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}};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}`)}}
455
542
  function serve(conn) {
456
543
  let buffer = '';
457
544
  conn.setEncoding('utf8');
@@ -471,6 +558,6 @@ function serve(conn) {
471
558
 
472
559
  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)}
473
560
  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)})}
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')}`)}
561
+ export async function ensureDaemon(){try{const p=await request({op:'ping'},false);if(p.ok)return p}catch{}if(!process.env.WEBHELM_CDP_URL)requireMigratedConfig();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')}`)}
475
562
  if(process.argv[2]==='daemon')main().catch(e=>{console.error(e);process.exit(1)});
476
563
  export {request,SOCKET,RUNTIME};
@@ -0,0 +1,24 @@
1
+ // A Session is live only while its owner performs operations or renews its heartbeat.
2
+ export const SESSION_IDLE_MS = 5 * 60 * 1000;
3
+
4
+ export function staleSession(session, now = Date.now(), ttl = SESSION_IDLE_MS) {
5
+ return !session.closing && !(session.inFlight > 0) &&
6
+ now - session.last_used_at >= ttl;
7
+ }
8
+
9
+ export function idleUrlFromIdentity(identityUrl, tabId) {
10
+ const url = new URL(identityUrl);
11
+ if (url.protocol !== 'chrome-extension:' || !url.pathname.endsWith('/workspace.html')) {
12
+ throw new Error('invalid extension identity URL');
13
+ }
14
+ url.searchParams.set('role', 'idle');
15
+ url.searchParams.set('slot', String(tabId));
16
+ return url.href;
17
+ }
18
+
19
+ export function sameIdleUrl(actual, expected) {
20
+ try {
21
+ const url = new URL(actual), idle = new URL(expected);
22
+ return url.protocol === 'chrome-extension:' && url.href === idle.href;
23
+ } catch { return false; }
24
+ }