beehaven 0.8.29 → 0.8.34
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/dist/agent-attribution.js +20 -2
- package/dist/agent-scope.js +55 -10
- package/dist/agent-state.js +44 -0
- package/dist/agents-md-text.js +64 -0
- package/dist/browser-login.js +14 -4
- package/dist/build-channel.js +2 -2
- package/dist/claude-bridge.js +12 -9
- package/dist/clearly.js +4 -3
- package/dist/cli-state.js +45 -3
- package/dist/cli.js +82 -53
- package/dist/commands/agent-skills.js +12 -1
- package/dist/commands/agent.js +235 -28
- package/dist/commands/agents-md.js +118 -0
- package/dist/commands/daemon-cmd.js +2 -0
- package/dist/commands/help.js +21 -0
- package/dist/commands/self-update.js +32 -5
- package/dist/commands/workspace.js +22 -6
- package/dist/daemon-client.js +10 -6
- package/dist/daemon-entry.js +1 -0
- package/dist/daemon-process.js +125 -0
- package/dist/daemon.js +131 -62
- package/dist/dashboard.js +118 -6
- package/dist/ipc-auth.js +3 -1
- package/dist/ipc-client.js +7 -6
- package/dist/ipc-path.js +92 -0
- package/dist/ipc-server.js +33 -33
- package/dist/npm-update.js +138 -0
- package/dist/session-binding.js +135 -0
- package/dist/session-target.js +91 -0
- package/dist/setup-service.js +9 -4
- package/dist/verb-surface.js +1 -0
- package/dist/watcher.js +9 -23
- package/dist/windows-hide.js +77 -0
- package/package.json +2 -2
- package/scripts/sync-skills.mjs +6 -0
- package/skills/agent-doodle-space/SKILL.md +1 -1
- package/skills/agent-home/SKILL.md +1 -1
- package/skills/brand-identity/SKILL.md +22 -5
- package/skills/clearly-agent/SKILL.md +38 -14
- package/skills/clearly-canvas/SKILL.md +31 -5
- package/skills/clearly-canvas/authoring.md +41 -12
- package/skills/clearly-canvas/example.mjs +72 -69
- package/skills/clearly-docs/SKILL.md +151 -0
- package/skills/clearly-init/SKILL.md +135 -0
- package/skills/clearly-workflows/SKILL.md +90 -0
- package/skills/clearly-workspace/SKILL.md +1 -1
- package/skills/codebase-map/SKILL.md +1 -1
- package/skills/design-craft/SKILL.md +9 -6
- package/skills/index.json +45 -10
- package/skills/layout-systems/SKILL.md +14 -11
- package/skills/pair-on-canvas/SKILL.md +1 -1
- package/skills/sticker-pack/SKILL.md +132 -0
- package/skills/visualize/SKILL.md +188 -0
package/dist/daemon.js
CHANGED
|
@@ -13,6 +13,8 @@ import { DaemonRepl } from './repl.js';
|
|
|
13
13
|
import { browsePath } from './file-browser.js';
|
|
14
14
|
import { CloudBeeProxy } from './bee/cloud-proxy.js';
|
|
15
15
|
import { IpcServer, probeIpcSocket } from './ipc-server.js';
|
|
16
|
+
import { ipcSocketPath } from './ipc-path.js';
|
|
17
|
+
import { isBeehavenCommandLine, windowsNodeProcesses, selectWindowsDaemons } from './daemon-process.js';
|
|
16
18
|
import { mintIpcAuthTokens, clearIpcAuthToken } from './ipc-auth.js';
|
|
17
19
|
/**
|
|
18
20
|
* What THIS daemon minted, so shutdown can avoid scrubbing a successor's token.
|
|
@@ -44,6 +46,7 @@ import { homedir, tmpdir } from 'os';
|
|
|
44
46
|
import { join, dirname, relative } from 'path';
|
|
45
47
|
import { fileURLToPath } from 'url';
|
|
46
48
|
import { compareVersions, usableUpdateDigest } from './version-utils.js';
|
|
49
|
+
import { npmUpdatePlan, installNpmVersion } from './npm-update.js';
|
|
47
50
|
import { installKind } from './commands/self-update.js';
|
|
48
51
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
49
52
|
const CONFIG_DIR = join(homedir(), '.beehaven');
|
|
@@ -961,9 +964,85 @@ function updatesAreManagedExternally() {
|
|
|
961
964
|
*
|
|
962
965
|
* ⚠ NOTHING ELSE CALLS IT. The daemon's timer is still the only production caller.
|
|
963
966
|
*/
|
|
967
|
+
/** Said once per daemon, not hourly — see the managed-updates note below. */
|
|
968
|
+
let announcedNpmUpdateBlocked = false;
|
|
969
|
+
/**
|
|
970
|
+
* Start the freshly installed version and leave (shared by the curl and npm update paths).
|
|
971
|
+
* A CLI-spawned headless daemon is detached, with no supervisor to interpret exit 75, so it starts
|
|
972
|
+
* the new entry in a helper that waits until this process releases the IPC socket.
|
|
973
|
+
*/
|
|
974
|
+
async function restartForUpdate() {
|
|
975
|
+
if (process.env.BEEHAVEN_CLI_OWNED_DAEMON === '1') {
|
|
976
|
+
const child = spawn(process.execPath, [join(__dirname, 'daemon-entry.js'), '--restart-after-update', String(process.pid)], {
|
|
977
|
+
detached: true,
|
|
978
|
+
stdio: 'ignore',
|
|
979
|
+
env: process.env,
|
|
980
|
+
});
|
|
981
|
+
child.on('error', (error) => console.error(` [update] Restart helper failed: ${error.message}`));
|
|
982
|
+
if (!child.pid)
|
|
983
|
+
throw new Error('could not start update restart helper');
|
|
984
|
+
child.unref();
|
|
985
|
+
}
|
|
986
|
+
if (daemonRelay?.isConnected())
|
|
987
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
988
|
+
process.exit(75);
|
|
989
|
+
}
|
|
990
|
+
/**
|
|
991
|
+
* ⚠⚠ npm INSTALLS (THE ONLY WINDOWS INSTALL) NEVER AUTO-UPDATED (CAN2-57). This routine re-ran the
|
|
992
|
+
* bash installer, so it skipped every npm install and a Windows user stayed on their first version.
|
|
993
|
+
* They now update with npm itself — to the version the release manifest names (stable only) — when
|
|
994
|
+
* this copy IS npm's global install and its folder is writable; otherwise one line says why and
|
|
995
|
+
* what to run. No rollback file: `install.sh` cannot reinstall an npm package; a bad release is
|
|
996
|
+
* undone with `npm install -g @clearlysh/beehaven@<previous>`.
|
|
997
|
+
*/
|
|
998
|
+
async function checkAndAutoUpdateNpm() {
|
|
999
|
+
// A headless daemon's stdout goes nowhere, so the update story also goes to daemon.log.
|
|
1000
|
+
const say = (m, level = 'info') => {
|
|
1001
|
+
(level === 'error' ? console.error : console.log)(` [update] ${m}`);
|
|
1002
|
+
try {
|
|
1003
|
+
logger[level]('daemon', `[update] ${m}`);
|
|
1004
|
+
}
|
|
1005
|
+
catch { /* logging never blocks an update */ }
|
|
1006
|
+
};
|
|
1007
|
+
try {
|
|
1008
|
+
const controller = new AbortController();
|
|
1009
|
+
const timeout = setTimeout(() => controller.abort(), 5000);
|
|
1010
|
+
const res = await fetch(LATEST_MANIFEST, { signal: controller.signal });
|
|
1011
|
+
clearTimeout(timeout);
|
|
1012
|
+
if (!res.ok)
|
|
1013
|
+
return;
|
|
1014
|
+
const latest = (await res.json())?.version;
|
|
1015
|
+
if (typeof latest !== 'string')
|
|
1016
|
+
return;
|
|
1017
|
+
const current = getCurrentVersion();
|
|
1018
|
+
if (compareVersions(latest, current) <= 0)
|
|
1019
|
+
return;
|
|
1020
|
+
const plan = npmUpdatePlan(join(__dirname, '..'));
|
|
1021
|
+
if (!plan.ok) {
|
|
1022
|
+
if (!announcedNpmUpdateBlocked) {
|
|
1023
|
+
announcedNpmUpdateBlocked = true;
|
|
1024
|
+
say(`${latest} is available, but this npm install cannot update itself: ${plan.reason}. Run: npm install -g @clearlysh/beehaven@latest`);
|
|
1025
|
+
}
|
|
1026
|
+
return;
|
|
1027
|
+
}
|
|
1028
|
+
say(`Updating ${current} → ${latest} with npm...`);
|
|
1029
|
+
const r = await installNpmVersion(plan, latest);
|
|
1030
|
+
if (!r.ok)
|
|
1031
|
+
throw new Error(r.message);
|
|
1032
|
+
if (getCurrentVersion() !== latest)
|
|
1033
|
+
throw new Error(`npm did not install ${latest}`);
|
|
1034
|
+
say(`Updated to v${latest} — restarting...`);
|
|
1035
|
+
await restartForUpdate();
|
|
1036
|
+
}
|
|
1037
|
+
catch (error) {
|
|
1038
|
+
say(`Update failed: ${error?.message ?? error}`, 'error');
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
964
1041
|
export async function checkAndAutoUpdate() {
|
|
965
1042
|
const owner = updatesAreManagedExternally();
|
|
966
1043
|
const runningKind = installKind(join(__dirname, 'daemon-entry.js'));
|
|
1044
|
+
if (!owner && runningKind === 'npm')
|
|
1045
|
+
return checkAndAutoUpdateNpm();
|
|
967
1046
|
if (owner || runningKind !== 'user') {
|
|
968
1047
|
// Said once at startup, not hourly — a line every hour about a thing that is working as
|
|
969
1048
|
// designed is how a log stops being read.
|
|
@@ -1015,19 +1094,6 @@ export async function checkAndAutoUpdate() {
|
|
|
1015
1094
|
throw new Error(`installer did not install ${latest}`);
|
|
1016
1095
|
}
|
|
1017
1096
|
console.log(` [update] Updated to v${latest} — restarting...`);
|
|
1018
|
-
if (process.env.BEEHAVEN_CLI_OWNED_DAEMON === '1') {
|
|
1019
|
-
// A CLI-spawned headless daemon is detached, with no supervisor to interpret exit 75.
|
|
1020
|
-
// Start the new entry in a helper that waits until this process releases the IPC socket.
|
|
1021
|
-
const child = spawn(process.execPath, [join(__dirname, 'daemon-entry.js'), '--restart-after-update', String(process.pid)], {
|
|
1022
|
-
detached: true,
|
|
1023
|
-
stdio: 'ignore',
|
|
1024
|
-
env: process.env,
|
|
1025
|
-
});
|
|
1026
|
-
child.on('error', (error) => console.error(` [update] Restart helper failed: ${error.message}`));
|
|
1027
|
-
if (!child.pid)
|
|
1028
|
-
throw new Error('could not start update restart helper');
|
|
1029
|
-
child.unref();
|
|
1030
|
-
}
|
|
1031
1097
|
// Drop the temp tarball BEFORE exiting. `process.exit` skips `finally`,
|
|
1032
1098
|
// so cleaning up only there would leak the whole release into /tmp on every
|
|
1033
1099
|
// SUCCESSFUL update — the one path that always runs.
|
|
@@ -1044,13 +1110,9 @@ export async function checkAndAutoUpdate() {
|
|
|
1044
1110
|
catch { }
|
|
1045
1111
|
installerPath = null;
|
|
1046
1112
|
}
|
|
1047
|
-
//
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
}
|
|
1051
|
-
// Exit with non-zero so launchd/systemd restarts with the new version
|
|
1052
|
-
// (KeepAlive > SuccessfulExit > false means exit 0 stays down)
|
|
1053
|
-
process.exit(75);
|
|
1113
|
+
// Start the new version and exit non-zero, so launchd/systemd also restarts with it
|
|
1114
|
+
// (KeepAlive > SuccessfulExit > false means exit 0 stays down).
|
|
1115
|
+
await restartForUpdate();
|
|
1054
1116
|
}
|
|
1055
1117
|
catch (error) {
|
|
1056
1118
|
console.error(` [update] Update failed: ${error?.message ?? error}`);
|
|
@@ -1104,41 +1166,17 @@ function isBeehavenProcess(pid) {
|
|
|
1104
1166
|
timeout: 3000,
|
|
1105
1167
|
}).trim();
|
|
1106
1168
|
}
|
|
1169
|
+
else if (process.platform === 'win32') {
|
|
1170
|
+
// No `ps` and no /proc on Windows: read the command line from CIM (CAN2-56).
|
|
1171
|
+
cmd = windowsNodeProcesses(pid)?.find(([p]) => p === pid)?.[1] ?? '';
|
|
1172
|
+
}
|
|
1107
1173
|
else {
|
|
1108
1174
|
// Linux: /proc/PID/cmdline uses NULs; normalize to spaces
|
|
1109
1175
|
cmd = readFileSync(`/proc/${pid}/cmdline`, 'utf8').replace(/\0/g, ' ').trim();
|
|
1110
1176
|
}
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
// Anchoring with ^ so a shell running a heredoc that contains
|
|
1115
|
-
// "node .../cli.js" deeper in its argv doesn't false-positive.
|
|
1116
|
-
const argv0Ok = /^(\S*\/)?node(\s|$)/.test(cmd) ||
|
|
1117
|
-
/^(\S*\/)?tsx(\s|$)/.test(cmd) ||
|
|
1118
|
-
/^(\S*\/)?beehaven(\s|$)/.test(cmd);
|
|
1119
|
-
if (!argv0Ok)
|
|
1120
|
-
return false;
|
|
1121
|
-
// Condition 2: a subsequent argument must reference a beehaven script.
|
|
1122
|
-
//
|
|
1123
|
-
// NOTE THE `\.?` — the directory may be `beehaven/` OR the DOTFILE
|
|
1124
|
-
// `.beehaven/`. `public/install.sh` installs to `~/.beehaven/cli/`, which is
|
|
1125
|
-
// the canonical location for every `curl … | sh` user, and these patterns
|
|
1126
|
-
// used to require a path segment of exactly "beehaven". So the CLI could not
|
|
1127
|
-
// recognise its OWN standard install: `stop` reported "No daemon running"
|
|
1128
|
-
// while one was running, `restart` printed a fresh PID whose process then
|
|
1129
|
-
// died with "Refusing to start — IPC socket already serving connections",
|
|
1130
|
-
// and the real daemon was effectively unkillable from the CLI. Only
|
|
1131
|
-
// monorepo (`beehaven_sdk/`) and Mac-app installs were ever detected.
|
|
1132
|
-
const scriptOk =
|
|
1133
|
-
// Dedicated daemon entry — what the Mac app tray spawns.
|
|
1134
|
-
/[/\\]\.?beehaven(_sdk)?[/\\].*daemon-entry\.(js|ts)\b/.test(cmd) ||
|
|
1135
|
-
/Clearly[^/\\]*\.app[/\\]Contents[/\\]Resources[/\\]beehaven[/\\]daemon-entry\.js\b/.test(cmd) ||
|
|
1136
|
-
// Legacy `cli.js start` — for old bundles pre-dating daemon-entry.js.
|
|
1137
|
-
/[/\\]\.?beehaven(_sdk)?[/\\].*cli\.(js|ts)\b/.test(cmd) ||
|
|
1138
|
-
/Clearly[^/\\]*\.app[/\\]Contents[/\\]Resources[/\\]beehaven[/\\]cli\.js\b/.test(cmd) ||
|
|
1139
|
-
// Bare `beehaven start` — wrapper already matched argv[0].
|
|
1140
|
-
/^(\S*\/)?beehaven\s+start\b/.test(cmd);
|
|
1141
|
-
return scriptOk;
|
|
1177
|
+
// The two-condition rule (argv[0] is node/tsx/beehaven AND an argument is a beehaven script)
|
|
1178
|
+
// lives in daemon-process.ts, where it also understands Windows command lines.
|
|
1179
|
+
return isBeehavenCommandLine(cmd);
|
|
1142
1180
|
}
|
|
1143
1181
|
catch {
|
|
1144
1182
|
return false; // Can't read → not our process or already dead
|
|
@@ -1151,6 +1189,23 @@ function isBeehavenProcess(pid) {
|
|
|
1151
1189
|
*/
|
|
1152
1190
|
export function findLiveDaemons() {
|
|
1153
1191
|
const found = new Set();
|
|
1192
|
+
// Windows: no PID-file trust without a command line, and no pgrep — ONE CIM query of node
|
|
1193
|
+
// processes answers both "is the PID file's process ours" and "are there orphans" (CAN2-56).
|
|
1194
|
+
if (process.platform === 'win32') {
|
|
1195
|
+
let pidFilePid = null;
|
|
1196
|
+
try {
|
|
1197
|
+
pidFilePid = existsSync(PID_FILE) ? parseInt(readFileSync(PID_FILE, 'utf8').trim(), 10) || null : null;
|
|
1198
|
+
}
|
|
1199
|
+
catch { }
|
|
1200
|
+
const alive = (pid) => { try {
|
|
1201
|
+
process.kill(pid, 0);
|
|
1202
|
+
return true;
|
|
1203
|
+
}
|
|
1204
|
+
catch {
|
|
1205
|
+
return false;
|
|
1206
|
+
} };
|
|
1207
|
+
return selectWindowsDaemons(windowsNodeProcesses(), { selfPid: process.pid, pidFilePid, isAlive: alive });
|
|
1208
|
+
}
|
|
1154
1209
|
// 1. Check PID file first — the canonical source.
|
|
1155
1210
|
if (existsSync(PID_FILE)) {
|
|
1156
1211
|
try {
|
|
@@ -1294,7 +1349,7 @@ export async function startDaemon(opts) {
|
|
|
1294
1349
|
// live server answering here means some daemon is alive that findLiveDaemons
|
|
1295
1350
|
// missed (e.g. different binary path, permissions edge case).
|
|
1296
1351
|
if (await probeIpcSocket()) {
|
|
1297
|
-
console.error(`\n ✗ Another beehaven daemon is listening on
|
|
1352
|
+
console.error(`\n ✗ Another beehaven daemon is listening on ${ipcSocketPath()}.`);
|
|
1298
1353
|
console.error(` ✗ Quit the Clearly Dev Mac app tray to stop the existing daemon, then restart.\n`);
|
|
1299
1354
|
try {
|
|
1300
1355
|
logger.error('daemon', 'Refusing to start — IPC socket already serving connections');
|
|
@@ -4061,7 +4116,7 @@ export async function startDaemon(opts) {
|
|
|
4061
4116
|
// so browser frontends become virtual IPC clients — same
|
|
4062
4117
|
// pipeline, app-rpc, broadcast fan-out as Claude's path.
|
|
4063
4118
|
dashboard.setAppBridge({
|
|
4064
|
-
createVirtualClient: (send, subs) => ipc.createVirtualClient(send, subs),
|
|
4119
|
+
createVirtualClient: (send, subs, scope) => ipc.createVirtualClient(send, subs, scope),
|
|
4065
4120
|
injectMessage: (id, msg) => ipc.injectMessage(id, msg),
|
|
4066
4121
|
releaseVirtualClient: (id) => ipc.releaseVirtualClient(id),
|
|
4067
4122
|
});
|
|
@@ -4086,15 +4141,29 @@ export async function startDaemon(opts) {
|
|
|
4086
4141
|
}
|
|
4087
4142
|
})();
|
|
4088
4143
|
// ── Dashboard (local observability UI) ──────────────────────────────
|
|
4089
|
-
|
|
4090
|
-
|
|
4091
|
-
|
|
4092
|
-
|
|
4093
|
-
|
|
4094
|
-
|
|
4095
|
-
|
|
4144
|
+
/**
|
|
4145
|
+
* ⚠⚠ A LOOPBACK HTTP SERVER IS OPEN TO EVERY USER AND PROGRAM ON THE MACHINE (CAN2-64), so a
|
|
4146
|
+
* daemon opens one only when something will use it. The Mac app's daemon does (the avatar UI
|
|
4147
|
+
* talks over /apps/ws); a HEADLESS daemon — the CLI on Windows and Linux, CI, servers — has no
|
|
4148
|
+
* window to show it in and, on a shared Windows machine, would hand every other signed-in user
|
|
4149
|
+
* a port into this one's daemon. It starts there only on request: BEEHAVEN_DASHBOARD=1,
|
|
4150
|
+
* `"dashboard": true` in ~/.beehaven/config.json, or developer mode.
|
|
4151
|
+
*/
|
|
4152
|
+
const headlessDaemon = process.env.BEEHAVEN_HEADLESS === '1' || process.env.BEEHAVEN_HEADLESS === 'true';
|
|
4153
|
+
const dashboardWanted = !headlessDaemon || developerMode || config.dashboard === true || process.env.BEEHAVEN_DASHBOARD === '1';
|
|
4154
|
+
if (!dashboardWanted) {
|
|
4155
|
+
console.log(' [dashboard] off in a headless daemon (set BEEHAVEN_DASHBOARD=1 to open it on 127.0.0.1)');
|
|
4156
|
+
}
|
|
4157
|
+
if (dashboardWanted)
|
|
4158
|
+
dashboard = new Dashboard({
|
|
4159
|
+
port: config.dashboardPort ?? 9800,
|
|
4160
|
+
host: '127.0.0.1',
|
|
4161
|
+
explorerTarget: `http://127.0.0.1:${config.devPort ?? 8787}`,
|
|
4162
|
+
explorerSecret: config.explorerSecret ?? config.adminSecret ?? '',
|
|
4163
|
+
projectDir: config.linkedProject ?? '',
|
|
4164
|
+
});
|
|
4096
4165
|
dashboard
|
|
4097
|
-
|
|
4166
|
+
?.start()
|
|
4098
4167
|
.then(() => {
|
|
4099
4168
|
console.log(` [dashboard] Beehaven Dashboard at ${dashboard.url}`);
|
|
4100
4169
|
})
|
|
@@ -4103,7 +4172,7 @@ export async function startDaemon(opts) {
|
|
|
4103
4172
|
});
|
|
4104
4173
|
// Forward daemon events to dashboard
|
|
4105
4174
|
watcher.on('event', (event) => {
|
|
4106
|
-
dashboard
|
|
4175
|
+
dashboard?.pushEvent({
|
|
4107
4176
|
type: event.hook_event_name || event.type || 'unknown',
|
|
4108
4177
|
timestamp: event.timestamp || Date.now(),
|
|
4109
4178
|
data: {
|
package/dist/dashboard.js
CHANGED
|
@@ -17,6 +17,59 @@ import { readFileSync, existsSync } from 'node:fs';
|
|
|
17
17
|
import { join, dirname } from 'node:path';
|
|
18
18
|
import { fileURLToPath } from 'node:url';
|
|
19
19
|
import { WebSocketServer } from 'ws';
|
|
20
|
+
// ── Who may talk to this server (CAN2-64) ───────────────────────────────────
|
|
21
|
+
/**
|
|
22
|
+
* ⚠⚠ A LOOPBACK PORT IS NOT A PRIVATE CHANNEL. Every web page the person opens can dial
|
|
23
|
+
* `ws://127.0.0.1:9800` — WebSockets ignore CORS — and so can every program on the machine,
|
|
24
|
+
* another Windows or macOS user's included. `/apps/ws` used to hand EVERY connection a
|
|
25
|
+
* developer-scope IPC client with no check at all (a comment in ipc-server.ts claimed a session
|
|
26
|
+
* cookie and an origin check that did not exist), so any website could ask the daemon for the
|
|
27
|
+
* signed-in Firebase token, run `sql`/`shell`, or install a package. Proven against a live
|
|
28
|
+
* daemon on 2026-10-13 with `Origin: https://evil.example`. The rules now:
|
|
29
|
+
*
|
|
30
|
+
* 1. The Host header must name this loopback server — a DNS-rebound hostname is refused.
|
|
31
|
+
* 2. `/apps/ws`: a PAGE must come from this server (an installed hive's frontend) or be a
|
|
32
|
+
* `file://` page (the Mac app loads the avatar UI with `loadFile`; a web page cannot claim
|
|
33
|
+
* `file://`), and it gets CONSUMER scope. A PROGRAM (no Origin) starts unauthenticated and
|
|
34
|
+
* must present the daemon's IPC token with `{type:'auth'}` — the pipe's own boundary. Any
|
|
35
|
+
* other origin, `null` included (sandboxed iframes send it), is refused at the upgrade.
|
|
36
|
+
* 3. JSON routes answer CORS only to loopback origins (a dev server on localhost). Browser
|
|
37
|
+
* building modules stay `*`: clearly.sh imports that public code cross-origin.
|
|
38
|
+
*/
|
|
39
|
+
export function isLoopbackHostHeader(host, port) {
|
|
40
|
+
const h = (host ?? '').toLowerCase();
|
|
41
|
+
return h === `127.0.0.1:${port}` || h === `localhost:${port}` || h === `[::1]:${port}`;
|
|
42
|
+
}
|
|
43
|
+
/** `http://localhost:N`, `http://127.0.0.1:N` or `http://[::1]:N` — a page served on this machine. */
|
|
44
|
+
export function isLoopbackOrigin(origin) {
|
|
45
|
+
if (!origin)
|
|
46
|
+
return false;
|
|
47
|
+
try {
|
|
48
|
+
const u = new URL(origin);
|
|
49
|
+
return u.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(u.hostname);
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/** A page served by THIS server. */
|
|
56
|
+
export function isSelfOrigin(origin, port) {
|
|
57
|
+
return (origin === `http://127.0.0.1:${port}` ||
|
|
58
|
+
origin === `http://localhost:${port}` ||
|
|
59
|
+
origin === `http://[::1]:${port}`);
|
|
60
|
+
}
|
|
61
|
+
/** How a `/apps/ws` connection starts: `consumer` scope, unauthenticated (`token`), or refused (null). */
|
|
62
|
+
export function appBridgeTrust(origin, port) {
|
|
63
|
+
if (!origin)
|
|
64
|
+
return 'token';
|
|
65
|
+
if (origin === 'file://' || isSelfOrigin(origin, port))
|
|
66
|
+
return 'consumer';
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
/** `/ws` streams events and accepts console logs: loopback or file pages, or programs. */
|
|
70
|
+
export function metricsSocketAllowed(origin) {
|
|
71
|
+
return !origin || origin === 'file://' || isLoopbackOrigin(origin);
|
|
72
|
+
}
|
|
20
73
|
// ── Fallback HTML ───────────────────────────────────────────────────────────
|
|
21
74
|
const FALLBACK_HTML = `<!DOCTYPE html>
|
|
22
75
|
<html lang="en">
|
|
@@ -150,11 +203,28 @@ export class Dashboard {
|
|
|
150
203
|
// first WSS's listener — both listeners fire on every upgrade event.
|
|
151
204
|
// Instead we accept all upgrades and route by req.url in the
|
|
152
205
|
// connection handler.
|
|
153
|
-
|
|
206
|
+
//
|
|
207
|
+
// Every upgrade is judged BEFORE it is accepted (see "Who may talk to this server").
|
|
208
|
+
this._wss = new WebSocketServer({
|
|
209
|
+
server: this._server,
|
|
210
|
+
verifyClient: (info, done) => {
|
|
211
|
+
const req = info.req;
|
|
212
|
+
const url = req.url ?? '';
|
|
213
|
+
const origin = req.headers.origin;
|
|
214
|
+
if (!isLoopbackHostHeader(req.headers.host, this.port))
|
|
215
|
+
return done(false, 403, 'Forbidden');
|
|
216
|
+
if (url.startsWith('/apps/ws')) {
|
|
217
|
+
return appBridgeTrust(origin, this.port) ? done(true) : done(false, 403, 'Forbidden');
|
|
218
|
+
}
|
|
219
|
+
if (url.startsWith('/ws'))
|
|
220
|
+
return metricsSocketAllowed(origin) ? done(true) : done(false, 403, 'Forbidden');
|
|
221
|
+
return done(false, 404, 'Not Found');
|
|
222
|
+
},
|
|
223
|
+
});
|
|
154
224
|
this._wss.on('connection', (ws, req) => {
|
|
155
225
|
const url = req.url ?? '';
|
|
156
226
|
if (url.startsWith('/apps/ws')) {
|
|
157
|
-
this._handleAppWsConnection(ws);
|
|
227
|
+
this._handleAppWsConnection(ws, appBridgeTrust(req.headers.origin, this.port));
|
|
158
228
|
}
|
|
159
229
|
else if (url.startsWith('/ws')) {
|
|
160
230
|
this._handleWSConnection(ws, req);
|
|
@@ -255,8 +325,10 @@ export class Dashboard {
|
|
|
255
325
|
get url() {
|
|
256
326
|
return `http://${this._config.host}:${this._config.port}`;
|
|
257
327
|
}
|
|
328
|
+
/** The port actually bound (config 0 picks one), which the Host and Origin checks compare to. */
|
|
258
329
|
get port() {
|
|
259
|
-
|
|
330
|
+
const addr = this._server?.address();
|
|
331
|
+
return addr && typeof addr === 'object' ? addr.port : this._config.port;
|
|
260
332
|
}
|
|
261
333
|
get running() {
|
|
262
334
|
return this._server !== null;
|
|
@@ -268,6 +340,30 @@ export class Dashboard {
|
|
|
268
340
|
_handleHTTP(req, res) {
|
|
269
341
|
const url = req.url ?? '/';
|
|
270
342
|
const method = req.method ?? 'GET';
|
|
343
|
+
const origin = req.headers.origin;
|
|
344
|
+
// A DNS-rebound name pointing at 127.0.0.1 carries its own Host — refuse it on every route.
|
|
345
|
+
if (!isLoopbackHostHeader(req.headers.host, this.port)) {
|
|
346
|
+
res.writeHead(403, { 'Content-Type': 'text/plain' });
|
|
347
|
+
res.end('Forbidden');
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
/**
|
|
351
|
+
* ⚠ ONE CHOKEPOINT FOR CORS. The handlers below were written to answer `*`; every response now
|
|
352
|
+
* passes through here, which keeps `*` only for browser building modules (public code that
|
|
353
|
+
* clearly.sh imports cross-origin) and otherwise reflects a loopback origin or sends none.
|
|
354
|
+
*/
|
|
355
|
+
const publicModule = /^\/apps\/[^/]+\/buildings\/[^/]+\.js$/.test(url);
|
|
356
|
+
const allowOrigin = publicModule ? '*' : isLoopbackOrigin(origin) ? origin : null;
|
|
357
|
+
const writeHead = res.writeHead.bind(res);
|
|
358
|
+
res.writeHead = (status, ...rest) => {
|
|
359
|
+
const headers = rest.find((r) => r && typeof r === 'object');
|
|
360
|
+
if (headers && 'Access-Control-Allow-Origin' in headers) {
|
|
361
|
+
delete headers['Access-Control-Allow-Origin'];
|
|
362
|
+
if (allowOrigin)
|
|
363
|
+
Object.assign(headers, { 'Access-Control-Allow-Origin': allowOrigin, Vary: 'Origin' });
|
|
364
|
+
}
|
|
365
|
+
return writeHead(status, ...rest);
|
|
366
|
+
};
|
|
271
367
|
// Handle CORS preflight
|
|
272
368
|
if (method === 'OPTIONS') {
|
|
273
369
|
res.writeHead(204, {
|
|
@@ -280,8 +376,14 @@ export class Dashboard {
|
|
|
280
376
|
return;
|
|
281
377
|
}
|
|
282
378
|
try {
|
|
283
|
-
// Explorer API proxy — forward /api/explorer/* to the Worker
|
|
379
|
+
// Explorer API proxy — forward /api/explorer/* to the Worker. It adds the admin secret, so
|
|
380
|
+
// only this server's own explorer page (or a program, which sends no Origin) may use it.
|
|
284
381
|
if (url.startsWith('/api/explorer')) {
|
|
382
|
+
if (origin && !isSelfOrigin(origin, this.port)) {
|
|
383
|
+
res.writeHead(403, { 'Content-Type': 'text/plain' });
|
|
384
|
+
res.end('Forbidden');
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
285
387
|
this._proxyExplorer(req, res, url);
|
|
286
388
|
return;
|
|
287
389
|
}
|
|
@@ -350,6 +452,11 @@ export class Dashboard {
|
|
|
350
452
|
}
|
|
351
453
|
}
|
|
352
454
|
if (method === 'POST' && url === '/api/console') {
|
|
455
|
+
if (!metricsSocketAllowed(origin)) {
|
|
456
|
+
res.writeHead(403, { 'Content-Type': 'text/plain' });
|
|
457
|
+
res.end('Forbidden');
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
353
460
|
this._handleConsolePost(req, res);
|
|
354
461
|
return;
|
|
355
462
|
}
|
|
@@ -702,7 +809,11 @@ export class Dashboard {
|
|
|
702
809
|
: { installed: [], available: [] };
|
|
703
810
|
this._serveJSON(res, payload);
|
|
704
811
|
}
|
|
705
|
-
_handleAppWsConnection(ws) {
|
|
812
|
+
_handleAppWsConnection(ws, trust) {
|
|
813
|
+
if (!trust) {
|
|
814
|
+
ws.close(1008, 'forbidden');
|
|
815
|
+
return;
|
|
816
|
+
}
|
|
706
817
|
if (!this._appBridge) {
|
|
707
818
|
ws.close(1011, 'app bridge not configured');
|
|
708
819
|
return;
|
|
@@ -720,7 +831,8 @@ export class Dashboard {
|
|
|
720
831
|
// Subscribe broadly by default — browser UIs usually want to see
|
|
721
832
|
// everything (pipeline:*, broadcast:*, etc.). Clients can tighten this
|
|
722
833
|
// by sending their own { type: 'subscribe', patterns: [...] } message.
|
|
723
|
-
|
|
834
|
+
// Consumer scope at most: a page never gets developer verbs (get-token, shell, sql, installs).
|
|
835
|
+
const virtId = bridge.createVirtualClient(send, ['*'], trust === 'consumer' ? 'consumer' : null);
|
|
724
836
|
ws.on('message', (raw) => {
|
|
725
837
|
let msg;
|
|
726
838
|
try {
|
package/dist/ipc-auth.js
CHANGED
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
* IPC auth — scope-aware shared secrets between daemon and local clients.
|
|
3
3
|
*
|
|
4
4
|
* Threat model: any process running as the same user can connect to
|
|
5
|
-
* /tmp/beehaven-ipc.sock (it's chmod 0600, which only restricts other users).
|
|
5
|
+
* /tmp/beehaven-ipc.sock (it's chmod 0600, which only restricts other users). On
|
|
6
|
+
* Windows the daemon listens on a per-user named pipe, which has no file mode at all — so on
|
|
7
|
+
* that platform these tokens ARE the boundary for same-user callers (ipc-path.ts).
|
|
6
8
|
* Without per-scope auth, any same-user process can invoke privileged
|
|
7
9
|
* verbs — extract the Firebase ID token via `get-token`, run arbitrary
|
|
8
10
|
* SQL, install marketplace packages with code execution.
|
package/dist/ipc-client.js
CHANGED
|
@@ -4,14 +4,15 @@
|
|
|
4
4
|
* Used by CLI commands (actions, call, pipe, watch, ls, cat) to communicate
|
|
5
5
|
* with the running daemon, which forwards requests to the cloud DO.
|
|
6
6
|
*
|
|
7
|
-
* Protocol: newline-delimited JSON over /tmp/beehaven-ipc.sock
|
|
7
|
+
* Protocol: newline-delimited JSON over /tmp/beehaven-ipc.sock (a named pipe on Windows — ipc-path.ts)
|
|
8
8
|
*/
|
|
9
|
-
import { existsSync } from 'node:fs';
|
|
10
9
|
import { createConnection } from 'net';
|
|
11
10
|
import { EventEmitter } from 'events';
|
|
12
11
|
import { resolveClientAuthToken } from './ipc-auth.js';
|
|
12
|
+
import { ipcSocketPath, socketMayExist } from './ipc-path.js';
|
|
13
13
|
import { activeAgentWrap } from './agent-attribution.js';
|
|
14
|
-
|
|
14
|
+
/** The default address (Unix socket, or this user's named pipe on Windows) — see `ipc-path.ts`. */
|
|
15
|
+
const IPC_SOCKET_PATH = ipcSocketPath();
|
|
15
16
|
/** Default timeout for RPC calls (ms) */
|
|
16
17
|
const DEFAULT_TIMEOUT = 30_000;
|
|
17
18
|
/**
|
|
@@ -49,7 +50,7 @@ export class IpcClient extends EventEmitter {
|
|
|
49
50
|
* — the only way to guard the CALL SITE rather than the helper it calls, which is the exact
|
|
50
51
|
* distinction that let 28 verbs ship un-attributed. Unset, nothing changes.
|
|
51
52
|
*/
|
|
52
|
-
this.socketPath = opts?.socketPath ??
|
|
53
|
+
this.socketPath = opts?.socketPath ?? ipcSocketPath(); // honours $BEEHAVEN_IPC_SOCKET
|
|
53
54
|
this.defaultTimeout = opts?.timeout ?? DEFAULT_TIMEOUT;
|
|
54
55
|
this.explicitAuthToken = opts?.authToken;
|
|
55
56
|
}
|
|
@@ -113,7 +114,7 @@ export class IpcClient extends EventEmitter {
|
|
|
113
114
|
? resolveClientAuthToken()
|
|
114
115
|
: this.explicitAuthToken;
|
|
115
116
|
let token = readToken();
|
|
116
|
-
if (!token && this.explicitAuthToken === undefined &&
|
|
117
|
+
if (!token && this.explicitAuthToken === undefined && socketMayExist(this.socketPath)) {
|
|
117
118
|
const deadline = Date.now() + TOKEN_MINT_WAIT_MS;
|
|
118
119
|
while (!token && Date.now() < deadline) {
|
|
119
120
|
/**
|
|
@@ -143,7 +144,7 @@ export class IpcClient extends EventEmitter {
|
|
|
143
144
|
* ⚠ THE SOCKET IS THE DISCRIMINATOR: up means "still starting, wait"; absent
|
|
144
145
|
* means "nothing is running, start one". Opposite actions.
|
|
145
146
|
*/
|
|
146
|
-
(
|
|
147
|
+
(socketMayExist(this.socketPath)
|
|
147
148
|
? 'The daemon is still starting — its socket is up but it has not minted an IPC token yet. ' +
|
|
148
149
|
'Wait a second and retry. (Do NOT restart: `stop` scrubs the token, which re-enters this window.)'
|
|
149
150
|
: 'No IPC auth token and no daemon socket — nothing is running. ' +
|
package/dist/ipc-path.js
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ipc-path — where the daemon's local sockets live, on every platform (CAN2-56).
|
|
3
|
+
*
|
|
4
|
+
* ⚠⚠ THE ONE PLACE A SOCKET ADDRESS IS DECIDED. `/tmp/beehaven-ipc.sock` was typed out in five CLI
|
|
5
|
+
* files (server, three clients, the claude bridge) and `/tmp/beehaven.sock` in two more, each
|
|
6
|
+
* gating its connect on `existsSync`, unlinking stale files and chmod-ing to 0600. On Windows none
|
|
7
|
+
* of that works: Node's `net` listens on a NAMED PIPE (`\\.\pipe\…`), there is no `/tmp`, a pipe is
|
|
8
|
+
* not a file you can stat, unlink or chmod — so the daemon could not bind and no client could
|
|
9
|
+
* reach it. Every caller now asks this module, and the Unix addresses are byte-for-byte unchanged
|
|
10
|
+
* (the Mac app, the mwah CLI and the Electron daemon client still dial `/tmp/beehaven-ipc.sock`).
|
|
11
|
+
*
|
|
12
|
+
* ⚠ A PIPE NAME IS MACHINE-GLOBAL, so it carries the user name: two people signed in to one
|
|
13
|
+
* Windows machine get two daemons, as two Unix users do. Same-user is still the boundary that
|
|
14
|
+
* matters, and the IPC auth token (`ipc-auth.ts`) is what gates verbs on both platforms — the
|
|
15
|
+
* Unix 0600 mode is defense in depth that a pipe does not have a file-mode equivalent for.
|
|
16
|
+
*
|
|
17
|
+
* ⚠ `$BEEHAVEN_IPC_SOCKET` / `$BEEHAVEN_WATCHER_SOCKET` override the address for BOTH ends (server
|
|
18
|
+
* and clients), so a test can stand a real daemon and a real client on a private path. Read at call
|
|
19
|
+
* time, not import time, for the same reason.
|
|
20
|
+
*/
|
|
21
|
+
import { existsSync, unlinkSync, chmodSync, statSync } from 'fs';
|
|
22
|
+
import { tmpdir, userInfo } from 'os';
|
|
23
|
+
import { join } from 'path';
|
|
24
|
+
export const UNIX_IPC_SOCKET = '/tmp/beehaven-ipc.sock';
|
|
25
|
+
export const UNIX_WATCHER_SOCKET = '/tmp/beehaven.sock';
|
|
26
|
+
export const UNIX_EVENTS_FILE = '/tmp/beehaven-events.jsonl';
|
|
27
|
+
/** `\\.\pipe\…` or `\\?\pipe\…` — the forms Node accepts for a Windows named pipe. */
|
|
28
|
+
export function isNamedPipe(p) {
|
|
29
|
+
return /^\\\\[.?]\\pipe\\/i.test(p);
|
|
30
|
+
}
|
|
31
|
+
/** The signed-in user's name, made safe for a pipe name. */
|
|
32
|
+
function pipeUser(env) {
|
|
33
|
+
let name = '';
|
|
34
|
+
try {
|
|
35
|
+
name = userInfo().username;
|
|
36
|
+
}
|
|
37
|
+
catch { /* no passwd entry / sandbox */ }
|
|
38
|
+
if (!name)
|
|
39
|
+
name = env.USERNAME || env.USER || 'user';
|
|
40
|
+
return name.toLowerCase().replace(/[^a-z0-9._-]+/g, '_').slice(0, 64) || 'user';
|
|
41
|
+
}
|
|
42
|
+
const pipe = (name, env) => `\\\\.\\pipe\\${name}-${pipeUser(env)}`;
|
|
43
|
+
/** The daemon's IPC address: the Unix socket, or this user's named pipe on Windows. */
|
|
44
|
+
export function ipcSocketPath(platform = process.platform, env = process.env) {
|
|
45
|
+
if (env.BEEHAVEN_IPC_SOCKET)
|
|
46
|
+
return env.BEEHAVEN_IPC_SOCKET;
|
|
47
|
+
return platform === 'win32' ? pipe('beehaven-ipc', env) : UNIX_IPC_SOCKET;
|
|
48
|
+
}
|
|
49
|
+
/** The Claude Code event watcher's HTTP-over-socket address. */
|
|
50
|
+
export function watcherSocketPath(platform = process.platform, env = process.env) {
|
|
51
|
+
if (env.BEEHAVEN_WATCHER_SOCKET)
|
|
52
|
+
return env.BEEHAVEN_WATCHER_SOCKET;
|
|
53
|
+
return platform === 'win32' ? pipe('beehaven-watcher', env) : UNIX_WATCHER_SOCKET;
|
|
54
|
+
}
|
|
55
|
+
/** The watcher's JSONL fallback file. Windows has no `/tmp`; its temp dir is per-user already. */
|
|
56
|
+
export function eventsFilePath(platform = process.platform) {
|
|
57
|
+
return platform === 'win32' ? join(tmpdir(), 'beehaven-events.jsonl') : UNIX_EVENTS_FILE;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Could a listener be at `p`? A socket FILE can be checked without touching it. A named pipe
|
|
61
|
+
* cannot: it exists only while a server holds it, and probing it with a filesystem call opens an
|
|
62
|
+
* instance of it. So a pipe answers `true` and the connect attempt decides (`ENOENT` = nothing).
|
|
63
|
+
*/
|
|
64
|
+
export function socketMayExist(p) {
|
|
65
|
+
return isNamedPipe(p) || existsSync(p);
|
|
66
|
+
}
|
|
67
|
+
/** Remove a stale socket file before binding. A named pipe leaves nothing behind to remove. */
|
|
68
|
+
export function clearStaleSocket(p) {
|
|
69
|
+
if (isNamedPipe(p))
|
|
70
|
+
return;
|
|
71
|
+
try {
|
|
72
|
+
if (existsSync(p))
|
|
73
|
+
unlinkSync(p);
|
|
74
|
+
}
|
|
75
|
+
catch { /* already gone */ }
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Restrict a freshly bound socket file to its owner (0600) and report the mode it really has.
|
|
79
|
+
* `applicable: false` for a named pipe, which has no file mode — its boundary is the per-user name
|
|
80
|
+
* plus the IPC token.
|
|
81
|
+
*/
|
|
82
|
+
export function restrictSocketToOwner(p) {
|
|
83
|
+
if (isNamedPipe(p))
|
|
84
|
+
return { applicable: false, mode: null };
|
|
85
|
+
try {
|
|
86
|
+
chmodSync(p, 0o600);
|
|
87
|
+
return { applicable: true, mode: statSync(p).mode & 0o777 };
|
|
88
|
+
}
|
|
89
|
+
catch (err) {
|
|
90
|
+
return { applicable: true, mode: null, error: String(err?.code ?? err?.message ?? err) };
|
|
91
|
+
}
|
|
92
|
+
}
|