@clearlysh/beehaven 0.8.31 → 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.
@@ -0,0 +1,64 @@
1
+ /**
2
+ * agents-md-text — the Clearly block for a PROJECT's AGENTS.md, as `beehaven agents-md` writes it
3
+ * (CAN2-66).
4
+ *
5
+ * ⚠⚠ A COPY, PINNED. The source of truth is the monorepo's `lib/connections/agentsMd.ts`, which the
6
+ * Integrations page, each agent's details and the MCP `clearly-agents-md` prompt render. This
7
+ * package cannot import it (it ships on its own), so the text is repeated here and the monorepo's
8
+ * `lib/__tests__/agentsMdCliParity.test.mjs` compares the two OUTPUTS, name argument included.
9
+ * Change both or the test fails — a stale copy does not render wrong, it instructs wrong.
10
+ *
11
+ * ⚠ NO IMPORTS, so that test can load this file directly.
12
+ */
13
+ export const CLI_BLOCK_START = '<!-- clearly:cli:start -->';
14
+ export const CLI_BLOCK_END = '<!-- clearly:cli:end -->';
15
+ export const MCP_BLOCK_START = '<!-- clearly:mcp:start -->';
16
+ export const MCP_BLOCK_END = '<!-- clearly:mcp:end -->';
17
+ /** What goes in CLAUDE.md so Claude Code reads the AGENTS.md block too. */
18
+ export const CLAUDE_MD_IMPORT = '@AGENTS.md';
19
+ /** The block for agents that use the `beehaven` CLI. */
20
+ export function agentsMdCliBlock(agentName) {
21
+ const name = (agentName ?? '').trim();
22
+ const login = name
23
+ ? `\`beehaven agent login ${name} --label "<what you are doing>"\` — you are ${name} in this workspace.`
24
+ : '`beehaven agent login <name> --label "<what you are doing>"`, using your own name (claude, codex, gemini).';
25
+ return [
26
+ CLI_BLOCK_START,
27
+ '<!-- managed-by: clearly. Run `beehaven agents-md` to update; edit outside this block. -->',
28
+ '',
29
+ '## Clearly workspace',
30
+ '',
31
+ 'This project keeps its documents, plans, boards and tickets in Clearly. Use the `beehaven` CLI',
32
+ 'for anything the team should see, not files in this repo.',
33
+ '',
34
+ `- Sign in once per session: ${login}`,
35
+ ' If it says the daemon is not running: `beehaven start --headless`.',
36
+ '- Read the `clearly-agent` skill before anything else, and `clearly-canvas` before design work',
37
+ ' (`beehaven skills install` puts them in place).',
38
+ '- `beehaven actions` lists what you can call; `beehaven call <action> \'<json>\'` calls it.',
39
+ '- Search before you create, file work in the project it belongs to, and end the session with',
40
+ ' `beehaven agent handoff "what you did · what is next"` so the next one starts there.',
41
+ CLI_BLOCK_END,
42
+ ].join('\n');
43
+ }
44
+ /** The block for agents connected over MCP (the `clearly` server). */
45
+ export function agentsMdMcpBlock() {
46
+ return [
47
+ MCP_BLOCK_START,
48
+ '<!-- managed-by: clearly-mcp. Re-run the `clearly-agents-md` prompt to update; edit outside this block. -->',
49
+ '',
50
+ '## Clearly workspace (connected over MCP as `clearly`)',
51
+ '',
52
+ 'This project keeps its documents, plans, boards and tickets in Clearly. Use the `clearly_*`',
53
+ 'tools, not files in this repo, for anything the team should see.',
54
+ '',
55
+ '- Start with `clearly_catalog` to see what exists; search with `clearly_grep` / `clearly_glob`.',
56
+ '- Read with `clearly_read`, make new work with `clearly_create`, change it with `clearly_edit`,',
57
+ ' and discuss it with `clearly_comment`. They take a `target`: a path (`~/Q1/Plan.md`), a key',
58
+ ' (`CLR-42`), an id or an exact title. The extension names the type (`.md` document, `.csv`',
59
+ ' sheet, `.board.json` board); a trailing slash means a project.',
60
+ '- Search before you write, and write decisions back so the next session inherits them.',
61
+ '- If a job is not covered by a listed tool, say so rather than guessing an action name.',
62
+ MCP_BLOCK_END,
63
+ ].join('\n');
64
+ }
@@ -265,7 +265,12 @@ export function persistBrowserLogin(captured) {
265
265
  userId: captured.userId,
266
266
  email: captured.email ?? null,
267
267
  displayName: captured.displayName ?? null,
268
- tokenStorage: 'keychain',
268
+ /**
269
+ * ⚠ SAY WHERE THE TOKEN REALLY IS (CAN2-65). This was always 'keychain', so on Windows —
270
+ * which has no keychain here — `whoami` reported a keychain that did not exist, and
271
+ * `Relay.loadConfig` / the refresh path shelled out to macOS `security` on every load.
272
+ */
273
+ tokenStorage: keychain.available ? 'keychain' : 'file',
269
274
  refreshToken: captured.refreshToken,
270
275
  token: captured.idToken,
271
276
  endpoint: dialUrl,
@@ -53,9 +53,9 @@ export const PUBLIC_BUILD = true;
53
53
  * ⚠ REWRITTEN IN `dist/` AT BUILD TIME, like `PUBLIC_BUILD`. `unstamped` is what a source checkout
54
54
  * carries and what `tsx`/`vitest` see, and it is honest: nothing has stamped them.
55
55
  */
56
- export const BUILD_SHA = '3767a788c0ee';
56
+ export const BUILD_SHA = '8f7eef9e12ce';
57
57
  /** ISO-8601 minute precision, UTC. Rewritten in `dist/` by `scripts/stamp-build-channel.mjs`. */
58
- export const BUILD_TIME = '2026-10-13T06:40Z';
58
+ export const BUILD_TIME = '2026-10-15T07:24Z';
59
59
  /**
60
60
  * The build identity as one short string, for `--version`.
61
61
  *
package/dist/cli.js CHANGED
@@ -143,6 +143,7 @@ function printHelp() {
143
143
 
144
144
  beehaven guide Agent-first setup and signup handoff
145
145
  beehaven signup Create a free Clearly account
146
+ beehaven agents-md Add Clearly to this project's AGENTS.md
146
147
 
147
148
  Personal workspace:
148
149
  beehaven cd home
@@ -956,6 +957,11 @@ const AGENT_LOGIN_EXEMPT = new Set([
956
957
  * would refuse the one person it is for — the agent whose user has not signed up yet (SGS-73).
957
958
  */
958
959
  'handoff',
960
+ /**
961
+ * ⚠ `agents-md` WRITES A FILE IN THIS REPO AND NOTHING ELSE — it never opens a workspace, and it is
962
+ * part of setting a project up, which must not require the login it helps a teammate do (CAN2-66).
963
+ */
964
+ 'agents-md',
959
965
  ]);
960
966
  /**
961
967
  * ⚠ ALIAS → CANONICAL VERB, for the activity log's `source` column only.
@@ -1371,6 +1377,9 @@ async function run() {
1371
1377
  state: { type: 'string' },
1372
1378
  goal: { type: 'string' },
1373
1379
  remove: { type: 'boolean', default: false },
1380
+ // `agents-md`: the MCP block instead of the CLI one; print it instead of writing it (CAN2-66).
1381
+ mcp: { type: 'boolean', default: false },
1382
+ print: { type: 'boolean', default: false },
1374
1383
  /**
1375
1384
  * ⚠⚠ `beehaven sheet` / `beehaven slides` — AND AN UNDECLARED FLAG IS A FATAL, NOT A NO-OP.
1376
1385
  *
@@ -3145,6 +3154,15 @@ async function run() {
3145
3154
  console.log(' (or quit and relaunch the Hive Mac app if it owns the daemon).');
3146
3155
  break;
3147
3156
  }
3157
+ case 'agents-md': {
3158
+ const { cmdAgentsMd } = await import('./commands/agents-md.js');
3159
+ process.exitCode = cmdAgentsMd(positionals[1], {
3160
+ mcp: !!values.mcp,
3161
+ print: !!values.print,
3162
+ remove: !!values.remove,
3163
+ });
3164
+ break;
3165
+ }
3148
3166
  case 'handoff': {
3149
3167
  const { cmdHandoff } = await import('./commands/handoff.js');
3150
3168
  process.exitCode = await cmdHandoff(positionals[1], {
@@ -0,0 +1,118 @@
1
+ /**
2
+ * beehaven agents-md — put the Clearly block in THIS project's AGENTS.md (CAN2-66).
3
+ *
4
+ * ⚠⚠ TWO FILES, TWO AUDIENCES. `beehaven login` already writes a GLOBAL block into
5
+ * ~/.claude/CLAUDE.md, ~/.codex/AGENTS.md and ~/.gemini/GEMINI.md (agent-instructions.ts), so every
6
+ * agent on THIS machine knows Clearly. A project's AGENTS.md is for everyone else who opens the
7
+ * repo — a teammate's Codex, a cloud agent, CI — and it is committed and shared. That is why this
8
+ * is its own command the person (or their agent, asked first) runs, not a side effect of install.
9
+ *
10
+ * ⚠ THE MARKERS ARE THE UPDATE MECHANISM, as on every other surface (lib/connections/agentsMd.ts):
11
+ * re-running replaces only what lies between our markers and leaves the rest byte for byte. The
12
+ * CLI and MCP blocks have their own markers, so a repo used both ways keeps both.
13
+ *
14
+ * ⚠ AGENTS.md IS THE FILE; CLAUDE.md POINTS AT IT with one `@AGENTS.md` line — one copy to keep
15
+ * current. An existing CLAUDE.md gains the line only if it lacks it; nothing else in it is touched.
16
+ */
17
+ import { existsSync, readFileSync, writeFileSync } from 'fs';
18
+ import { join, resolve } from 'path';
19
+ import { execFileSync } from 'child_process';
20
+ import { color } from '../spinner.js';
21
+ import { CLI_BLOCK_START, CLI_BLOCK_END, MCP_BLOCK_START, MCP_BLOCK_END, CLAUDE_MD_IMPORT, agentsMdCliBlock, agentsMdMcpBlock, } from '../agents-md-text.js';
22
+ /** Our block replaced between its markers, or appended after one blank line. Keeps CRLF files CRLF. */
23
+ export function spliceBlock(text, block, start, end) {
24
+ const eol = text.includes('\r\n') ? '\r\n' : '\n';
25
+ const b = block.split('\n').join(eol);
26
+ const i = text.indexOf(start);
27
+ const j = i >= 0 ? text.indexOf(end, i) : -1;
28
+ if (i >= 0 && j >= 0)
29
+ return text.slice(0, i) + b + text.slice(j + end.length);
30
+ if (!text.trim())
31
+ return b + eol;
32
+ return text.replace(/\s*$/, '') + eol + eol + b + eol;
33
+ }
34
+ /** Our block taken out, with the blank line that separated it. Everything else stays. */
35
+ export function removeBlock(text, start, end) {
36
+ const i = text.indexOf(start);
37
+ const j = i >= 0 ? text.indexOf(end, i) : -1;
38
+ if (i < 0 || j < 0)
39
+ return text;
40
+ const before = text.slice(0, i).replace(/(\r?\n)*$/, '');
41
+ const after = text.slice(j + end.length).replace(/^(\r?\n)*/, '');
42
+ const eol = text.includes('\r\n') ? '\r\n' : '\n';
43
+ if (!before)
44
+ return after;
45
+ return after ? before + eol + eol + after : before + eol;
46
+ }
47
+ /** The repository root when `dir` is inside a git work tree, else `dir` itself. */
48
+ export function projectRoot(dir) {
49
+ try {
50
+ const top = execFileSync('git', ['rev-parse', '--show-toplevel'], {
51
+ cwd: dir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true, timeout: 5_000,
52
+ }).trim();
53
+ return top || dir;
54
+ }
55
+ catch {
56
+ return dir;
57
+ }
58
+ }
59
+ /** Write (or remove) the block in `<root>/AGENTS.md` and make CLAUDE.md import it. */
60
+ export function writeProjectAgentsMd(opts) {
61
+ const root = projectRoot(resolve(opts.dir));
62
+ const [start, end] = opts.mcp ? [MCP_BLOCK_START, MCP_BLOCK_END] : [CLI_BLOCK_START, CLI_BLOCK_END];
63
+ const agentsPath = join(root, 'AGENTS.md');
64
+ const claudePath = join(root, 'CLAUDE.md');
65
+ const files = [];
66
+ const had = existsSync(agentsPath);
67
+ const before = had ? readFileSync(agentsPath, 'utf8') : '';
68
+ if (opts.remove) {
69
+ const next = removeBlock(before, start, end);
70
+ if (next !== before)
71
+ writeFileSync(agentsPath, next);
72
+ files.push({ path: agentsPath, change: next !== before ? 'removed' : 'absent' });
73
+ return { root, files };
74
+ }
75
+ const next = spliceBlock(before, opts.mcp ? agentsMdMcpBlock() : agentsMdCliBlock(), start, end);
76
+ if (next !== before)
77
+ writeFileSync(agentsPath, next);
78
+ files.push({ path: agentsPath, change: !had ? 'created' : next !== before ? 'updated' : 'current' });
79
+ const claude = existsSync(claudePath) ? readFileSync(claudePath, 'utf8') : null;
80
+ if (claude === null) {
81
+ writeFileSync(claudePath, CLAUDE_MD_IMPORT + '\n');
82
+ files.push({ path: claudePath, change: 'created' });
83
+ }
84
+ else if (!claude.split(/\r?\n/).some((l) => l.trim() === CLAUDE_MD_IMPORT)) {
85
+ const eol = claude.includes('\r\n') ? '\r\n' : '\n';
86
+ writeFileSync(claudePath, claude.replace(/\s*$/, '') + (claude.trim() ? eol + eol : '') + CLAUDE_MD_IMPORT + eol);
87
+ files.push({ path: claudePath, change: 'updated' });
88
+ }
89
+ else {
90
+ files.push({ path: claudePath, change: 'current' });
91
+ }
92
+ return { root, files };
93
+ }
94
+ export function cmdAgentsMd(dir, opts) {
95
+ if (opts.print) {
96
+ process.stdout.write((opts.mcp ? agentsMdMcpBlock() : agentsMdCliBlock()) + '\n');
97
+ return 0;
98
+ }
99
+ let r;
100
+ try {
101
+ r = writeProjectAgentsMd({ dir: dir ?? process.cwd(), mcp: opts.mcp, remove: opts.remove });
102
+ }
103
+ catch (err) {
104
+ console.error(` ${color.red('✗')} ${err?.message ?? err}`);
105
+ return 1;
106
+ }
107
+ console.log('');
108
+ for (const f of r.files) {
109
+ const mark = f.change === 'current' || f.change === 'absent' ? color.dim('·') : color.green('✓');
110
+ console.log(` ${mark} ${f.path} ${color.dim(f.change)}`);
111
+ }
112
+ const changed = r.files.some((f) => f.change !== 'current' && f.change !== 'absent');
113
+ if (changed && !opts.remove) {
114
+ console.log(color.dim(`\n Commit ${r.files.map((f) => f.path.slice(r.root.length + 1)).join(' and ')} so every agent that opens this project knows the workspace.`));
115
+ }
116
+ console.log('');
117
+ return 0;
118
+ }
@@ -413,6 +413,27 @@ const PAGES = {
413
413
  \`beehaven login\` to connect the CLI to it.`,
414
414
  related: ['login', 'guide'],
415
415
  },
416
+ 'agents-md': {
417
+ synopsis: 'beehaven agents-md [dir] [--mcp] [--print] [--remove]',
418
+ description: `Add the Clearly block to this project's AGENTS.md, so every agent that opens the
419
+ repository — a teammate's Codex or Claude Code, a cloud agent, CI — knows the workspace and how
420
+ to reach it. Writes at the git root (or the directory given), and makes CLAUDE.md import it with
421
+ one \`@AGENTS.md\` line. Needs no login.
422
+
423
+ The block sits between Clearly's markers: run it again to update the block, and everything
424
+ else in the file stays as it was. \`beehaven login\` already teaches the agents on THIS machine
425
+ (their global instruction files); this is the shared, committed copy for everyone else.`,
426
+ flags: [
427
+ { name: '--mcp', desc: 'Write the block for agents connected over MCP instead of the CLI' },
428
+ { name: '--print', desc: 'Print the block and write nothing' },
429
+ { name: '--remove', desc: 'Take the block out again' },
430
+ ],
431
+ examples: [
432
+ { cmd: 'beehaven agents-md', note: 'AGENTS.md + CLAUDE.md at the repository root' },
433
+ { cmd: 'beehaven agents-md --mcp', note: 'for a team that connects over MCP' },
434
+ ],
435
+ related: ['skills', 'login'],
436
+ },
416
437
  handoff: {
417
438
  synopsis: 'beehaven handoff [file.md|-] [--tasks tasks.md|json] [--csv data.csv]… [--title "…"] [--wait] [--json]',
418
439
  description: `Give a person your work when they have no Clearly account yet. Needs no login.
package/dist/daemon.js CHANGED
@@ -4116,7 +4116,7 @@ export async function startDaemon(opts) {
4116
4116
  // so browser frontends become virtual IPC clients — same
4117
4117
  // pipeline, app-rpc, broadcast fan-out as Claude's path.
4118
4118
  dashboard.setAppBridge({
4119
- createVirtualClient: (send, subs) => ipc.createVirtualClient(send, subs),
4119
+ createVirtualClient: (send, subs, scope) => ipc.createVirtualClient(send, subs, scope),
4120
4120
  injectMessage: (id, msg) => ipc.injectMessage(id, msg),
4121
4121
  releaseVirtualClient: (id) => ipc.releaseVirtualClient(id),
4122
4122
  });
@@ -4141,15 +4141,29 @@ export async function startDaemon(opts) {
4141
4141
  }
4142
4142
  })();
4143
4143
  // ── Dashboard (local observability UI) ──────────────────────────────
4144
- dashboard = new Dashboard({
4145
- port: config.dashboardPort ?? 9800,
4146
- host: '127.0.0.1',
4147
- explorerTarget: `http://127.0.0.1:${config.devPort ?? 8787}`,
4148
- explorerSecret: config.explorerSecret ?? config.adminSecret ?? '',
4149
- projectDir: config.linkedProject ?? '',
4150
- });
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
+ });
4151
4165
  dashboard
4152
- .start()
4166
+ ?.start()
4153
4167
  .then(() => {
4154
4168
  console.log(` [dashboard] Beehaven Dashboard at ${dashboard.url}`);
4155
4169
  })
@@ -4158,7 +4172,7 @@ export async function startDaemon(opts) {
4158
4172
  });
4159
4173
  // Forward daemon events to dashboard
4160
4174
  watcher.on('event', (event) => {
4161
- dashboard.pushEvent({
4175
+ dashboard?.pushEvent({
4162
4176
  type: event.hook_event_name || event.type || 'unknown',
4163
4177
  timestamp: event.timestamp || Date.now(),
4164
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 {
@@ -602,20 +602,23 @@ export class IpcServer extends EventEmitter {
602
602
  // IPC bus so the same pipeline/app-rpc handlers serve both Claude and
603
603
  // the human UI.
604
604
  /** Register a virtual client and return its id. */
605
- createVirtualClient(virtualSend, initialSubscriptions = ['*']) {
605
+ createVirtualClient(virtualSend, initialSubscriptions = ['*'], scope = null) {
606
606
  const id = `virt_${++this.virtualClientCounter}`;
607
+ /**
608
+ * ⚠⚠ A VIRTUAL CLIENT IS ONLY AS TRUSTED AS WHOEVER IS ON THE OTHER END OF IT (CAN2-64).
609
+ * This used to be `authenticated: true, scope: 'developer'` for everyone, justified by "the
610
+ * dashboard bridge enforces its own auth (session cookie, origin check)". It enforced none,
611
+ * so any web page that opened ws://127.0.0.1:9800/apps/ws could call get-token. The bridge
612
+ * now judges the connection (dashboard.ts `appBridgeTrust`) and passes 'consumer' or null;
613
+ * a null client must authenticate with the IPC token, exactly like a socket client.
614
+ */
607
615
  const client = {
608
616
  id,
609
617
  virtualSend,
610
618
  subscriptions: initialSubscriptions,
611
619
  buffer: '',
612
- // Virtual clients are in-process — they're already inside the trust
613
- // boundary. The dashboard /apps/ws bridge enforces its own auth
614
- // (session cookie, origin check) before creating the virtual client,
615
- // so we hand them developer scope to mirror what a logged-in tray
616
- // user can already do.
617
- authenticated: true,
618
- scope: 'developer',
620
+ authenticated: scope !== null,
621
+ scope,
619
622
  };
620
623
  this.clients.set(id, client);
621
624
  // Mimic the on-connect status frame real clients receive so the bridge
@@ -60,6 +60,7 @@ export const VERB_SURFACE = {
60
60
  logout: { cls: 'STRUCTURAL', why: 'clears local credentials' },
61
61
  signup: { cls: 'STRUCTURAL', why: 'opens a browser' },
62
62
  handoff: { cls: 'STRUCTURAL', why: 'posts a local file to the anonymous door; no account, no workspace' },
63
+ 'agents-md': { cls: 'STRUCTURAL', why: "writes the Clearly block into this repo's AGENTS.md; no workspace" },
63
64
  whoami: { cls: 'STRUCTURAL', why: 'reads local credentials' },
64
65
  start: { cls: 'STRUCTURAL', why: 'spawns the daemon `call` needs' },
65
66
  stop: { cls: 'STRUCTURAL', why: 'daemon lifecycle' },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clearlysh/beehaven",
3
- "version": "0.8.31",
3
+ "version": "0.8.33",
4
4
  "description": "Beehaven CLI — command your Clearly workspace, agents, and canvas from the terminal",
5
5
  "private": false,
6
6
  "type": "module",
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: agent-doodle-space
3
- description: Use an agent identity's Avatar Soul and durable Doodle Space. Trigger when someone says "draw my avatar", "change my expression", "make a doodle", or wants to inspect an agent's visual identity. Read the minted appearance before drawing; save durable art to a session composition. Requires AGENT_SOUL_SPACE and is separate from avatar-shop.
3
+ description: Needs a terminal with the beehaven CLI (Claude Code, Codex, Gemini CLI); in a chat with only the Clearly connector, say so and use the clearly_* tools instead. Use an agent identity's Avatar Soul and durable Doodle Space. Trigger when someone says "draw my avatar", "change my expression", "make a doodle", or wants to inspect an agent's visual identity. Read the minted appearance before drawing; save durable art to a session composition. Requires AGENT_SOUL_SPACE and is separate from avatar-shop.
4
4
  ---
5
5
 
6
6
  # Agent Soul + Doodle Space
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: agent-home
3
- description: Design and inhabit an agent-owned memory apartment on Clearly's canvas. Use when someone says "build my agent home", "make a room", "save a home memory", or "move the avatar". Arrange rooms, doors, windows, furniture, avatar position, memories, journals, and Home ads on a fixed 24px grid. Requires AGENT_SOUL_SPACE.
3
+ description: Needs a terminal with the beehaven CLI (Claude Code, Codex, Gemini CLI); in a chat with only the Clearly connector, say so and use the clearly_* tools instead. Design and inhabit an agent-owned memory apartment on Clearly's canvas. Use when someone says "build my agent home", "make a room", "save a home memory", or "move the avatar". Arrange rooms, doors, windows, furniture, avatar position, memories, journals, and Home ads on a fixed 24px grid. Requires AGENT_SOUL_SPACE.
4
4
  ---
5
5
 
6
6
  # Agent Home memory apartment
@@ -1,6 +1,7 @@
1
1
  ---
2
2
  name: brand-identity
3
3
  description: >-
4
+ Needs a terminal with the beehaven CLI (Claude Code, Codex, Gemini CLI); in a chat with only the Clearly connector, say so and use the clearly_* tools instead.
4
5
  Design the requested logo or brand identity on a Clearly canvas —
5
6
  brief, mark, wordmark, lockups and clear space, palette, type system,
6
7
  applications, and a spec board someone else can build from. Use when asked