@hybridlabor-api/aos 4.6.3 → 4.6.5

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.
@@ -4,9 +4,10 @@
4
4
  //
5
5
  // aos-dashboard [--port 7900] [--no-open]
6
6
  //
7
- // Binds to 127.0.0.1 only. Control actions run launchctl against a fixed
8
- // service table — the request never reaches a shell, and an id that is not in
9
- // the table is refused, so nothing a browser sends can widen what this can do.
7
+ // Binds to 127.0.0.1 only. Control actions run against a fixed service table
8
+ // using launchctl on macOS or the corresponding Windows service wrapper/task —
9
+ // the request never reaches a shell, and an id that is not in the table is
10
+ // refused, so nothing a browser sends can widen what this can do.
10
11
 
11
12
  import { createServer } from 'node:http';
12
13
  import { connect, createServer as createTcpServer } from 'node:net';
@@ -22,6 +23,9 @@ const execFileP = promisify(execFile);
22
23
  const HOME = os.homedir();
23
24
  const h = (...p) => path.join(HOME, ...p);
24
25
  const IS_MAC = process.platform === 'darwin';
26
+ const WINDOWS_STARTUP_DIR = process.env.APPDATA
27
+ ? path.join(process.env.APPDATA, 'Microsoft', 'Windows', 'Start Menu', 'Programs', 'Startup')
28
+ : h('AppData', 'Roaming', 'Microsoft', 'Windows', 'Start Menu', 'Programs', 'Startup');
25
29
  const PKG_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
26
30
  const BRAND_DIR = path.join(PKG_ROOT, 'assets', 'brand');
27
31
 
@@ -104,6 +108,14 @@ const SERVICES = [
104
108
 
105
109
  const byId = new Map(SERVICES.map((s) => [s.id, s]));
106
110
 
111
+ // Windows uses the VBS wrappers installed by the AOS installer for memB and
112
+ // Synapse. The process patterns are constants, never request data, so the
113
+ // dashboard cannot be turned into an arbitrary process launcher.
114
+ const WINDOWS_DAEMONS = {
115
+ memb: { vbs: 'com.bdb.memb.webui.vbs', processPattern: 'src[\\\\/]backend[\\\\/]server\\.py' },
116
+ synapse: { vbs: 'com.bdb.synapse.vbs', processPattern: 'synapse\\.js.*serve' },
117
+ };
118
+
107
119
  // ------------------------------------------------------------------ probing
108
120
  function portOpen(port, timeout = 800) {
109
121
  return new Promise((resolve) => {
@@ -127,11 +139,23 @@ const freePort = () => new Promise((resolve, reject) => {
127
139
  });
128
140
 
129
141
  async function agentLoaded(label) {
130
- if (!IS_MAC) return null;
131
- try {
132
- const { stdout } = await execFileP('launchctl', ['list']);
133
- return stdout.split('\n').some((l) => l.trim().endsWith(label));
134
- } catch { return null; }
142
+ if (IS_MAC) {
143
+ try {
144
+ const { stdout } = await execFileP('launchctl', ['list']);
145
+ return stdout.split('\n').some((l) => l.trim().endsWith(label));
146
+ } catch { return null; }
147
+ }
148
+ if (process.platform === 'win32' && label === 'com.bdb.openwiki.daemon') {
149
+ try {
150
+ const { stdout } = await execFileP('powershell.exe', [
151
+ '-NoProfile', '-Command',
152
+ "$task = Get-ScheduledTask -TaskName 'BDB_OpenWiki_Daemon' -ErrorAction SilentlyContinue; if ($task) { $task.State }",
153
+ ], { timeout: 5000 });
154
+ const state = stdout.trim();
155
+ return state === 'Ready' || state === 'Running';
156
+ } catch { return null; }
157
+ }
158
+ return null;
135
159
  }
136
160
 
137
161
  const localVersion = (dir) => {
@@ -300,7 +324,42 @@ async function control(id, action) {
300
324
  const svc = byId.get(id);
301
325
  if (!svc) throw new Error('unknown service');
302
326
  if (!['start', 'stop', 'restart'].includes(action)) throw new Error('unknown action');
303
- if (!IS_MAC) throw new Error('start/stop is wired for launchd (macOS) only');
327
+
328
+ if (process.platform === 'win32' && id === 'openwiki') {
329
+ // OpenWiki's refresh daemon is registered as a Scheduled Task on Windows,
330
+ // not as a launchd plist. Keep the command allowlisted and pass only a
331
+ // constant task name to PowerShell; browser input never becomes a command.
332
+ const run = (command) => execFileP('powershell.exe', [
333
+ '-NoProfile', '-Command', command,
334
+ ], { timeout: 10000 }).catch((e) => { throw new Error(e.stderr?.trim() || e.message); });
335
+ const task = "'BDB_OpenWiki_Daemon'";
336
+ if (action === 'stop' || action === 'restart') {
337
+ await run(`Stop-ScheduledTask -TaskName ${task} -ErrorAction SilentlyContinue`);
338
+ }
339
+ if (action === 'start' || action === 'restart') {
340
+ await run(`Start-ScheduledTask -TaskName ${task} -ErrorAction Stop`);
341
+ }
342
+ return { ok: true };
343
+ }
344
+
345
+ if (process.platform === 'win32' && WINDOWS_DAEMONS[id]) {
346
+ const daemon = WINDOWS_DAEMONS[id];
347
+ const vbsPath = path.join(WINDOWS_STARTUP_DIR, daemon.vbs);
348
+ if (!existsSync(vbsPath)) throw new Error(`no Windows startup wrapper installed for ${svc.name}`);
349
+ const run = (command) => execFileP('powershell.exe', [
350
+ '-NoProfile', '-Command', command,
351
+ ], { timeout: 10000 }).catch((e) => { throw new Error(e.stderr?.trim() || e.message); });
352
+ if (action === 'stop' || action === 'restart') {
353
+ const escapedPattern = daemon.processPattern.replace(/'/g, "''");
354
+ await run(`Get-CimInstance Win32_Process | Where-Object { $_.CommandLine -and $_.CommandLine -match '${escapedPattern}' } | ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }`);
355
+ }
356
+ if (action === 'start' || action === 'restart') {
357
+ await execFileP('wscript.exe', [vbsPath], { timeout: 10000 });
358
+ }
359
+ return { ok: true };
360
+ }
361
+
362
+ if (!IS_MAC) throw new Error('start/stop is not available on this platform for this service');
304
363
 
305
364
  const plist = h('Library', 'LaunchAgents', `${svc.agent}.plist`);
306
365
  if (!existsSync(plist)) throw new Error(`no LaunchAgent installed for ${svc.name}`);
package/installer.js CHANGED
@@ -241,6 +241,42 @@ function hasExecutable(binary) {
241
241
  }
242
242
  }
243
243
 
244
+ // Windows installers launched from an existing terminal can inherit a stale
245
+ // PATH after Go was installed through winget or the official installer. The
246
+ // Synapse JS launcher shells out to Go on first run, so discover the standard
247
+ // install locations as well as PATH and persist the resolved bin directory in
248
+ // the generated startup wrapper.
249
+ function findWindowsGoBin() {
250
+ if (process.platform !== 'win32') return null;
251
+ const candidates = [];
252
+ try {
253
+ const found = execSync('where.exe go', { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] })
254
+ .split(/\r?\n/).map(s => s.trim()).find(Boolean);
255
+ if (found) candidates.push(path.dirname(found));
256
+ } catch (_) { /* PATH can be stale immediately after installation */ }
257
+
258
+ const roots = [
259
+ process.env.GOROOT,
260
+ process.env.ProgramW6432,
261
+ process.env.ProgramFiles,
262
+ process.env.LOCALAPPDATA ? path.join(process.env.LOCALAPPDATA, 'Programs') : null,
263
+ ].filter(Boolean);
264
+ for (const root of roots) {
265
+ candidates.push(path.join(root, 'Go', 'bin'));
266
+ candidates.push(path.join(root, 'go', 'bin'));
267
+ }
268
+ return candidates.find(dir => fs.existsSync(path.join(dir, 'go.exe'))) || null;
269
+ }
270
+
271
+ function verifyPythonImports(pythonPath, cwd, importCode) {
272
+ const result = spawnSync(pythonPath, ['-c', importCode], {
273
+ cwd,
274
+ stdio: 'ignore',
275
+ timeout: 30000,
276
+ });
277
+ return !result.error && result.status === 0;
278
+ }
279
+
244
280
  function resolveUnsupportedMcpConfigKeys() {
245
281
  const keys = unsupportedMcpConfigKeys.slice();
246
282
  conditionalMcpConfigKeys.forEach(entry => {
@@ -1072,6 +1108,15 @@ function moveIfExists(src, dest, label) {
1072
1108
  }
1073
1109
  }
1074
1110
 
1111
+ function safeRmDirSync(dirPath, maxRetries = 5, retryDelay = 200) {
1112
+ if (!dirPath || !fs.existsSync(dirPath)) return;
1113
+ try {
1114
+ fs.rmSync(dirPath, { recursive: true, force: true, maxRetries, retryDelay });
1115
+ } catch (err) {
1116
+ logDebug(err, `safeRmDirSync: ${dirPath}`);
1117
+ }
1118
+ }
1119
+
1075
1120
  function copyDirRecursiveSync(source, target, excludeList = [], manifest = null, knownSourceHashes = null) {
1076
1121
  // Fall back to session-level state when no explicit manifest is passed.
1077
1122
  const mfst = manifest !== null ? manifest : _sessionManifest;
@@ -1722,7 +1767,21 @@ function downloadOrUpdateModule(pkgName, targetDir, displayName) {
1722
1767
  // kept until the swap succeeds, and restored if it does not.
1723
1768
  const stagingDir = `${targetDir}.incoming-${timestamp}`;
1724
1769
  const retiredDir = `${targetDir}.previous-${timestamp}`;
1725
- fs.rmSync(stagingDir, { recursive: true, force: true });
1770
+
1771
+ // Clean up any stale staging or retired directories left behind by earlier interrupted runs
1772
+ try {
1773
+ const parentDir = path.dirname(targetDir);
1774
+ const baseName = path.basename(targetDir);
1775
+ if (fs.existsSync(parentDir)) {
1776
+ for (const item of fs.readdirSync(parentDir)) {
1777
+ if (item.startsWith(`${baseName}.incoming-`) || item.startsWith(`${baseName}.previous-`)) {
1778
+ safeRmDirSync(path.join(parentDir, item));
1779
+ }
1780
+ }
1781
+ }
1782
+ } catch (_) {}
1783
+
1784
+ safeRmDirSync(stagingDir);
1726
1785
  fs.mkdirSync(stagingDir, { recursive: true });
1727
1786
  execSync(`tar -xzf "${path.join(targetDir, tarball)}" --strip-components=1 -C "${stagingDir}"`, { stdio: 'ignore' });
1728
1787
  fs.unlinkSync(path.join(targetDir, tarball));
@@ -1733,7 +1792,11 @@ function downloadOrUpdateModule(pkgName, targetDir, displayName) {
1733
1792
  for (const keep of PRESERVE) {
1734
1793
  const from = path.join(targetDir, keep);
1735
1794
  if (fs.existsSync(from) && !fs.existsSync(path.join(stagingDir, keep))) {
1736
- fs.renameSync(from, path.join(stagingDir, keep));
1795
+ try {
1796
+ fs.renameSync(from, path.join(stagingDir, keep));
1797
+ } catch (preserveErr) {
1798
+ logDebug(preserveErr, `preserve ${keep}`);
1799
+ }
1737
1800
  }
1738
1801
  }
1739
1802
 
@@ -1741,11 +1804,17 @@ function downloadOrUpdateModule(pkgName, targetDir, displayName) {
1741
1804
  fs.renameSync(targetDir, retiredDir);
1742
1805
  fs.renameSync(stagingDir, targetDir);
1743
1806
  } catch (swapError) {
1744
- if (fs.existsSync(retiredDir) && !fs.existsSync(targetDir)) fs.renameSync(retiredDir, targetDir);
1745
- fs.rmSync(stagingDir, { recursive: true, force: true });
1807
+ if (fs.existsSync(retiredDir) && !fs.existsSync(targetDir)) {
1808
+ try { fs.renameSync(retiredDir, targetDir); } catch (_) {}
1809
+ }
1810
+ safeRmDirSync(stagingDir);
1746
1811
  throw swapError;
1747
1812
  }
1748
- fs.rmSync(retiredDir, { recursive: true, force: true });
1813
+
1814
+ // On Windows especially, deleting a directory that was just renamed or contains
1815
+ // locked file handles can throw EPERM / EBUSY. Since targetDir already has the
1816
+ // new version in place, failure to clean up retiredDir must be non-fatal.
1817
+ safeRmDirSync(retiredDir);
1749
1818
 
1750
1819
  s.stop(`${displayName} ready (v${status.remoteVer || 'latest'})`);
1751
1820
  return true;
@@ -1810,6 +1879,17 @@ async function installMemB(interactive) {
1810
1879
  if (!hasUv) {
1811
1880
  try { execSync('uv --version', { stdio: 'ignore' }); hasUv = true; } catch (e) { hasUv = false; }
1812
1881
  }
1882
+ if (!hasUv && fs.existsSync(venvPython)) {
1883
+ try {
1884
+ execSync(`"${venvPython}" -m pip --version`, { stdio: 'ignore' });
1885
+ } catch (_) {
1886
+ try {
1887
+ execSync(`"${venvPython}" -m ensurepip --default-pip`, { cwd: membDir, stdio: 'ignore' });
1888
+ } catch (ePip) {
1889
+ logDebug(ePip, 'ensurepip bootstrap for memB');
1890
+ }
1891
+ }
1892
+ }
1813
1893
  const uvInstall = (pkgsArg) => `uv pip install --python "${venvPython}" ${pkgsArg}`;
1814
1894
  const pipInstall = (pkgsArg) => `"${venvPython}" -m pip install ${pkgsArg} --timeout 30 --no-input`;
1815
1895
  const installCmd = (pkgsArg) => hasUv ? uvInstall(pkgsArg) : pipInstall(pkgsArg);
@@ -1820,6 +1900,30 @@ async function installMemB(interactive) {
1820
1900
  if (installWebUI && fs.existsSync(serverPy)) {
1821
1901
  runPipWithRetry(installCmd('fastapi uvicorn'), { cwd: membDir, stdio: 'ignore' }, 'pip fastapi+uvicorn for memB WebUI', 2, 120000);
1822
1902
  }
1903
+
1904
+ // A partially upgraded venv can contain pydantic while missing or
1905
+ // mismatching pydantic-core. That failure only appears later when
1906
+ // the WebUI imports its models, so validate the actual interpreter
1907
+ // before registering the daemon and repair the pair in place.
1908
+ const importCheck = installWebUI && fs.existsSync(serverPy)
1909
+ ? 'import pydantic, pydantic_core; from fastapi import FastAPI; import uvicorn'
1910
+ : 'import pydantic, pydantic_core';
1911
+ if (!verifyPythonImports(venvPython, membDir, importCheck)) {
1912
+ log.warn('memB Python dependencies are inconsistent; repairing pydantic and pydantic-core.');
1913
+ const repairPackages = installWebUI && fs.existsSync(serverPy)
1914
+ ? '--force-reinstall "pydantic>=2.7.3" "pydantic-core>=2.18.4" fastapi uvicorn'
1915
+ : '--force-reinstall "pydantic>=2.7.3" "pydantic-core>=2.18.4"';
1916
+ const repaired = runPipWithRetry(
1917
+ installCmd(repairPackages),
1918
+ { cwd: membDir, stdio: 'inherit' },
1919
+ 'repair memB Python dependencies',
1920
+ 2,
1921
+ 900000,
1922
+ );
1923
+ if (repaired && !verifyPythonImports(venvPython, membDir, importCheck)) {
1924
+ log.warn('memB dependency repair did not pass the import check; inspect the venv before starting the daemon.');
1925
+ }
1926
+ }
1823
1927
  } catch (e) {
1824
1928
  log.warn(`Failed memB standalone venv setup: ${e.message}`);
1825
1929
  }
@@ -1973,11 +2077,20 @@ async function installSynapse() {
1973
2077
  const stderrLog = path.join(synapseLogDir, 'daemon.stderr.log');
1974
2078
  const batPath = path.join(synapseLogDir, 'run-synapse.bat');
1975
2079
  const runCmd = binaryPath.endsWith('.js') ? `node "${binaryPath}" serve --port 7781` : `"${binaryPath}" serve --port 7781`;
2080
+ const goBin = binaryPath.endsWith('.js') ? findWindowsGoBin() : null;
2081
+ if (binaryPath.endsWith('.js') && goBin) {
2082
+ log.step(`Synapse Windows wrapper will use Go from ${goBin}`);
2083
+ } else if (binaryPath.endsWith('.js')) {
2084
+ log.warn('Synapse Windows launcher requires Go on first run; install Go from https://go.dev/dl/ before starting the daemon.');
2085
+ }
1976
2086
  // WshShell.Run has no stdout/stderr redirection of its own, so a crash on launch
1977
2087
  // (e.g. a missing dependency the binary shells out to) used to die silently with
1978
2088
  // nothing to diagnose short of reading source — route through a .bat wrapper that
1979
2089
  // redirects to the same ~/.synapse log files the macOS launchd plist already writes.
1980
- const batContent = `@echo off\r\ncd /d "${synapseDir}"\r\n${runCmd} >> "${stdoutLog}" 2>> "${stderrLog}"\r\n`;
2090
+ const goSetup = goBin
2091
+ ? `set "PATH=${goBin.replace(/%/g, '%%')};%PATH%"\r\n`
2092
+ : `where.exe go >nul 2>&1 || (echo Go is required by the Synapse launcher. Install it from https://go.dev/dl/ and restart this daemon. >> "${stderrLog}" & exit /b 1)\r\n`;
2093
+ const batContent = `@echo off\r\n${goSetup}cd /d "${synapseDir}"\r\n${runCmd} >> "${stdoutLog}" 2>> "${stderrLog}"\r\n`;
1981
2094
  const vbsPath = path.join(startupDir, 'com.bdb.synapse.vbs');
1982
2095
  const vbsContent = `Set WshShell = CreateObject("WScript.Shell")\r\nWshShell.CurrentDirectory = "${synapseDir}"\r\nWshShell.Run """${batPath}""", 0, False\r\n`;
1983
2096
  try {
@@ -2076,11 +2189,12 @@ async function installOpenWikiVisualizer() {
2076
2189
  const ecosystemWiki = path.join(homeDir, '.openwiki', 'ecosystem-wiki');
2077
2190
  const syncScript = path.join(srcDir, 'skills', 'global_config', 'openwiki-skill', 'scripts', 'sync_ecosystem_wiki.py');
2078
2191
  if (!wikiPath) {
2192
+ const pyBin = hasExecutable('python3') ? 'python3' : hasExecutable('python') ? 'python' : null;
2079
2193
  if (looksLikeWiki(ecosystemWiki)) {
2080
2194
  wikiPath = ecosystemWiki;
2081
- } else if (fs.existsSync(syncScript) && hasExecutable('python3')) {
2195
+ } else if (fs.existsSync(syncScript) && pyBin) {
2082
2196
  try {
2083
- execSync(`python3 "${syncScript}"`, { stdio: 'ignore' });
2197
+ execSync(`"${pyBin}" "${syncScript}"`, { stdio: 'ignore' });
2084
2198
  if (looksLikeWiki(ecosystemWiki)) wikiPath = ecosystemWiki;
2085
2199
  } catch (e) { logDebug(e, 'ecosystem wiki sync'); }
2086
2200
  }
@@ -2091,6 +2205,9 @@ async function installOpenWikiVisualizer() {
2091
2205
  });
2092
2206
  if (!isCancel(answer) && answer && answer.trim()) {
2093
2207
  const candidate = answer.trim().replace(/^~(?=$|\/|\\)/, homeDir);
2208
+ if (!fs.existsSync(candidate)) {
2209
+ try { fs.mkdirSync(candidate, { recursive: true }); } catch (_) {}
2210
+ }
2094
2211
  if (fs.existsSync(candidate) && fs.statSync(candidate).isDirectory()) {
2095
2212
  wikiPath = candidate;
2096
2213
  if (!looksLikeWiki(candidate)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hybridlabor-api/aos",
3
- "version": "4.6.3",
3
+ "version": "4.6.5",
4
4
  "description": "AOS — A Curated AI AGENT OS. Optimized agent skills and add-ons like memB, OpenWiki, Heimdall Token Saver, and Godmode architectures.",
5
5
  "main": "installer.js",
6
6
  "engines": {