webhelm 0.0.0-stage → 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/PRIVACY.md +21 -0
- package/README.md +54 -2
- package/bin/webhelm.mjs +2 -0
- package/package.json +32 -4
- package/runtime/actions-repo.mjs +62 -0
- package/runtime/admin.mjs +7 -0
- package/runtime/chatgpt/chatgpt-delivery-errors.mjs +94 -0
- package/runtime/chatgpt/chatgpt-probe.mjs +23 -0
- package/runtime/chatgpt/chatgpt-queue.mjs +441 -0
- package/runtime/chatgpt/lifecycle-common.mjs +79 -0
- package/runtime/chatgpt/readiness.mjs +120 -0
- package/runtime/chrome.mjs +125 -0
- package/runtime/cli.mjs +161 -0
- package/runtime/config-paths.mjs +14 -0
- package/runtime/migrate.mjs +47 -0
- package/runtime/node-daemon.mjs +476 -0
- package/runtime/platforms.mjs +89 -0
|
@@ -0,0 +1,476 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import net from 'node:net';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import vm from 'node:vm';
|
|
7
|
+
import crypto from 'node:crypto';
|
|
8
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
9
|
+
import { invokePlatformAction } from './platforms.mjs';
|
|
10
|
+
import { ChatGPTQueue } from './chatgpt/chatgpt-queue.mjs';
|
|
11
|
+
import { ensureManagedChrome } from './chrome.mjs';
|
|
12
|
+
import { PROFILE_DIR, RUNTIME_DIR, requireMigratedConfig } from './config-paths.mjs';
|
|
13
|
+
|
|
14
|
+
const ROOT = path.dirname(new URL(import.meta.url).pathname);
|
|
15
|
+
const HOME = os.homedir();
|
|
16
|
+
export { PROFILE_DIR };
|
|
17
|
+
const DEFAULT_RUNTIME = RUNTIME_DIR;
|
|
18
|
+
const RUNTIME = process.env.WEBHELM_RUNTIME_DIR || DEFAULT_RUNTIME;
|
|
19
|
+
const SOCKET = process.env.WEBHELM_SOCKET || path.join(RUNTIME, 'session-node.sock');
|
|
20
|
+
const LOCK = process.env.WEBHELM_LOCK || path.join(RUNTIME, 'session-node.lock');
|
|
21
|
+
const sessions = new Map();
|
|
22
|
+
const targets = new Map();
|
|
23
|
+
const pending = new Map();
|
|
24
|
+
let socket, nextId = 1, browserWs, connected = false, reconnecting = false, shuttingDown = false;
|
|
25
|
+
let chatgptQueue, managedChrome;
|
|
26
|
+
let connectingBrowser=null;
|
|
27
|
+
const delay = ms => new Promise(r => setTimeout(r, ms));
|
|
28
|
+
|
|
29
|
+
class Mutex { constructor(){this.tail=Promise.resolve()} async run(fn){let release;const gate=new Promise(r=>release=r);const prev=this.tail;this.tail=prev.then(()=>gate);await prev;try{return await fn()}finally{release()}} }
|
|
30
|
+
class Cdp {
|
|
31
|
+
constructor(url){this.url=url;this.events=new Map();this.closed=false;}
|
|
32
|
+
async connect(){
|
|
33
|
+
if (this.ws && this.ws.readyState===WebSocket.OPEN) return;
|
|
34
|
+
const ws=new WebSocket(this.url); this.ws=ws;
|
|
35
|
+
await new Promise((resolve,reject)=>{ws.addEventListener('open',resolve,{once:true});ws.addEventListener('error',reject,{once:true});});
|
|
36
|
+
this.closed=false;
|
|
37
|
+
ws.addEventListener('message',e=>{let m;try{m=JSON.parse(e.data)}catch{return} if(m.id){const p=pending.get(m.id);if(p){pending.delete(m.id);m.error?p.reject(new Error(m.error.message||'CDP error')):p.resolve(m.result||{})}}else if(m.method){for(const fn of this.events.get(m.method)||[])fn(m.params||{},m.sessionId)}});
|
|
38
|
+
ws.addEventListener('close',()=>{this.closed=true;connected=false;for(const [id,item] of pending){pending.delete(id);item.reject(Error('CDP WebSocket disconnected'));}});
|
|
39
|
+
}
|
|
40
|
+
on(method,fn){if(!this.events.has(method))this.events.set(method,new Set());this.events.get(method).add(fn);return()=>this.events.get(method)?.delete(fn)}
|
|
41
|
+
async send(method,params={},sessionId){await this.connect();const id=nextId++;return new Promise((resolve,reject)=>{const timeout=method==='Runtime.evaluate'||method==='Page.captureScreenshot'?60000:15000;const timer=setTimeout(()=>{pending.delete(id);reject(new Error(`CDP timeout after ${timeout}ms: ${method}`))},timeout);pending.set(id,{sessionId,resolve:v=>{clearTimeout(timer);resolve(v)},reject:e=>{clearTimeout(timer);reject(e)}});this.ws.send(JSON.stringify({id,method,params,...(sessionId?{sessionId}:{})}))})}
|
|
42
|
+
}
|
|
43
|
+
// Extension workspace acquisition is not atomic across concurrent RPC calls.
|
|
44
|
+
// Serialize only the short lease transaction, not browser Session operations.
|
|
45
|
+
const leaseMutexes=new Map();
|
|
46
|
+
function leaseMutex(workspace) {
|
|
47
|
+
if(!leaseMutexes.has(workspace))leaseMutexes.set(workspace,new Mutex());
|
|
48
|
+
return leaseMutexes.get(workspace);
|
|
49
|
+
}
|
|
50
|
+
async function acquireIdentityLease(session) {
|
|
51
|
+
return leaseMutex(session.workspace).run(async()=>{
|
|
52
|
+
const lease=await extensionCall(session,'workspace.acquireIdentity',{name:session.workspace});
|
|
53
|
+
const targetId=await targetForUrl(lease.identityUrl);
|
|
54
|
+
if(!targetId){
|
|
55
|
+
await extensionCall(session,'workspace.release',{name:session.workspace,tabId:lease.tabId}).catch(()=>{});
|
|
56
|
+
throw Error('Workspace extension leased a tab without a matching CDP target');
|
|
57
|
+
}
|
|
58
|
+
if(targets.has(targetId)){
|
|
59
|
+
// Never release a tab owned by another active Session.
|
|
60
|
+
throw Error('Workspace extension returned a target already owned by another Session');
|
|
61
|
+
}
|
|
62
|
+
return {lease,targetId};
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function connectBrowser() {
|
|
67
|
+
if(browserWs && !browserWs.closed)return browserWs;
|
|
68
|
+
if(connectingBrowser)return connectingBrowser;
|
|
69
|
+
connectingBrowser=(async()=>{
|
|
70
|
+
managedChrome=await ensureManagedChrome();
|
|
71
|
+
const endpoint=managedChrome.endpoint;
|
|
72
|
+
let version;
|
|
73
|
+
try{version=await (await fetch(`${endpoint}/json/version`)).json();}
|
|
74
|
+
catch{throw Error(`Managed Chrome CDP unavailable at ${endpoint}`);}
|
|
75
|
+
const browser=new Cdp(version.webSocketDebuggerUrl);
|
|
76
|
+
await browser.connect();
|
|
77
|
+
browserWs=browser;
|
|
78
|
+
debuggerSession=null;
|
|
79
|
+
browser.on('Target.targetCreated',info=>adoptPopup(info.targetInfo));
|
|
80
|
+
browser.on('Target.targetInfoChanged',info=>adoptPopup(info.targetInfo));
|
|
81
|
+
browser.on('Target.targetDestroyed',({targetId})=>{
|
|
82
|
+
if(debuggerSession?.targetId===targetId)debuggerSession=null;
|
|
83
|
+
targets.delete(targetId);
|
|
84
|
+
for(const s of sessions.values())if(s.targets.has(targetId)){
|
|
85
|
+
const ownerSid=s.attachments.get(targetId);
|
|
86
|
+
for(const [id,item] of pending)if(item.sessionId===ownerSid){
|
|
87
|
+
pending.delete(id);
|
|
88
|
+
item.reject(Error(`CDP target closed: ${targetId}. Verify external side effects before retrying.`));
|
|
89
|
+
}
|
|
90
|
+
s.targets.delete(targetId);
|
|
91
|
+
s.attachments.delete(targetId);
|
|
92
|
+
for(const dispose of s.eventDisposers.get(targetId)||[])dispose();
|
|
93
|
+
s.eventDisposers.delete(targetId);
|
|
94
|
+
if(s.targetId===targetId)s.targetId=s.targets.values().next().value;
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
await browser.send('Target.setDiscoverTargets',{discover:true});
|
|
98
|
+
connected=true;
|
|
99
|
+
if(!reconnecting){
|
|
100
|
+
reconnecting=true;
|
|
101
|
+
try{
|
|
102
|
+
const {targetInfos=[]}=await browser.send('Target.getTargets');
|
|
103
|
+
const available=new Set(targetInfos.filter(t=>t.type==='page').map(t=>t.targetId));
|
|
104
|
+
for(const session of sessions.values())for(const id of [...session.targets]){
|
|
105
|
+
if(available.has(id)){
|
|
106
|
+
try{await attach(session,id,browser);}catch{session.attachments.delete(id);}
|
|
107
|
+
}else {session.targets.delete(id);session.attachments.delete(id);}
|
|
108
|
+
}
|
|
109
|
+
}finally{reconnecting=false;}
|
|
110
|
+
}
|
|
111
|
+
return browser;
|
|
112
|
+
})();
|
|
113
|
+
try{return await connectingBrowser;}
|
|
114
|
+
finally{connectingBrowser=null;}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function adoptPopup(info){if(info?.type!=='page'||!info.openerId)return;const owner=targets.get(info.openerId);if(owner){owner.targets.add(info.targetId);targets.set(info.targetId,owner)}}
|
|
118
|
+
async function attach(s,targetId,b=null){b ||= await connectBrowser();const {sessionId}=await b.send('Target.attachToTarget',{targetId,flatten:true});s.targets.add(targetId);s.attachments.set(targetId,sessionId);targets.set(targetId,s);for(const d of ['Page','DOM','Runtime','Network'])await b.send(`${d}.enable`,{},sessionId);for(const dispose of s.eventDisposers?.get(targetId)||[])dispose();const removers=[];for(const method of ['Page.loadEventFired','Page.domContentEventFired','Page.javascriptDialogOpening','Page.javascriptDialogClosed','Runtime.exceptionThrown','Network.requestWillBeSent','Network.responseReceived'])removers.push(b.on(method,(params,owner)=>{if(owner===sessionId){if(s.events.length>=500)s.events.shift();s.events.push({method,params,session_id:sessionId,target_id:targetId})}}));s.eventDisposers?.set(targetId,removers);return sessionId}
|
|
119
|
+
function sidFor(s,targetId=s.targetId){const sid=s.attachments.get(targetId);if(!sid)throw new Error(`target ${targetId} is not attached to this session`);return sid}
|
|
120
|
+
async function evaluate(s,expression,targetId=s.targetId,awaitPromise=true){if(!s.targets.has(targetId))throw Error('cannot evaluate a target outside the active WebHelm session');const b=await connectBrowser();const r=await b.send('Runtime.evaluate',{expression,returnByValue:true,awaitPromise,userGesture:true},sidFor(s,targetId));if(r.exceptionDetails)throw new Error(r.exceptionDetails.exception?.description||r.exceptionDetails.text);return r.result?.value}
|
|
121
|
+
async function cdp(s,method,params={},targetId=s.targetId){if(method==='Target.createTarget')throw Error('Target.createTarget is disabled; use new_tab(url)');if(method==='Target.attachToTarget'||method==='Target.detachFromTarget')throw Error('direct target attachment changes are disabled in WebHelm sessions');if(['Target.closeTarget','Target.activateTarget'].includes(method)&&!s.targets.has(params.targetId))throw Error('cannot operate on a tab outside the active WebHelm session');if(method.startsWith('Target.')||method.startsWith('Browser.')||method.startsWith('ServiceWorker.'))return connectBrowser().then(b=>b.send(method,params));return connectBrowser().then(b=>b.send(method,params,sidFor(s,targetId)))}
|
|
122
|
+
function makeHelpers(s){
|
|
123
|
+
const h={
|
|
124
|
+
js:(expression,target_id)=>evaluate(s,expression,target_id||s.targetId),
|
|
125
|
+
cdp:(method,params={})=>cdp(s,method,params),
|
|
126
|
+
async goto_url(url){await cdp(s,'Page.navigate',{url});await h.wait_for_load();return h.page_info()},
|
|
127
|
+
async current_tab(){const info=await cdp(s,'Target.getTargetInfo',{targetId:s.targetId});return info.targetInfo},
|
|
128
|
+
async new_tab(url='about:blank') {
|
|
129
|
+
const browser=await connectBrowser();
|
|
130
|
+
let targetId,lease;
|
|
131
|
+
if(s.workspaceSupported){
|
|
132
|
+
({lease,targetId}=await acquireIdentityLease(s));
|
|
133
|
+
s.leasedTabs.set(targetId,lease.tabId);
|
|
134
|
+
}else ({targetId}=await browser.send('Target.createTarget',{url:'about:blank'}));
|
|
135
|
+
await attach(s,targetId);
|
|
136
|
+
s.targetId=targetId;
|
|
137
|
+
if(url!=='about:blank')await browser.send('Page.navigate',{url},sidFor(s,targetId));
|
|
138
|
+
else if(lease)await browser.send('Page.navigate',{url:'about:blank'},sidFor(s,targetId));
|
|
139
|
+
return targetId;
|
|
140
|
+
},
|
|
141
|
+
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};
|
|
157
|
+
},
|
|
158
|
+
async page_info(){return evaluate(s,'({url:location.href,title:document.title,readyState:document.readyState})')},
|
|
159
|
+
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')},
|
|
160
|
+
async wait_for_element(selector, options={}) {
|
|
161
|
+
const timeout = typeof options === 'number' ? options * 1000 : (options.timeout ?? 10) * 1000;
|
|
162
|
+
const visible = typeof options === 'object' ? !!options.visible : false;
|
|
163
|
+
const end = Date.now() + timeout;
|
|
164
|
+
while (Date.now() < end) {
|
|
165
|
+
try {
|
|
166
|
+
if (await evaluate(s, `(()=>{const e=document.querySelector(${JSON.stringify(selector)});if(!e)return false;if(!${visible})return true;const r=e.getBoundingClientRect();return r.width>0&&r.height>0})()`)) return true;
|
|
167
|
+
} catch {}
|
|
168
|
+
await delay(100);
|
|
169
|
+
}
|
|
170
|
+
return false;
|
|
171
|
+
},
|
|
172
|
+
async fill_input(selector,text){if(!await h.wait_for_element(selector))throw Error(`element not found: ${selector}`);return evaluate(s,`(()=>{const e=document.querySelector(${JSON.stringify(selector)});const set=Object.getOwnPropertyDescriptor(Object.getPrototypeOf(e),'value')?.set;if(set)set.call(e,${JSON.stringify(String(text))});else e.textContent=${JSON.stringify(String(text))};e.dispatchEvent(new InputEvent('input',{bubbles:true,inputType:'insertText',data:${JSON.stringify(String(text))}}));e.dispatchEvent(new Event('change',{bubbles:true}));return true})()`)},
|
|
173
|
+
async click(selector){if(!await h.wait_for_element(selector))throw Error(`element not found: ${selector}`);return evaluate(s,`(()=>{const e=document.querySelector(${JSON.stringify(selector)});e.click();return true})()`)},
|
|
174
|
+
async capture_screenshot(pathname){const b=await connectBrowser();const data=await b.send('Page.captureScreenshot',{format:'png'},sidFor(s));if(pathname)fs.writeFileSync(pathname,Buffer.from(data.data,'base64'));return data.data},
|
|
175
|
+
async snapshot(interactive_only=false){const result=await evaluate(s,`(()=>{let n=0;const xs=[...document.querySelectorAll('button,a,input,textarea,select,[role],[tabindex]')].filter(e=>{const r=e.getBoundingClientRect(),s=getComputedStyle(e);return r.width&&r.height&&s.visibility!=='hidden'&&s.display!=='none'});return xs.map(e=>{const id='e'+(++n);e.setAttribute('data-ref',id);const role=e.getAttribute('role')||({BUTTON:'button',A:'link',INPUT:'textbox',TEXTAREA:'textbox',SELECT:'combobox'}[e.tagName]||e.tagName.toLowerCase());const name=e.getAttribute('aria-label')||e.innerText||e.value||e.placeholder||'';return '- '+role+' '+JSON.stringify(name.trim().slice(0,100))+' [ref='+id+']'}).join('\\n')})()`);return result||''},
|
|
176
|
+
async workspace_status(){return extensionCall(s,'workspace.status',{name:s.workspace})},
|
|
177
|
+
async workspace_capacity(){const r=await h.workspace_status();const idle=Array.isArray(r.idleTabIds)?r.idleTabIds.length:null,leased=Array.isArray(r.leasedTabIds)?r.leasedTabIds.length:null;return {name:r.name,poolSize:r.poolSize??null,maxCapacity:r.maxCapacity??r.poolSize??null,physicalTabs:r.physicalTabs??(idle!==null&&leased!==null?idle+leased:null),idle,leased,available:Number.isInteger(r.maxCapacity??r.poolSize)&&leased!==null?Math.max(0,(r.maxCapacity??r.poolSize)-leased):null}} ,
|
|
178
|
+
async workspace_list(){return extensionCall(s,'workspace.list')},
|
|
179
|
+
async workspace_create(name,poolSize=5){return extensionCall(s,'workspace.create',{name,poolSize})},
|
|
180
|
+
async workspace_resize(name,poolSize){return extensionCall(s,'workspace.resize',{name,poolSize})},
|
|
181
|
+
async workspace_delete(name,force=false){return extensionCall(s,'workspace.delete',{name,force})},
|
|
182
|
+
async wait(seconds){return delay(seconds*1000)},
|
|
183
|
+
async sleep(milliseconds){return delay(milliseconds)},
|
|
184
|
+
async type_text(text){return cdp(s,'Input.insertText',{text:String(text)})},
|
|
185
|
+
async upload_file(selector, filename){
|
|
186
|
+
if (!fs.existsSync(filename)) throw Error(`upload file does not exist: ${filename}`);
|
|
187
|
+
const root=await cdp(s,'DOM.getDocument',{depth:-1});
|
|
188
|
+
const {nodeId}=await cdp(s,'DOM.querySelector',{nodeId:root.root.nodeId,selector});
|
|
189
|
+
if (!nodeId) throw Error(`upload input not found: ${selector}`);
|
|
190
|
+
await cdp(s,'DOM.setFileInputFiles',{nodeId,files:[path.resolve(filename)]});
|
|
191
|
+
return {uploaded:true};
|
|
192
|
+
},
|
|
193
|
+
async list_tabs(){
|
|
194
|
+
const {targetInfos=[]}=await connectBrowser().then(b=>b.send('Target.getTargets'));
|
|
195
|
+
return targetInfos.filter(t=>t.type==='page'&&s.targets.has(t.targetId));
|
|
196
|
+
},
|
|
197
|
+
async switch_tab(target){
|
|
198
|
+
const tid=typeof target==='string'?target:target?.targetId||target?.target_id;
|
|
199
|
+
if (!s.targets.has(tid)) throw Error('cannot switch to a tab outside the active WebHelm session');
|
|
200
|
+
if (!s.attachments.has(tid)) await attach(s,tid);
|
|
201
|
+
s.targetId=tid;
|
|
202
|
+
return tid;
|
|
203
|
+
},
|
|
204
|
+
async activate_tab(target){
|
|
205
|
+
const tid=typeof target==='string'?target:target?.targetId||target?.target_id||s.targetId;
|
|
206
|
+
if (!s.targets.has(tid)) throw Error('cannot activate a tab outside the active WebHelm session');
|
|
207
|
+
return cdp(s,'Target.activateTarget',{targetId:tid});
|
|
208
|
+
},
|
|
209
|
+
events(){const e=s.events.splice(0);return e},
|
|
210
|
+
async current_url(){return (await h.page_info()).url},
|
|
211
|
+
async title(){return (await h.page_info()).title}
|
|
212
|
+
};
|
|
213
|
+
Object.defineProperty(h,'target_id',{get:()=>s.targetId});
|
|
214
|
+
Object.defineProperty(h,'targetId',{get:()=>s.targetId});
|
|
215
|
+
return h;
|
|
216
|
+
}
|
|
217
|
+
const EXTENSION_ID='kkkpgmhpoeceojalkpfdidffdfnlgdog';
|
|
218
|
+
const EXTENSION_PREFIX=`chrome-extension://${EXTENSION_ID}/`;
|
|
219
|
+
let debuggerSession=null;
|
|
220
|
+
function extensionConfigured(){
|
|
221
|
+
if(process.env.WEBHELM_NO_PROFILE_EXTENSION_PROBE==='1')return false;
|
|
222
|
+
// A sleeping MV3 worker has no CDP target. Chrome records the installed
|
|
223
|
+
// extension in Secure Preferences even when no extension page is open.
|
|
224
|
+
for(const filename of ['Default/Secure Preferences','Default/Preferences']){
|
|
225
|
+
try{
|
|
226
|
+
const config=JSON.parse(fs.readFileSync(path.join(PROFILE_DIR,filename),'utf8'));
|
|
227
|
+
const extension=config.extensions?.settings?.[EXTENSION_ID];
|
|
228
|
+
if(extension)return extension.state!==0;
|
|
229
|
+
}catch{}
|
|
230
|
+
}
|
|
231
|
+
return false;
|
|
232
|
+
}
|
|
233
|
+
async function evaluateExtension(browser,sessionId,expression){
|
|
234
|
+
const result=await browser.send('Runtime.evaluate',{
|
|
235
|
+
expression,returnByValue:true,awaitPromise:true},sessionId);
|
|
236
|
+
if(result.exceptionDetails)throw Error(result.exceptionDetails.exception?.description||result.exceptionDetails.text||'extension evaluation failed');
|
|
237
|
+
const value=result.result?.value;
|
|
238
|
+
if(!value?.ok)throw Error(value?.error?.message||'workspace extension RPC failed');
|
|
239
|
+
return value.result;
|
|
240
|
+
}
|
|
241
|
+
async function extensionCall(_session,method,args={}) {
|
|
242
|
+
const browser=await connectBrowser();
|
|
243
|
+
const request=JSON.stringify({method,args});
|
|
244
|
+
const {targetInfos=[]}=await browser.send('Target.getTargets');
|
|
245
|
+
const worker=targetInfos.find(t=>t.type==='service_worker'&&t.url.startsWith(EXTENSION_PREFIX));
|
|
246
|
+
if(worker){
|
|
247
|
+
if(debuggerSession?.targetId!==worker.targetId){
|
|
248
|
+
debuggerSession=null;
|
|
249
|
+
const {sessionId}=await browser.send('Target.attachToTarget',{targetId:worker.targetId,flatten:true});
|
|
250
|
+
debuggerSession={targetId:worker.targetId,sessionId};
|
|
251
|
+
}
|
|
252
|
+
try{return await evaluateExtension(browser,debuggerSession.sessionId,
|
|
253
|
+
`globalThis.browserWorkspaceManagerRpc(${request})`);
|
|
254
|
+
}catch(error){debuggerSession=null;throw error;}
|
|
255
|
+
}
|
|
256
|
+
const page=targetInfos.find(t=>t.type==='page'&&t.url.startsWith(EXTENSION_PREFIX)&&t.url.includes('/workspace.html'));
|
|
257
|
+
if(page){
|
|
258
|
+
const {sessionId}=await browser.send('Target.attachToTarget',{targetId:page.targetId,flatten:true});
|
|
259
|
+
try{return await evaluateExtension(browser,sessionId,`chrome.runtime.sendMessage(${request})`);}
|
|
260
|
+
finally{await browser.send('Target.detachFromTarget',{sessionId}).catch(()=>{});}
|
|
261
|
+
}
|
|
262
|
+
if(!extensionConfigured())throw Error('WebHelm workspace extension is not installed or enabled');
|
|
263
|
+
// Wake a suspended MV3 worker from a temporary background extension page.
|
|
264
|
+
// Only this temporary target is closed; no user tab is reused or modified.
|
|
265
|
+
const {targetId}=await browser.send('Target.createTarget',{
|
|
266
|
+
url:`${EXTENSION_PREFIX}workspace.html?webhelm_wakeup=1`,background:true});
|
|
267
|
+
let sessionId;
|
|
268
|
+
try{
|
|
269
|
+
({sessionId}=await browser.send('Target.attachToTarget',{targetId,flatten:true}));
|
|
270
|
+
let ready=false;
|
|
271
|
+
for(let attempt=0;attempt<20;attempt++){
|
|
272
|
+
try{
|
|
273
|
+
const reply=await browser.send('Runtime.evaluate',{
|
|
274
|
+
expression:`typeof chrome!=='undefined'&&chrome.runtime?.id===${JSON.stringify(EXTENSION_ID)}`,
|
|
275
|
+
returnByValue:true},sessionId);
|
|
276
|
+
if(reply.result?.value===true){ready=true;break;}
|
|
277
|
+
}catch{}
|
|
278
|
+
await delay(100);
|
|
279
|
+
}
|
|
280
|
+
if(!ready)throw Error('WebHelm installed extension did not initialize its background page');
|
|
281
|
+
return await evaluateExtension(browser,sessionId,`chrome.runtime.sendMessage(${request})`);
|
|
282
|
+
}finally{
|
|
283
|
+
if(sessionId)await browser.send('Target.detachFromTarget',{sessionId}).catch(()=>{});
|
|
284
|
+
await browser.send('Target.closeTarget',{targetId}).catch(()=>{});
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function jsonSafe(value){try{JSON.stringify(value);return {value}}catch{return {value_repr:String(value)}}}
|
|
289
|
+
async function createSession(req={}) {
|
|
290
|
+
const browser=await connectBrowser();
|
|
291
|
+
const sid=crypto.randomBytes(8).toString('hex');
|
|
292
|
+
const s={session_id:sid, workspace:req.workspace||'Harness', targetId:null,
|
|
293
|
+
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()};
|
|
295
|
+
let targetId,lease;
|
|
296
|
+
try {
|
|
297
|
+
if(await extensionSupported()) {
|
|
298
|
+
({lease,targetId}=await acquireIdentityLease(s));
|
|
299
|
+
s.leasedTabs.set(targetId,lease.tabId);
|
|
300
|
+
s.workspaceSupported=true;
|
|
301
|
+
} else {
|
|
302
|
+
({targetId}=await browser.send('Target.createTarget',{url:'about:blank'}));
|
|
303
|
+
}
|
|
304
|
+
s.targetId=targetId;
|
|
305
|
+
s.rootTargetId=targetId;
|
|
306
|
+
await attach(s,targetId);
|
|
307
|
+
if(req.url && req.url!=='about:blank')await browser.send('Page.navigate',{url:req.url},sidFor(s,targetId));
|
|
308
|
+
else if (lease) await browser.send('Page.navigate',{url:'about:blank'},sidFor(s,targetId));
|
|
309
|
+
const h=makeHelpers(s);
|
|
310
|
+
s.namespace={...h,helpers:h,session_id:sid};
|
|
311
|
+
s.context=vm.createContext(s.namespace);
|
|
312
|
+
sessions.set(sid,s);
|
|
313
|
+
return {session_id:sid,workspace:s.workspace,target_id:targetId,
|
|
314
|
+
url:req.url||'about:blank',workspace_supported:!!lease};
|
|
315
|
+
}catch(error){
|
|
316
|
+
if(lease){
|
|
317
|
+
try{await extensionCall(s,'workspace.release',{name:s.workspace,tabId:lease.tabId});}
|
|
318
|
+
catch{if(targetId)await browser.send('Target.closeTarget',{targetId}).catch(()=>{});}
|
|
319
|
+
}else if(targetId)await browser.send('Target.closeTarget',{targetId}).catch(()=>{});
|
|
320
|
+
throw error;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
async function targetForUrl(url){for(let i=0;i<100;i++){const {targetInfos=[]}=await connectBrowser().then(b=>b.send('Target.getTargets'));const match=targetInfos.find(t=>t.type==='page'&&t.url===url);if(match)return match.targetId;await delay(50)}return null}
|
|
325
|
+
async function extensionSupported() {
|
|
326
|
+
const browser=await connectBrowser();
|
|
327
|
+
const {targetInfos=[]}=await browser.send('Target.getTargets');
|
|
328
|
+
const installed=targetInfos.some(t=>t.url?.startsWith(EXTENSION_PREFIX))||extensionConfigured();
|
|
329
|
+
if(!installed)return false;
|
|
330
|
+
let error;
|
|
331
|
+
for(let attempt=0;attempt<3;attempt++){
|
|
332
|
+
try{await extensionCall({workspace:'Harness'},'workspace.list');return true;}
|
|
333
|
+
catch(e){error=e;if(attempt<2)await delay(150);}
|
|
334
|
+
}
|
|
335
|
+
throw Error(`Browser Workspace extension installed but RPC unavailable: ${error?.message||'unknown'}`);
|
|
336
|
+
}
|
|
337
|
+
|
|
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}`}}})}
|
|
339
|
+
async function stopSession(sid){
|
|
340
|
+
const s=sessions.get(sid);
|
|
341
|
+
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};
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
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}`)}
|
|
370
|
+
function getChatGPTQueue() {
|
|
371
|
+
if (chatgptQueue) return chatgptQueue;
|
|
372
|
+
chatgptQueue = new ChatGPTQueue({
|
|
373
|
+
persistenceRoot: RUNTIME,
|
|
374
|
+
startSession: ({url}) => createSession({url}),
|
|
375
|
+
execSession: (session_id, code) => execute({session_id, code}),
|
|
376
|
+
stopSession,
|
|
377
|
+
logger: entry => console.error(JSON.stringify({component:'chatgpt-queue',...entry})),
|
|
378
|
+
});
|
|
379
|
+
// Keep the durable queue intact during a cutover smoke check. A paused
|
|
380
|
+
// daemon accepts queue writes but never starts its dispatcher.
|
|
381
|
+
if(process.env.WEBHELM_CHATGPT_QUEUE_PAUSED!=='1')chatgptQueue.start();
|
|
382
|
+
return chatgptQueue;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
async function workspaceCapacity(req={}) {
|
|
386
|
+
const name=typeof req.workspace==='string'&&req.workspace.trim()?req.workspace.trim():'Harness';
|
|
387
|
+
const observedAt=new Date().toISOString();
|
|
388
|
+
try {
|
|
389
|
+
const state=await extensionCall(null,'workspace.status',{name});
|
|
390
|
+
const idle=Array.isArray(state.idleTabIds)?state.idleTabIds.length:
|
|
391
|
+
Number.isInteger(state.idle)?state.idle:null;
|
|
392
|
+
const leased=Array.isArray(state.leasedTabIds)?state.leasedTabIds.length:
|
|
393
|
+
Number.isInteger(state.leased)?state.leased:null;
|
|
394
|
+
const poolSize=Number.isInteger(state.poolSize)?state.poolSize:null;
|
|
395
|
+
const maximum=Number.isInteger(state.maxCapacity)?state.maxCapacity:poolSize;
|
|
396
|
+
const available=maximum!==null&&leased!==null?Math.max(0,maximum-leased):null;
|
|
397
|
+
return {name,poolSize,idle,leased,available,observedAt,reason:null};
|
|
398
|
+
} catch(error) {
|
|
399
|
+
return {name,poolSize:null,idle:null,leased:null,available:null,
|
|
400
|
+
observedAt,reason:`WebHelm capacity unavailable: ${error.message}`};
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/** Emit one canonical machine-readable action receipt as the final stdout line. */
|
|
405
|
+
export function actionOutput(logs, value) {
|
|
406
|
+
const entries=Array.isArray(logs)?logs:[];
|
|
407
|
+
const receipt=value===undefined?null:JSON.stringify(value);
|
|
408
|
+
const output=receipt!==null&&entries.at(-1)!==receipt?[...entries,receipt]:entries;
|
|
409
|
+
return output.join('\n').slice(-40000);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
async function runPlatform(req) {
|
|
413
|
+
const platform=req.platform, action=req.action, config=req.config || {};
|
|
414
|
+
const auto=Boolean(req.auto_session);
|
|
415
|
+
const started=auto ? await createSession({url:req.url}) : {session_id:req.session_id};
|
|
416
|
+
const sid=started.session_id;
|
|
417
|
+
if (!sid) throw Error('platform run requires a session_id or auto_session');
|
|
418
|
+
const session=sessions.get(sid);
|
|
419
|
+
if (!session) throw Error(`unknown session_id: ${sid}`);
|
|
420
|
+
let result, cleanup;
|
|
421
|
+
try {
|
|
422
|
+
result=await session.mutex.run(async()=>{
|
|
423
|
+
const logs=[];
|
|
424
|
+
const helpers=makeHelpers(session);
|
|
425
|
+
helpers.enqueueChatGPTTurn=request=>getChatGPTQueue().enqueue(request);
|
|
426
|
+
helpers.chatgptQueue=getChatGPTQueue();
|
|
427
|
+
try {
|
|
428
|
+
const value=await invokePlatformAction(platform,action,{
|
|
429
|
+
helpers, config,
|
|
430
|
+
log:(...items)=>logs.push(items.map(v=>typeof v==='string'?v:JSON.stringify(v)).join(' ')),
|
|
431
|
+
});
|
|
432
|
+
// Keep the legacy stdout JSON receipt for NeoY/Relay callers, while
|
|
433
|
+
// retaining the structured `value` for new integrations. Do not print
|
|
434
|
+
// a duplicate receipt if the Action already logged its JSON result.
|
|
435
|
+
return {ok:true,stdout:actionOutput(logs,value),stderr:'',value};
|
|
436
|
+
} catch(error) {
|
|
437
|
+
return {ok:false,stdout:logs.join('\n').slice(-40000),stderr:'',error:`${error.name}: ${error.message}`};
|
|
438
|
+
}
|
|
439
|
+
});
|
|
440
|
+
} finally {
|
|
441
|
+
cleanup=auto ? await stopSession(sid).catch(e=>({release_error:e.message})) : {caller_owned_session:true};
|
|
442
|
+
}
|
|
443
|
+
result.cleanup=cleanup;
|
|
444
|
+
if (platform==='chatgpt' && action==='new-turn' && config.mode==='sync' && result.ok) {
|
|
445
|
+
if (result.value?.status!=='submitted' || result.value?.thread_id!==String(config.thread_id) || !result.value?.verified_by) {
|
|
446
|
+
result={ok:false,error:'synchronous ChatGPT new-turn did not return a verified submission receipt',cleanup};
|
|
447
|
+
} else Object.assign(result,result.value);
|
|
448
|
+
}
|
|
449
|
+
return {platform,action,workspace_supported:started.workspace_supported??null,
|
|
450
|
+
session_mode:auto?'auto_session_no_self_heal':'caller_owned_self_heal_capable',
|
|
451
|
+
self_heal_capable:!auto,result};
|
|
452
|
+
}
|
|
453
|
+
|
|
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}`)}}
|
|
455
|
+
function serve(conn) {
|
|
456
|
+
let buffer = '';
|
|
457
|
+
conn.setEncoding('utf8');
|
|
458
|
+
conn.on('data', async (chunk) => {
|
|
459
|
+
buffer += chunk;
|
|
460
|
+
const newline = buffer.indexOf('\n');
|
|
461
|
+
if (newline < 0) return;
|
|
462
|
+
const raw = buffer.slice(0, newline);
|
|
463
|
+
try {
|
|
464
|
+
const reply = await handle(JSON.parse(raw));
|
|
465
|
+
conn.end(JSON.stringify(reply) + '\n');
|
|
466
|
+
} catch (error) {
|
|
467
|
+
conn.end(JSON.stringify({ error: `${error.name}: ${error.message}` }) + '\n');
|
|
468
|
+
}
|
|
469
|
+
});
|
|
470
|
+
}
|
|
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)}
|
|
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)})}
|
|
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')}`)}
|
|
475
|
+
if(process.argv[2]==='daemon')main().catch(e=>{console.error(e);process.exit(1)});
|
|
476
|
+
export {request,SOCKET,RUNTIME};
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
4
|
+
import { parse as parseYaml } from 'yaml';
|
|
5
|
+
import { ACTIONS_DIR } from './actions-repo.mjs';
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
export const PLATFORMS = path.resolve(process.env.WEBHELM_PLATFORM_DIR || path.join(ACTIONS_DIR,'platforms'));
|
|
9
|
+
|
|
10
|
+
function namePart(name) {
|
|
11
|
+
if (typeof name !== 'string' || !/^[a-z0-9][a-z0-9-]*$/i.test(name)) {
|
|
12
|
+
throw new Error(`invalid platform or action name: ${name}`);
|
|
13
|
+
}
|
|
14
|
+
return name;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function listPlatforms() {
|
|
18
|
+
return fs.readdirSync(PLATFORMS, { withFileTypes: true })
|
|
19
|
+
.filter(item => item.isDirectory() && fs.existsSync(path.join(PLATFORMS, item.name, 'manifest.yaml')))
|
|
20
|
+
.map(item => item.name).sort();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function platformManifest(platform) {
|
|
24
|
+
const file = path.join(PLATFORMS, namePart(platform), 'manifest.yaml');
|
|
25
|
+
if (!fs.existsSync(file)) throw new Error(`unknown platform: ${platform}`);
|
|
26
|
+
const manifest = parseYaml(fs.readFileSync(file, 'utf8'), { uniqueKeys: true });
|
|
27
|
+
if (!manifest || typeof manifest !== 'object' || manifest.platform !== platform) {
|
|
28
|
+
throw new Error(`invalid platform manifest: ${platform}`);
|
|
29
|
+
}
|
|
30
|
+
return manifest;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function platformProfile(platform) {
|
|
34
|
+
const manifest = platformManifest(platform);
|
|
35
|
+
const fields = ['platform', 'display_name', 'aliases', 'implementation_status', 'media', 'fields', 'capabilities', 'content_mapping', 'notes'];
|
|
36
|
+
return Object.fromEntries(fields.filter(k => k in manifest).map(k => [k, manifest[k]]));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function actionPath(platform, action) {
|
|
40
|
+
const manifest = platformManifest(platform);
|
|
41
|
+
const name = namePart(action.replaceAll('_', '-'));
|
|
42
|
+
const relative = manifest.actions?.[name];
|
|
43
|
+
if (typeof relative !== 'string' || !/^actions\/[\w-]+\.mjs$/.test(relative)) {
|
|
44
|
+
throw new Error(`unknown or non-Node platform action ${platform}/${name}`);
|
|
45
|
+
}
|
|
46
|
+
const file = path.resolve(PLATFORMS, platform, relative);
|
|
47
|
+
if (!file.startsWith(path.resolve(PLATFORMS, platform) + path.sep) || !fs.existsSync(file)) {
|
|
48
|
+
throw new Error(`Node action missing or outside platform: ${platform}/${name}`);
|
|
49
|
+
}
|
|
50
|
+
return file;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function validateConfig(platform, action, config) {
|
|
54
|
+
if (!config || typeof config !== 'object' || Array.isArray(config)) throw new Error('platform config must be a JSON object');
|
|
55
|
+
if (action !== 'post') return;
|
|
56
|
+
const { fields = {}, media = {} } = platformProfile(platform);
|
|
57
|
+
for (const [name, spec] of Object.entries(fields)) {
|
|
58
|
+
if (typeof spec !== 'object' || spec?.type === 'operation') continue;
|
|
59
|
+
const value = config[name];
|
|
60
|
+
if (spec.required && (value === undefined || value === null || value === '' || (Array.isArray(value) && !value.length))) {
|
|
61
|
+
throw new Error(`${platform}: missing required field ${name}`);
|
|
62
|
+
}
|
|
63
|
+
if (value === undefined || value === null) continue;
|
|
64
|
+
if (spec.type === 'string') {
|
|
65
|
+
const text = String(value);
|
|
66
|
+
const max = spec.max_chars ?? spec.max_chars_including_tags;
|
|
67
|
+
if (spec.min_chars !== undefined && text && text.length < spec.min_chars) throw new Error(`${name} too short`);
|
|
68
|
+
if (max !== undefined && text.length > max) throw new Error(`${name} too long`);
|
|
69
|
+
} else if (spec.type === 'string_list') {
|
|
70
|
+
const values = Array.isArray(value) ? value : String(value).split(',').map(v => v.trim()).filter(Boolean);
|
|
71
|
+
if (spec.max_count !== undefined && values.length > spec.max_count) throw new Error(`${name} has too many items`);
|
|
72
|
+
if (spec.max_chars_each !== undefined && values.some(v => String(v).length > spec.max_chars_each)) throw new Error(`${name} item too long`);
|
|
73
|
+
if (spec.max_total_chars !== undefined && values.reduce((n,v) => n + String(v).length,0) > spec.max_total_chars) throw new Error(`${name} total length exceeds limit`);
|
|
74
|
+
} else if (spec.type === 'enum' && !(spec.values || []).includes(value)) {
|
|
75
|
+
throw new Error(`${name} must be one of: ${(spec.values || []).join(', ')}`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
const maxImages = media.images?.max_count;
|
|
79
|
+
if (maxImages !== undefined && (config.images || []).length > maxImages) throw new Error('images exceeds platform maximum');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export async function invokePlatformAction(platform, action, { helpers, config, log }) {
|
|
83
|
+
const file = actionPath(platform, action);
|
|
84
|
+
validateConfig(platform, action, config);
|
|
85
|
+
const module = await import(pathToFileURL(file).href + `?mtime=${fs.statSync(file).mtimeMs}`);
|
|
86
|
+
const runner = module.run || module.default;
|
|
87
|
+
if (typeof runner !== 'function') throw new Error(`Node action does not export a run function: ${platform}/${action}`);
|
|
88
|
+
return runner({ helpers, config, log });
|
|
89
|
+
}
|