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.
- package/README.md +5 -4
- package/package.json +1 -1
- package/runtime/chrome.mjs +5 -1
- package/runtime/cli.mjs +13 -3
- package/runtime/config-paths.mjs +9 -2
- package/runtime/extension/manifest.json +25 -0
- package/runtime/extension/service-worker.mjs +117 -0
- package/runtime/extension/workspace-manager.mjs +661 -0
- package/runtime/extension/workspace-page.mjs +7 -0
- package/runtime/extension/workspace.html +8 -0
- package/runtime/extension-manager.mjs +105 -0
- package/runtime/migrate.mjs +34 -1
- package/runtime/node-daemon.mjs +133 -46
- package/runtime/session-lifecycle.mjs +24 -0
|
@@ -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
|
+
}
|
package/runtime/migrate.mjs
CHANGED
|
@@ -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
|
+
}
|
package/runtime/node-daemon.mjs
CHANGED
|
@@ -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(
|
|
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
|
-
//
|
|
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
|
|
143
|
-
if(!s.targets.has(
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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,
|
|
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)
|
|
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
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
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:
|
|
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(){
|
|
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
|
+
}
|