beehaven 0.8.29 → 0.8.33

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.
Files changed (45) hide show
  1. package/dist/agents-md-text.js +64 -0
  2. package/dist/browser-login.js +14 -4
  3. package/dist/build-channel.js +2 -2
  4. package/dist/claude-bridge.js +12 -9
  5. package/dist/clearly.js +4 -3
  6. package/dist/cli.js +26 -7
  7. package/dist/commands/agents-md.js +118 -0
  8. package/dist/commands/daemon-cmd.js +2 -0
  9. package/dist/commands/help.js +21 -0
  10. package/dist/commands/self-update.js +32 -5
  11. package/dist/commands/workspace.js +3 -3
  12. package/dist/daemon-client.js +10 -6
  13. package/dist/daemon-entry.js +1 -0
  14. package/dist/daemon-process.js +125 -0
  15. package/dist/daemon.js +131 -62
  16. package/dist/dashboard.js +118 -6
  17. package/dist/ipc-auth.js +3 -1
  18. package/dist/ipc-client.js +7 -6
  19. package/dist/ipc-path.js +92 -0
  20. package/dist/ipc-server.js +33 -33
  21. package/dist/npm-update.js +138 -0
  22. package/dist/setup-service.js +9 -4
  23. package/dist/verb-surface.js +1 -0
  24. package/dist/watcher.js +9 -23
  25. package/dist/windows-hide.js +77 -0
  26. package/package.json +1 -1
  27. package/scripts/sync-skills.mjs +6 -0
  28. package/skills/agent-doodle-space/SKILL.md +1 -1
  29. package/skills/agent-home/SKILL.md +1 -1
  30. package/skills/brand-identity/SKILL.md +22 -5
  31. package/skills/clearly-agent/SKILL.md +11 -4
  32. package/skills/clearly-canvas/SKILL.md +31 -5
  33. package/skills/clearly-canvas/authoring.md +40 -12
  34. package/skills/clearly-canvas/example.mjs +72 -69
  35. package/skills/clearly-docs/SKILL.md +151 -0
  36. package/skills/clearly-init/SKILL.md +135 -0
  37. package/skills/clearly-workflows/SKILL.md +90 -0
  38. package/skills/clearly-workspace/SKILL.md +1 -1
  39. package/skills/codebase-map/SKILL.md +1 -1
  40. package/skills/design-craft/SKILL.md +9 -6
  41. package/skills/index.json +45 -10
  42. package/skills/layout-systems/SKILL.md +14 -11
  43. package/skills/pair-on-canvas/SKILL.md +1 -1
  44. package/skills/sticker-pack/SKILL.md +132 -0
  45. package/skills/visualize/SKILL.md +188 -0
@@ -0,0 +1,125 @@
1
+ /**
2
+ * daemon-process — "is this process a beehaven daemon?", on every platform (CAN2-56).
3
+ *
4
+ * Split out of `daemon.ts` so the rule is testable without importing the daemon, and so Windows has
5
+ * an answer at all: `findLiveDaemons` read a command line with `ps` (macOS) or `/proc/<pid>/cmdline`
6
+ * (Linux) and found orphans with `pgrep`. Windows has none of those, so every lookup threw, was
7
+ * swallowed as "not ours", and `stop` / `restart` / the single-instance guard never saw a running
8
+ * daemon. Windows reads command lines from one CIM query instead.
9
+ */
10
+ import { execFileSync } from 'child_process';
11
+ /**
12
+ * Whether a full command line is a beehaven daemon or CLI process.
13
+ *
14
+ * Strict two-condition test (unchanged on macOS/Linux):
15
+ * 1. argv[0] is a node/tsx/beehaven binary — not a shell, an editor, a grep, etc.;
16
+ * 2. a later argument is a beehaven cli/daemon-entry script (under a beehaven* directory OR a
17
+ * Clearly*.app bundle).
18
+ *
19
+ * ⚠ WINDOWS COMMAND LINES ARE NORMALISED FIRST: argv[0] arrives quoted with backslashes, spaces and
20
+ * an extension (`"C:\Program Files\nodejs\node.exe" C:\…\daemon-entry.js`). It is reduced to the
21
+ * Unix shape (`C:/Program_Files/nodejs/node …`) so the same two conditions apply, unquoted.
22
+ */
23
+ export function isBeehavenCommandLine(raw, platform = process.platform) {
24
+ let cmd = (raw ?? '').trim();
25
+ if (!cmd)
26
+ return false;
27
+ if (platform === 'win32') {
28
+ const m = /^"([^"]*)"\s*(.*)$/s.exec(cmd) ?? /^(\S+)\s*(.*)$/s.exec(cmd);
29
+ if (!m)
30
+ return false;
31
+ const argv0 = m[1].replace(/\\/g, '/').replace(/\.(exe|cmd)$/i, '').replace(/\s+/g, '_').toLowerCase();
32
+ cmd = `${argv0} ${m[2].replace(/"/g, '')}`.trim();
33
+ }
34
+ // Condition 1: argv[0] must be a node/tsx/beehaven-wrapper binary. Anchored with ^ so a shell
35
+ // running a heredoc that contains "node .../cli.js" deeper in its argv doesn't false-positive.
36
+ const argv0Ok = /^(\S*\/)?node(\s|$)/.test(cmd) ||
37
+ /^(\S*\/)?tsx(\s|$)/.test(cmd) ||
38
+ /^(\S*\/)?beehaven(\s|$)/.test(cmd);
39
+ if (!argv0Ok)
40
+ return false;
41
+ // ⚠⚠ ON WINDOWS ONLY THE DAEMON ENTRY COUNTS. The `cli.js` patterns below exist for LEGACY daemons
42
+ // (`node cli.js start`, before daemon-entry.js) — and on macOS `pgrep` never surfaced ordinary CLI
43
+ // runs anyway (it reads its pattern as a BASIC regex, where `(js|ts)` matches nothing). Windows
44
+ // lists every node process, so with them a `start --headless` parent waiting for its child was
45
+ // "another daemon" (the child refused to start — the second Windows CI run), and `stop` would
46
+ // have killed an agent's in-flight `beehaven call`. There are no legacy Windows daemons to find.
47
+ if (platform === 'win32')
48
+ return /[/\\]\.?beehaven([-_]sdk)?[/\\].*daemon-entry\.(js|ts)\b/.test(cmd);
49
+ // Condition 2: a subsequent argument must reference a beehaven script.
50
+ //
51
+ // NOTE THE `\.?` — the directory may be `beehaven/` OR the DOTFILE `.beehaven/`. And `[-_]sdk`:
52
+ // the monorepo checkout is `beehaven_sdk/`, but a clone of the GitHub repo is `beehaven-sdk/` —
53
+ // the first Windows CI run could not find its own daemon for exactly that reason (CAN2-56).
54
+ // `public/install.sh` installs to `~/.beehaven/cli/`, the canonical location for every
55
+ // `curl … | sh` user; requiring exactly "beehaven" once made the CLI blind to its own standard
56
+ // install (`stop` said "No daemon running" while one was).
57
+ return (
58
+ // Dedicated daemon entry — what the Mac app tray and `start --headless` spawn.
59
+ /[/\\]\.?beehaven([-_]sdk)?[/\\].*daemon-entry\.(js|ts)\b/.test(cmd) ||
60
+ /Clearly[^/\\]*\.app[/\\]Contents[/\\]Resources[/\\]beehaven[/\\]daemon-entry\.js\b/.test(cmd) ||
61
+ // Legacy `cli.js start` — for old bundles pre-dating daemon-entry.js.
62
+ /[/\\]\.?beehaven([-_]sdk)?[/\\].*cli\.(js|ts)\b/.test(cmd) ||
63
+ /Clearly[^/\\]*\.app[/\\]Contents[/\\]Resources[/\\]beehaven[/\\]cli\.js\b/.test(cmd) ||
64
+ // Bare `beehaven start` — wrapper already matched argv[0].
65
+ /^(\S*\/)?beehaven\s+start\b/.test(cmd));
66
+ }
67
+ /** Parse `ConvertTo-Json` output of `{ProcessId, CommandLine}` rows (one object, or an array). */
68
+ export function parseWindowsProcessJson(out) {
69
+ const text = (out ?? '').trim();
70
+ if (!text)
71
+ return [];
72
+ let parsed;
73
+ try {
74
+ parsed = JSON.parse(text);
75
+ }
76
+ catch {
77
+ return [];
78
+ }
79
+ const rows = Array.isArray(parsed) ? parsed : [parsed];
80
+ const result = [];
81
+ for (const r of rows) {
82
+ const pid = Number(r?.ProcessId);
83
+ if (Number.isFinite(pid) && pid > 0)
84
+ result.push([pid, typeof r?.CommandLine === 'string' ? r.CommandLine : '']);
85
+ }
86
+ return result;
87
+ }
88
+ /**
89
+ * Node processes on Windows with their command lines — one `Get-CimInstance` call (~0.5–1 s, so it
90
+ * runs only on start/stop/status paths, never per RPC). `pid` narrows it to one process.
91
+ * Returns `null` when the query itself failed (no PowerShell, blocked by policy, timed out) — which
92
+ * is NOT the same as "no node processes", and `selectWindowsDaemons` treats it differently.
93
+ */
94
+ export function windowsNodeProcesses(pid) {
95
+ const filter = pid ? `ProcessId=${Math.trunc(pid)}` : "Name='node.exe'";
96
+ const script = `Get-CimInstance Win32_Process -Filter "${filter}" | Select-Object ProcessId,CommandLine | ConvertTo-Json -Compress`;
97
+ try {
98
+ const out = execFileSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', script], {
99
+ encoding: 'utf8',
100
+ stdio: ['ignore', 'pipe', 'ignore'],
101
+ timeout: 8000,
102
+ windowsHide: true,
103
+ });
104
+ return parseWindowsProcessJson(out);
105
+ }
106
+ catch {
107
+ return null;
108
+ }
109
+ }
110
+ /**
111
+ * Which of these Windows processes are beehaven daemons (never this process).
112
+ *
113
+ * ⚠ WHEN THE PROCESS QUERY FAILED (`rows === null`), THE PID FILE IS THE ONLY WITNESS LEFT. Seeing
114
+ * nothing would make `stop` say "No daemon running" over a live one and let `start` spawn a second
115
+ * daemon beside it — the failure the first Windows CI run showed. A live process at the recorded
116
+ * PID is trusted then, and only then: with a working query, the command line decides, so a PID
117
+ * recycled by an unrelated program is never taken for the daemon.
118
+ */
119
+ export function selectWindowsDaemons(rows, opts) {
120
+ if (rows === null) {
121
+ const p = opts.pidFilePid;
122
+ return p && p > 0 && p !== opts.selfPid && opts.isAlive(p) ? [p] : [];
123
+ }
124
+ return rows.filter(([pid, cmd]) => pid !== opts.selfPid && isBeehavenCommandLine(cmd, 'win32')).map(([pid]) => pid);
125
+ }
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
- // Give relay a moment to flush
1048
- if (daemonRelay?.isConnected()) {
1049
- await new Promise((r) => setTimeout(r, 500));
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
- if (!cmd)
1112
- return false;
1113
- // Condition 1: argv[0] must be a node/tsx/beehaven-wrapper binary.
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 /tmp/beehaven-ipc.sock.`);
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
- dashboard = new Dashboard({
4090
- port: config.dashboardPort ?? 9800,
4091
- host: '127.0.0.1',
4092
- explorerTarget: `http://127.0.0.1:${config.devPort ?? 8787}`,
4093
- explorerSecret: config.explorerSecret ?? config.adminSecret ?? '',
4094
- projectDir: config.linkedProject ?? '',
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
- .start()
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.pushEvent({
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
- this._wss = new WebSocketServer({ server: this._server });
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
- return this._config.port;
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
- const virtId = bridge.createVirtualClient(send, ['*']);
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.
@@ -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
- const IPC_SOCKET_PATH = '/tmp/beehaven-ipc.sock';
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 ?? process.env.BEEHAVEN_IPC_SOCKET ?? IPC_SOCKET_PATH;
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 && existsSync(this.socketPath)) {
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
- (existsSync(this.socketPath)
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. ' +