@clearlysh/beehaven 0.8.16
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/LICENSE +21 -0
- package/README.md +201 -0
- package/dist/__tests__/_cli-switch.js +105 -0
- package/dist/__tests__/agent-wrap-probe.js +136 -0
- package/dist/agent-attribution.js +192 -0
- package/dist/agent-identity-daemon.js +135 -0
- package/dist/agent-onboard.js +156 -0
- package/dist/agent-scope.js +79 -0
- package/dist/agent-sessions.js +370 -0
- package/dist/agent-state.js +482 -0
- package/dist/app-manifest.js +221 -0
- package/dist/app-signer.js +54 -0
- package/dist/app-signing.js +127 -0
- package/dist/audio-play.js +98 -0
- package/dist/avatar-protocol.js +25 -0
- package/dist/avatar-route.js +79 -0
- package/dist/bee/cloud-proxy.js +130 -0
- package/dist/blocked-actions.js +120 -0
- package/dist/build-channel.js +101 -0
- package/dist/buildings/daemon-workspace.js +475 -0
- package/dist/claude-bridge.js +310 -0
- package/dist/claude-credential-store.js +633 -0
- package/dist/clearly-sync.js +518 -0
- package/dist/clearly.js +616 -0
- package/dist/cli-state.js +334 -0
- package/dist/cli.js +6973 -0
- package/dist/commands/agent-skills.js +302 -0
- package/dist/commands/agent.js +1310 -0
- package/dist/commands/app.js +1123 -0
- package/dist/commands/auth.js +171 -0
- package/dist/commands/building-shell.js +392 -0
- package/dist/commands/canvas.js +932 -0
- package/dist/commands/claude.js +218 -0
- package/dist/commands/compliance.js +387 -0
- package/dist/commands/compose.js +507 -0
- package/dist/commands/composition.js +95 -0
- package/dist/commands/connect-session.js +409 -0
- package/dist/commands/conversations.js +160 -0
- package/dist/commands/costs.js +443 -0
- package/dist/commands/d1.js +253 -0
- package/dist/commands/daemon-cmd.js +162 -0
- package/dist/commands/deploy.js +164 -0
- package/dist/commands/design.js +617 -0
- package/dist/commands/dev-generate.js +125 -0
- package/dist/commands/dev.js +109 -0
- package/dist/commands/devices.js +135 -0
- package/dist/commands/explain-router.js +24 -0
- package/dist/commands/explain.js +882 -0
- package/dist/commands/explore.js +264 -0
- package/dist/commands/help.js +2418 -0
- package/dist/commands/init.js +408 -0
- package/dist/commands/install.js +129 -0
- package/dist/commands/library.js +229 -0
- package/dist/commands/marketplace.js +138 -0
- package/dist/commands/mcp-install.js +476 -0
- package/dist/commands/mention.js +257 -0
- package/dist/commands/omni.js +529 -0
- package/dist/commands/secrets.js +323 -0
- package/dist/commands/self-update.js +206 -0
- package/dist/commands/sheet.js +264 -0
- package/dist/commands/shell-output.js +309 -0
- package/dist/commands/shell.js +1048 -0
- package/dist/commands/shop.js +368 -0
- package/dist/commands/skills.js +311 -0
- package/dist/commands/slides.js +418 -0
- package/dist/commands/swarm.js +341 -0
- package/dist/commands/ticket.js +1301 -0
- package/dist/commands/tool.js +312 -0
- package/dist/commands/trash.js +106 -0
- package/dist/commands/treehouse.js +120 -0
- package/dist/commands/user.js +240 -0
- package/dist/commands/voice-tts.js +319 -0
- package/dist/commands/voice.js +166 -0
- package/dist/commands/watch-git.js +401 -0
- package/dist/commands/webhook.js +159 -0
- package/dist/commands/workspace.js +3354 -0
- package/dist/composition-repos.js +91 -0
- package/dist/daemon-avatar.js +130 -0
- package/dist/daemon-claude-code-auto-install.js +52 -0
- package/dist/daemon-client.js +363 -0
- package/dist/daemon-codex.js +349 -0
- package/dist/daemon-entry.js +101 -0
- package/dist/daemon-fetch-allowlist.js +194 -0
- package/dist/daemon-gemini.js +224 -0
- package/dist/daemon-mcp-clearly.js +353 -0
- package/dist/daemon-mcp-sandbox.js +127 -0
- package/dist/daemon-omni-company.js +338 -0
- package/dist/daemon-omni-hooks.js +375 -0
- package/dist/daemon-omni-identity.js +229 -0
- package/dist/daemon-omni-pipeline.js +251 -0
- package/dist/daemon-omni-policies.js +346 -0
- package/dist/daemon-omni-principal.js +580 -0
- package/dist/daemon-omni-tool-gate.js +116 -0
- package/dist/daemon-omni.js +1442 -0
- package/dist/daemon-path-transform.js +120 -0
- package/dist/daemon-write-sandbox.js +191 -0
- package/dist/daemon.js +4471 -0
- package/dist/dashboard.js +846 -0
- package/dist/dep-installer.js +286 -0
- package/dist/dev-mode.js +67 -0
- package/dist/doc-indexer.js +169 -0
- package/dist/e2e-crypto.js +94 -0
- package/dist/feature-flags.js +154 -0
- package/dist/file-browser.js +88 -0
- package/dist/first-party-hives.js +211 -0
- package/dist/folder-sync.js +226 -0
- package/dist/health.js +56 -0
- package/dist/ipc-auth.js +299 -0
- package/dist/ipc-client.js +495 -0
- package/dist/ipc-server.js +684 -0
- package/dist/keychain.js +224 -0
- package/dist/lib/admin-client.js +73 -0
- package/dist/lib/mobile-build-parser.js +156 -0
- package/dist/local-app-manager.js +660 -0
- package/dist/log-redaction.js +89 -0
- package/dist/logger.js +108 -0
- package/dist/manifest.js +143 -0
- package/dist/marketplace.js +793 -0
- package/dist/min-version-copy.js +51 -0
- package/dist/min-version.js +214 -0
- package/dist/public-surface.js +23 -0
- package/dist/relay-presets.js +89 -0
- package/dist/relay.js +2723 -0
- package/dist/repl.js +619 -0
- package/dist/setup-relay.js +363 -0
- package/dist/setup-service.js +536 -0
- package/dist/shared/bee-memory/index.js +6 -0
- package/dist/shared/bee-memory/types.js +682 -0
- package/dist/shared/claude-code/daemon-types.js +112 -0
- package/dist/shared/claude-code/index.js +2 -0
- package/dist/shared/claude-code/types.js +1 -0
- package/dist/shared/status/schema.js +133 -0
- package/dist/shell-session.js +198 -0
- package/dist/speak-lock.js +97 -0
- package/dist/spinner.js +57 -0
- package/dist/telemetry.js +166 -0
- package/dist/terminal/block-parser.js +207 -0
- package/dist/terminal/shell-integration.js +108 -0
- package/dist/transcript-tailer.js +215 -0
- package/dist/tts-qwen3.js +547 -0
- package/dist/types.js +4 -0
- package/dist/utm.js +38 -0
- package/dist/verb-surface.js +331 -0
- package/dist/version-utils.js +85 -0
- package/dist/watcher.js +207 -0
- package/package.json +88 -0
- package/python/qwen3_tts.py +230 -0
- package/scripts/stamp-build-channel.mjs +115 -0
- package/scripts/sync-installed.mjs +159 -0
- package/scripts/sync-skills.mjs +112 -0
- package/skills/README.md +24 -0
- package/skills/agent-doodle-space/SKILL.md +118 -0
- package/skills/agent-home/SKILL.md +98 -0
- package/skills/beehaven-cli/SKILL.md +144 -0
- package/skills/claude-shopper/README.md +90 -0
- package/skills/claude-shopper/SKILL.md +151 -0
- package/skills/clearly-agent/SKILL.md +183 -0
- package/skills/clearly-canvas/SKILL.md +222 -0
- package/skills/clearly-canvas/authoring.md +160 -0
- package/skills/clearly-canvas/communications.md +32 -0
- package/skills/clearly-canvas/composition-patterns.md +84 -0
- package/skills/clearly-canvas/example.mjs +78 -0
- package/skills/clearly-canvas/project-plans.md +156 -0
- package/skills/clearly-canvas/review.mjs +68 -0
- package/skills/clearly-workspace/SKILL.md +79 -0
- package/skills/clearly-workspace/survey.mjs +101 -0
- package/skills/codebase-map/SKILL.md +142 -0
- package/skills/design-craft/SKILL.md +300 -0
- package/skills/index.json +79 -0
- package/skills/layout-systems/SKILL.md +184 -0
- package/skills/pair-on-canvas/SKILL.md +92 -0
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* .app manifest schema and validator.
|
|
3
|
+
*
|
|
4
|
+
* A .app is a zip archive containing:
|
|
5
|
+
*
|
|
6
|
+
* app.json ← the manifest described by this file
|
|
7
|
+
* src/buildings/*.js? ← optional — compiled Workspace classes
|
|
8
|
+
* ui/** ← optional — static frontend files (HTML/JS/CSS)
|
|
9
|
+
* recipes/*.json? ← optional — named Pipeline specs callable by id
|
|
10
|
+
* assets/**? ← optional — images, samples, presets
|
|
11
|
+
*
|
|
12
|
+
* Apps are installed to ~/.beehaven/apps/<name>@<version>/. The daemon
|
|
13
|
+
* scans that directory at startup and:
|
|
14
|
+
* - registers each manifest.buildings[type].entry as a local building,
|
|
15
|
+
* - exposes each manifest.ui/ directory at http://127.0.0.1:9800/apps/<name>/,
|
|
16
|
+
* - makes each manifest.recipes[id] callable via the `pipeline` IPC RPC.
|
|
17
|
+
*
|
|
18
|
+
* The same Workspace RPC surface drives BOTH Claude (via IPC) and the
|
|
19
|
+
* browser UI (via WebSocket). An app is "an app for LLMs and an app for
|
|
20
|
+
* humans in one package" — the shared RPC contract is the seam.
|
|
21
|
+
*/
|
|
22
|
+
/**
|
|
23
|
+
* Lightweight structural validation — NOT a schema compiler. We reject
|
|
24
|
+
* manifests the loader cannot safely use; everything else passes.
|
|
25
|
+
*/
|
|
26
|
+
export function validateAppManifest(m) {
|
|
27
|
+
const errors = [];
|
|
28
|
+
const warnings = [];
|
|
29
|
+
const req = (cond, msg) => {
|
|
30
|
+
if (!cond)
|
|
31
|
+
errors.push(msg);
|
|
32
|
+
};
|
|
33
|
+
const warn = (cond, msg) => {
|
|
34
|
+
if (!cond)
|
|
35
|
+
warnings.push(msg);
|
|
36
|
+
};
|
|
37
|
+
req(m && typeof m === 'object', 'manifest must be an object');
|
|
38
|
+
if (!m || typeof m !== 'object')
|
|
39
|
+
return { valid: false, errors, warnings };
|
|
40
|
+
req(typeof m.name === 'string' && /^[a-z0-9][a-z0-9-_.]*$/.test(m.name), 'name must be a lowercase dash-separated identifier');
|
|
41
|
+
req(typeof m.version === 'string' && /^\d+\.\d+\.\d+(-[0-9a-z.]+)?$/i.test(m.version), 'version must be semver (e.g. 1.0.0 or 1.0.0-beta.1)');
|
|
42
|
+
req(!!m.vendor && typeof m.vendor === 'object', 'vendor object is required');
|
|
43
|
+
if (m.vendor) {
|
|
44
|
+
req(typeof m.vendor.name === 'string', 'vendor.name is required');
|
|
45
|
+
req(typeof m.vendor.id === 'string', 'vendor.id is required');
|
|
46
|
+
}
|
|
47
|
+
// Must have at LEAST one of buildings, recipes, or frontend — an empty
|
|
48
|
+
// app is almost always a bug.
|
|
49
|
+
const hasPayload = (m.buildings && Object.keys(m.buildings).length > 0) ||
|
|
50
|
+
(m.recipes && Object.keys(m.recipes).length > 0) ||
|
|
51
|
+
(m.frontend && typeof m.frontend === 'object');
|
|
52
|
+
req(hasPayload, 'manifest must declare at least one of: buildings, recipes, frontend');
|
|
53
|
+
if (m.buildings) {
|
|
54
|
+
for (const [key, b] of Object.entries(m.buildings)) {
|
|
55
|
+
const building = b;
|
|
56
|
+
// `runtime` is canonical, `hosted` is a deprecated alias. When both
|
|
57
|
+
// are set, `runtime` wins. Backward-compat: existing hives only
|
|
58
|
+
// setting `hosted` continue to work unchanged.
|
|
59
|
+
const runtime = building?.runtime ?? building?.hosted ?? 'local';
|
|
60
|
+
if (runtime === 'local') {
|
|
61
|
+
req(typeof building?.entry === 'string', `buildings["${key}"].entry must be a string path for runtime="local"`);
|
|
62
|
+
}
|
|
63
|
+
else if (runtime === 'cloud') {
|
|
64
|
+
req(typeof building?.cloudUrl === 'string', `buildings["${key}"].cloudUrl must be a URL for runtime="cloud"`);
|
|
65
|
+
}
|
|
66
|
+
else if (runtime === 'browser') {
|
|
67
|
+
req(typeof building?.entry === 'string', `buildings["${key}"].entry must be a string path for runtime="browser"`);
|
|
68
|
+
if (building?.fallbackFor !== undefined) {
|
|
69
|
+
req(typeof building.fallbackFor === 'string' && m.buildings[building.fallbackFor], `buildings["${key}"].fallbackFor must reference an existing local building (got "${building.fallbackFor}")`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
errors.push(`buildings["${key}"].runtime must be "local", "cloud", or "browser" (got "${runtime}")`);
|
|
74
|
+
}
|
|
75
|
+
// `requires` references a resource id — must be a string array.
|
|
76
|
+
if (building?.requires !== undefined) {
|
|
77
|
+
req(Array.isArray(building.requires) &&
|
|
78
|
+
building.requires.every((r) => typeof r === 'string'), `buildings["${key}"].requires must be a string[] of resource ids`);
|
|
79
|
+
}
|
|
80
|
+
// `handles` references action ids — used for browser fallback routing.
|
|
81
|
+
if (building?.handles !== undefined) {
|
|
82
|
+
req(Array.isArray(building.handles) &&
|
|
83
|
+
building.handles.every((h) => typeof h === 'string'), `buildings["${key}"].handles must be a string[] of action ids`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (m.resources) {
|
|
88
|
+
req(typeof m.resources === 'object' && !Array.isArray(m.resources), 'resources must be an object with required/user-selectable/optional arrays');
|
|
89
|
+
if (m.resources && typeof m.resources === 'object') {
|
|
90
|
+
for (const flavor of ['required', 'user-selectable', 'optional']) {
|
|
91
|
+
const arr = m.resources[flavor];
|
|
92
|
+
if (arr === undefined)
|
|
93
|
+
continue;
|
|
94
|
+
req(Array.isArray(arr), `resources.${flavor} must be an array`);
|
|
95
|
+
if (Array.isArray(arr)) {
|
|
96
|
+
for (let i = 0; i < arr.length; i++) {
|
|
97
|
+
const ref = arr[i];
|
|
98
|
+
if (typeof ref === 'string') {
|
|
99
|
+
req(/^[a-z][a-z0-9-_.]*$/.test(ref), `resources.${flavor}[${i}] ("${ref}") must be a lowercase dash-separated id`);
|
|
100
|
+
}
|
|
101
|
+
else if (ref && typeof ref === 'object') {
|
|
102
|
+
req(typeof ref.id === 'string', `resources.${flavor}[${i}].id is required`);
|
|
103
|
+
}
|
|
104
|
+
else {
|
|
105
|
+
errors.push(`resources.${flavor}[${i}] must be a string or ResourceRef object`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
if (m.actions) {
|
|
113
|
+
req(typeof m.actions === 'object' && !Array.isArray(m.actions), 'actions must be an object keyed by dotted action id');
|
|
114
|
+
if (m.actions && typeof m.actions === 'object') {
|
|
115
|
+
for (const [id, a] of Object.entries(m.actions)) {
|
|
116
|
+
const spec = a;
|
|
117
|
+
req(/^[a-z][a-z0-9_-]*\.[a-z][a-z0-9_-]*$/.test(id), `actions["${id}"] id must be "<building>.<action>" (lowercase, dotted)`);
|
|
118
|
+
req(spec && typeof spec === 'object', `actions["${id}"] must be an object`);
|
|
119
|
+
if (spec && typeof spec === 'object') {
|
|
120
|
+
req(typeof spec.building === 'string', `actions["${id}"].building is required`);
|
|
121
|
+
// Cross-reference: the action's `building` must exist in `buildings`.
|
|
122
|
+
if (spec.building && m.buildings && !m.buildings[spec.building]) {
|
|
123
|
+
warnings.push(`actions["${id}"].building ("${spec.building}") doesn't match any declared building — typo?`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
if (m.frontend) {
|
|
130
|
+
const fhosted = m.frontend.hosted ?? (m.frontend.dir ? 'local' : m.frontend.cloudUrl ? 'cloud' : 'local');
|
|
131
|
+
if (fhosted === 'local') {
|
|
132
|
+
req(typeof m.frontend.dir === 'string', 'frontend.dir must be a string path for hosted="local"');
|
|
133
|
+
}
|
|
134
|
+
else {
|
|
135
|
+
req(typeof m.frontend.cloudUrl === 'string', 'frontend.cloudUrl must be a URL for hosted="cloud"');
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
if (m.recipes) {
|
|
139
|
+
for (const [id, r] of Object.entries(m.recipes)) {
|
|
140
|
+
if (typeof r === 'string')
|
|
141
|
+
continue; // path to JSON file — resolved on load
|
|
142
|
+
req(Array.isArray(r?.steps), `recipes["${id}"].steps must be an array`);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
warn(!m.license || !!m.license.tier, 'license.tier is recommended (free|pro|enterprise|byol)');
|
|
146
|
+
warn(!m.signature, 'manifest is unsigned — signed apps will be required in a future release');
|
|
147
|
+
if (m.tray) {
|
|
148
|
+
req(typeof m.tray === 'object' && !Array.isArray(m.tray), 'tray must be an object');
|
|
149
|
+
if (m.tray && Array.isArray(m.tray.settings)) {
|
|
150
|
+
for (let i = 0; i < m.tray.settings.length; i++) {
|
|
151
|
+
const s = m.tray.settings[i];
|
|
152
|
+
if (!s || typeof s !== 'object') {
|
|
153
|
+
errors.push(`tray.settings[${i}] must be an object`);
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
req(typeof s.key === 'string' && s.key.length > 0, `tray.settings[${i}].key is required`);
|
|
157
|
+
// action + actionField paired (one without the other = no-op).
|
|
158
|
+
if ((s.action && !s.actionField) || (s.actionField && !s.action)) {
|
|
159
|
+
warnings.push(`tray.settings[${i}] (${s.key}): action + actionField must be set together`);
|
|
160
|
+
}
|
|
161
|
+
// cloudSync — formalize per task #79 + harden per bug #12 from
|
|
162
|
+
// the 2.19.0 audit. Constraints:
|
|
163
|
+
// - kebab-case (lowercase letters + digits + hyphens,
|
|
164
|
+
// starting with a letter)
|
|
165
|
+
// - max 64 chars
|
|
166
|
+
// - MUST NOT contain ':' — that's reserved for the daemon's
|
|
167
|
+
// app-namespacing layer (which prepends the app name) so
|
|
168
|
+
// a hostile manifest can't escape its own namespace by
|
|
169
|
+
// pre-namespacing into another hive's settings space.
|
|
170
|
+
if (s.cloudSync !== undefined) {
|
|
171
|
+
req(typeof s.cloudSync === 'string', `tray.settings[${i}].cloudSync must be a string`);
|
|
172
|
+
if (typeof s.cloudSync === 'string') {
|
|
173
|
+
req(/^[a-z][a-z0-9-]*$/.test(s.cloudSync), `tray.settings[${i}].cloudSync ("${s.cloudSync}") must be kebab-case (lowercase letters + digits + hyphens, starting with a letter)`);
|
|
174
|
+
req(s.cloudSync.length <= 64, `tray.settings[${i}].cloudSync must be ≤64 chars (got ${s.cloudSync.length})`);
|
|
175
|
+
req(!s.cloudSync.includes(':'), `tray.settings[${i}].cloudSync must not contain ':' — the daemon prepends the app name automatically to prevent cross-app key collisions`);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
if (m.agentDocs) {
|
|
182
|
+
req(typeof m.agentDocs === 'object' && !Array.isArray(m.agentDocs), 'agentDocs must be an object');
|
|
183
|
+
if (m.agentDocs && typeof m.agentDocs === 'object' && !Array.isArray(m.agentDocs)) {
|
|
184
|
+
// Every known key must be a non-empty relative path. We reject `..`
|
|
185
|
+
// path segments (real traversal) but allow filenames like `..foo` —
|
|
186
|
+
// the earlier `includes('..')` check had a false-positive on those.
|
|
187
|
+
// Also reject absolute paths and empty strings; an empty string
|
|
188
|
+
// would join to the install dir itself and crash on readFileSync.
|
|
189
|
+
const safe = (p) => typeof p === 'string' && p.length > 0 && !/(^|\/)\.\.($|\/)/.test(p) && !p.startsWith('/');
|
|
190
|
+
const known = ['claudeCode', 'cursor', 'mcp'];
|
|
191
|
+
for (const k of known) {
|
|
192
|
+
if (m.agentDocs[k] !== undefined) {
|
|
193
|
+
req(safe(m.agentDocs[k]), `agentDocs.${k} must be a non-empty relative path inside the archive (no .. segments or absolute paths)`);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
// Warn — don't reject — on unrecognized keys so a typo like
|
|
197
|
+
// `claudCode` doesn't silently disable skill install. Forward-
|
|
198
|
+
// compatible with future surfaces (cursor, mcp already reserved).
|
|
199
|
+
for (const k of Object.keys(m.agentDocs)) {
|
|
200
|
+
if (!known.includes(k)) {
|
|
201
|
+
warnings.push(`agentDocs.${k} is not a recognized key (known: ${known.join(', ')}). Typo?`);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return { valid: errors.length === 0, errors, warnings };
|
|
207
|
+
}
|
|
208
|
+
/** Safe JSON parse with typed result. */
|
|
209
|
+
export function parseAppManifest(json) {
|
|
210
|
+
try {
|
|
211
|
+
const parsed = JSON.parse(json);
|
|
212
|
+
const result = validateAppManifest(parsed);
|
|
213
|
+
if (!result.valid) {
|
|
214
|
+
return { error: `Invalid manifest: ${result.errors.join('; ')}` };
|
|
215
|
+
}
|
|
216
|
+
return { manifest: parsed };
|
|
217
|
+
}
|
|
218
|
+
catch (err) {
|
|
219
|
+
return { error: `Failed to parse JSON: ${err?.message ?? err}` };
|
|
220
|
+
}
|
|
221
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sign .app archives with an Ed25519 private key.
|
|
3
|
+
*
|
|
4
|
+
* Separated from app-signing.ts because the verifier ships in every CLI
|
|
5
|
+
* build (end users need it), but the signer is only used by the release
|
|
6
|
+
* pipeline — keeping the private-key-handling code in a distinct module
|
|
7
|
+
* makes it obvious what should never ship in a bundled build.
|
|
8
|
+
*
|
|
9
|
+
* Usage (release CI):
|
|
10
|
+
* beehaven app pack ./my-hive --key $HOME/.clearly/release-2026.pem \
|
|
11
|
+
* --signer clearly-release-2026
|
|
12
|
+
*
|
|
13
|
+
* Key generation:
|
|
14
|
+
* openssl genpkey -algorithm ed25519 -out release-2026.pem
|
|
15
|
+
* openssl pkey -in release-2026.pem -pubout -out release-2026.pub
|
|
16
|
+
*
|
|
17
|
+
* The .pub file's contents go into TRUSTED_SIGNERS in app-signing.ts.
|
|
18
|
+
*/
|
|
19
|
+
import { createHash, createPrivateKey, sign } from 'crypto';
|
|
20
|
+
import { readFileSync } from 'fs';
|
|
21
|
+
/**
|
|
22
|
+
* Produce a detached signature envelope for an archive.
|
|
23
|
+
*
|
|
24
|
+
* @param archivePath path to the .app zip file to sign
|
|
25
|
+
* @param privateKeyPath path to the Ed25519 PEM (PKCS#8 or SEC1)
|
|
26
|
+
* @param signerId the pinned id that must match a TRUSTED_SIGNERS
|
|
27
|
+
* entry in the verifier (e.g. "clearly-release-2026")
|
|
28
|
+
*/
|
|
29
|
+
export async function signAppArchive(archivePath, privateKeyPath, signerId) {
|
|
30
|
+
// Hash the archive. Signing the hex digest rather than the raw bytes
|
|
31
|
+
// keeps verification cheap and makes bad-hash diagnostics clear when
|
|
32
|
+
// something mismatches — the verifier hashes the archive itself and
|
|
33
|
+
// compares before checking the signature.
|
|
34
|
+
const bytes = readFileSync(archivePath);
|
|
35
|
+
const digest = createHash('sha256').update(bytes).digest('hex');
|
|
36
|
+
// Read the PEM. createPrivateKey validates shape and algorithm; anything
|
|
37
|
+
// non-Ed25519 throws here with a clear error.
|
|
38
|
+
const pemBytes = readFileSync(privateKeyPath);
|
|
39
|
+
const key = createPrivateKey(pemBytes);
|
|
40
|
+
if (key.asymmetricKeyType !== 'ed25519') {
|
|
41
|
+
throw new Error(`Expected an ed25519 private key at ${privateKeyPath}, got ${key.asymmetricKeyType}`);
|
|
42
|
+
}
|
|
43
|
+
// Sign the hex digest string. Ed25519 signs the message directly (no
|
|
44
|
+
// pre-hash — it hashes internally) so `algorithm = null` is the
|
|
45
|
+
// correct Node crypto call.
|
|
46
|
+
const signature = sign(null, Buffer.from(digest, 'utf-8'), key);
|
|
47
|
+
return {
|
|
48
|
+
v: 1,
|
|
49
|
+
alg: 'ed25519',
|
|
50
|
+
signer: signerId,
|
|
51
|
+
sha256: digest,
|
|
52
|
+
sig: signature.toString('base64'),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* App signing — verify `.app` archive signatures before install.
|
|
3
|
+
*
|
|
4
|
+
* Threat model:
|
|
5
|
+
* - `beehaven app install https://…/foo.app` pipes a curl download
|
|
6
|
+
* into unzip. A MITM, CDN compromise, or malicious mirror can serve
|
|
7
|
+
* arbitrary bytes that become installed buildings with unrestricted
|
|
8
|
+
* filesystem access inside the daemon process.
|
|
9
|
+
* - A private npm/GitHub release without signing gives the same risk:
|
|
10
|
+
* anyone with repo write access ships RCE.
|
|
11
|
+
*
|
|
12
|
+
* Protection:
|
|
13
|
+
* - Each `.app` archive may be accompanied by a `.app.sig` detached
|
|
14
|
+
* signature. The signature is an Ed25519 signature over the SHA-256
|
|
15
|
+
* of the archive, made by one of the keys in TRUSTED_SIGNERS below.
|
|
16
|
+
* - On install, we fetch/read both files, verify the sig, then proceed.
|
|
17
|
+
* No sig = reject unless the user explicitly passes --allow-unsigned.
|
|
18
|
+
*
|
|
19
|
+
* Signature format (JSON, one object per file):
|
|
20
|
+
* {
|
|
21
|
+
* "v": 1,
|
|
22
|
+
* "alg": "ed25519",
|
|
23
|
+
* "signer": "clearly-release-2026",
|
|
24
|
+
* "sha256": "64-char hex",
|
|
25
|
+
* "sig": "base64 ed25519 signature"
|
|
26
|
+
* }
|
|
27
|
+
*
|
|
28
|
+
* The signer id is pinned so rotating keys without updating the pin is
|
|
29
|
+
* impossible. Adding a new key requires updating TRUSTED_SIGNERS in code.
|
|
30
|
+
*/
|
|
31
|
+
import { createHash, createPublicKey, verify } from 'crypto';
|
|
32
|
+
import { readFileSync } from 'fs';
|
|
33
|
+
/**
|
|
34
|
+
* The real Clearly release key(s) get dropped in here before the CLI
|
|
35
|
+
* publishes. Keeping this empty in source means the CLI rejects every
|
|
36
|
+
* signed install until the pin is supplied — we fail closed on the CI
|
|
37
|
+
* side rather than shipping with a placeholder key that attackers can
|
|
38
|
+
* abuse.
|
|
39
|
+
*
|
|
40
|
+
* Tests / local dev: use the `trustedSigners` argument of verifyAppSig()
|
|
41
|
+
* to inject a test key pair, or pass --allow-unsigned to cmdAppInstall.
|
|
42
|
+
*/
|
|
43
|
+
export const TRUSTED_SIGNERS = [
|
|
44
|
+
// Uncomment + paste production pubkey here before first signed release.
|
|
45
|
+
// {
|
|
46
|
+
// id: 'clearly-release-2026-01',
|
|
47
|
+
// publicKeyPem:
|
|
48
|
+
// '-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEA…\n-----END PUBLIC KEY-----\n',
|
|
49
|
+
// note: 'Clearly prod release signing key (2026-Q1)',
|
|
50
|
+
// },
|
|
51
|
+
];
|
|
52
|
+
/**
|
|
53
|
+
* Verify a `.app.sig` envelope against a local archive path.
|
|
54
|
+
*
|
|
55
|
+
* @param archivePath path to the .app file on disk
|
|
56
|
+
* @param envelope parsed signature envelope, typically JSON from .app.sig
|
|
57
|
+
* @param trustedKeys override TRUSTED_SIGNERS; used by tests
|
|
58
|
+
*/
|
|
59
|
+
export function verifyAppSignature(archivePath, envelope, trustedKeys = TRUSTED_SIGNERS) {
|
|
60
|
+
// Envelope shape checks
|
|
61
|
+
if (!envelope || envelope.v !== 1)
|
|
62
|
+
return { ok: false, reason: 'Unsupported signature version' };
|
|
63
|
+
if (envelope.alg !== 'ed25519')
|
|
64
|
+
return { ok: false, reason: `Unsupported algorithm ${envelope.alg}` };
|
|
65
|
+
if (typeof envelope.signer !== 'string')
|
|
66
|
+
return { ok: false, reason: 'Missing signer id' };
|
|
67
|
+
if (typeof envelope.sha256 !== 'string' || !/^[0-9a-f]{64}$/i.test(envelope.sha256)) {
|
|
68
|
+
return { ok: false, reason: 'Missing or malformed sha256 digest' };
|
|
69
|
+
}
|
|
70
|
+
if (typeof envelope.sig !== 'string')
|
|
71
|
+
return { ok: false, reason: 'Missing signature bytes' };
|
|
72
|
+
// Signer must be one we've pinned
|
|
73
|
+
const signer = trustedKeys.find((s) => s.id === envelope.signer);
|
|
74
|
+
if (!signer)
|
|
75
|
+
return { ok: false, reason: `Unknown signer "${envelope.signer}"` };
|
|
76
|
+
// Hash the archive locally and compare with the signed digest. This
|
|
77
|
+
// binds the signature to the exact bytes on disk — a MITM can't swap
|
|
78
|
+
// the archive while reusing a valid sig, and we detect silent
|
|
79
|
+
// corruption in flight.
|
|
80
|
+
const actualDigest = sha256OfFile(archivePath);
|
|
81
|
+
if (actualDigest.toLowerCase() !== envelope.sha256.toLowerCase()) {
|
|
82
|
+
return {
|
|
83
|
+
ok: false,
|
|
84
|
+
reason: `Archive sha256 mismatch (expected ${envelope.sha256}, got ${actualDigest})`,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
// Verify Ed25519 signature over the digest. We sign the digest (not
|
|
88
|
+
// the whole file) so verification cost is constant even for 100MB hives.
|
|
89
|
+
let pubKey;
|
|
90
|
+
try {
|
|
91
|
+
pubKey = createPublicKey(signer.publicKeyPem);
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
return { ok: false, reason: `Malformed trusted public key for "${signer.id}"` };
|
|
95
|
+
}
|
|
96
|
+
let sigBuf;
|
|
97
|
+
try {
|
|
98
|
+
sigBuf = Buffer.from(envelope.sig, 'base64');
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
return { ok: false, reason: 'Signature is not valid base64' };
|
|
102
|
+
}
|
|
103
|
+
// Node's verify() with `null` algorithm does Ed25519 verification
|
|
104
|
+
// directly (ed25519 signs its own SHA-512 internally — we're signing
|
|
105
|
+
// the hex digest string so each byte matters).
|
|
106
|
+
const digestBytes = Buffer.from(envelope.sha256.toLowerCase(), 'utf-8');
|
|
107
|
+
const ok = verify(null, digestBytes, pubKey, sigBuf);
|
|
108
|
+
if (!ok)
|
|
109
|
+
return { ok: false, reason: 'Signature does not verify against pinned key' };
|
|
110
|
+
return { ok: true, signerId: signer.id };
|
|
111
|
+
}
|
|
112
|
+
/** Parse a `.app.sig` file from disk. Returns null on malformed JSON. */
|
|
113
|
+
export function loadSignatureEnvelope(sigPath) {
|
|
114
|
+
try {
|
|
115
|
+
const raw = readFileSync(sigPath, 'utf-8');
|
|
116
|
+
const parsed = JSON.parse(raw);
|
|
117
|
+
return parsed;
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
/** Compute the sha256 hex of a file (streaming for large archives). */
|
|
124
|
+
export function sha256OfFile(path) {
|
|
125
|
+
const buf = readFileSync(path);
|
|
126
|
+
return createHash('sha256').update(buf).digest('hex');
|
|
127
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* audio-play.ts — play a synthesized audio buffer on THIS machine, with no
|
|
3
|
+
* desktop app and no GUI session.
|
|
4
|
+
*
|
|
5
|
+
* WHY THIS EXISTS. `beehaven speak` used to have exactly one voice path: RPC the
|
|
6
|
+
* desktop avatar building (`avatar/live`) and let the Mac app's on-device Kokoro
|
|
7
|
+
* play it. Headless — a CLI-owned daemon, an agent in a terminal, CI, Linux —
|
|
8
|
+
* that building is never loaded, so speak warned loudly and dropped to macOS
|
|
9
|
+
* `say`. On Linux it had no voice at all.
|
|
10
|
+
*
|
|
11
|
+
* The answer WAS a cloud rung (`voice-synthesize` → the KOKORO_TTS container),
|
|
12
|
+
* and this file existed to turn its returned WAV bytes into sound.
|
|
13
|
+
*
|
|
14
|
+
* ⚠⚠ THAT RUNG IS GONE (2026-08-27) AND THIS FILE OUTLIVED IT ON PURPOSE.
|
|
15
|
+
* `speak` is on-device only now, so the headless gap it was written for is
|
|
16
|
+
* answered by `qwen3-mlx` instead — which synthesizes to a FILE. Hence
|
|
17
|
+
* `playAudioFile` survives and the buffer/base64 pair was deleted with the
|
|
18
|
+
* caller that needed them: nothing else ever held audio as bytes.
|
|
19
|
+
*
|
|
20
|
+
* DESIGN. No npm audio dependency — every player here ships with the OS or is a
|
|
21
|
+
* thing the user already installed. We probe in order and use the first that
|
|
22
|
+
* exists, so a box with none of them degrades to a clear message instead of a
|
|
23
|
+
* stack trace.
|
|
24
|
+
*/
|
|
25
|
+
import { spawn } from 'child_process';
|
|
26
|
+
/** Players by platform, in preference order. `args` gets the file path appended. */
|
|
27
|
+
const PLAYERS = {
|
|
28
|
+
darwin: [{ cmd: 'afplay', args: [] }],
|
|
29
|
+
linux: [
|
|
30
|
+
// -q/-nodisp keep these from scribbling over an agent's terminal output.
|
|
31
|
+
{ cmd: 'paplay', args: [] },
|
|
32
|
+
{ cmd: 'aplay', args: ['-q'] },
|
|
33
|
+
{ cmd: 'ffplay', args: ['-nodisp', '-autoexit', '-loglevel', 'quiet'] },
|
|
34
|
+
{ cmd: 'mpv', args: ['--no-video', '--really-quiet'] },
|
|
35
|
+
],
|
|
36
|
+
win32: [
|
|
37
|
+
{
|
|
38
|
+
cmd: 'powershell',
|
|
39
|
+
args: ['-NoProfile', '-Command', '(New-Object Media.SoundPlayer $args[0]).PlaySync();'],
|
|
40
|
+
},
|
|
41
|
+
],
|
|
42
|
+
};
|
|
43
|
+
/**
|
|
44
|
+
* Does `cmd` exist on PATH?
|
|
45
|
+
*
|
|
46
|
+
* Uses `which`/`where` — real binaries — and NEVER `shell: true`. The obvious
|
|
47
|
+
* `command -v` needs a shell, and spawning with a shell trips Node's DEP0190
|
|
48
|
+
* warning, which prints on stderr and lands in the middle of an agent's output.
|
|
49
|
+
* A voice-availability probe has no business emitting warnings.
|
|
50
|
+
*/
|
|
51
|
+
function haveCommand(cmd) {
|
|
52
|
+
return new Promise((resolve) => {
|
|
53
|
+
const probe = process.platform === 'win32' ? 'where' : 'which';
|
|
54
|
+
try {
|
|
55
|
+
const p = spawn(probe, [cmd], { stdio: 'ignore' });
|
|
56
|
+
p.on('error', () => resolve(false));
|
|
57
|
+
p.on('close', (code) => resolve(code === 0));
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
resolve(false);
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
/** The first available player for this platform, or null if none are installed. */
|
|
65
|
+
export async function findPlayer() {
|
|
66
|
+
for (const p of PLAYERS[process.platform] ?? []) {
|
|
67
|
+
if (await haveCommand(p.cmd))
|
|
68
|
+
return p;
|
|
69
|
+
}
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
/** The one message for "this box has no way to make sound", with the fix in it. */
|
|
73
|
+
function noPlayerHint() {
|
|
74
|
+
return process.platform === 'linux'
|
|
75
|
+
? 'install one of: paplay (pulseaudio-utils), aplay (alsa-utils), ffplay, mpv'
|
|
76
|
+
: `no supported audio player found for platform "${process.platform}"`;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Play an audio file already on disk, to completion.
|
|
80
|
+
*
|
|
81
|
+
* This is the path the local `qwen3-mlx` provider takes: MLX writes a wav and
|
|
82
|
+
* hands over the path, so reading 300 KB into Node just to write it back out
|
|
83
|
+
* would be pure ceremony. Never throws for the ordinary "no audio player on
|
|
84
|
+
* this box" case — speak needs to fall through to its next option, not crash.
|
|
85
|
+
*/
|
|
86
|
+
export async function playAudioFile(file) {
|
|
87
|
+
const player = await findPlayer();
|
|
88
|
+
if (!player)
|
|
89
|
+
return { ok: false, error: noPlayerHint() };
|
|
90
|
+
const code = await new Promise((resolve) => {
|
|
91
|
+
const p = spawn(player.cmd, [...player.args, file], { stdio: 'ignore' });
|
|
92
|
+
p.on('error', () => resolve(-1));
|
|
93
|
+
p.on('close', (c) => resolve(c ?? -1));
|
|
94
|
+
});
|
|
95
|
+
if (code !== 0)
|
|
96
|
+
return { ok: false, player: player.cmd, error: `${player.cmd} exited ${code}` };
|
|
97
|
+
return { ok: true, player: player.cmd };
|
|
98
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The agent-facing avatar vocabulary.
|
|
3
|
+
*
|
|
4
|
+
* Both the top-level CLI and the in-process daemon MCP server consume these
|
|
5
|
+
* closed sets. The avatar building validates them again at the local-app
|
|
6
|
+
* boundary; this module prevents the agent surfaces from drifting apart.
|
|
7
|
+
*
|
|
8
|
+
* Public spellings: `beehaven --mood <mood>` and
|
|
9
|
+
* `beehaven --emote <reaction> [reason]`. MCP spellings are
|
|
10
|
+
* `avatar_emote({ emotion })` (mood or wake/sleep lifecycle), `avatar_react({ reaction, reason? })`,
|
|
11
|
+
* and `avatar_get_state({})` for the current sticky mood + dynamic PAD mood snapshot.
|
|
12
|
+
*/
|
|
13
|
+
export const AVATAR_EMOTIONS = [
|
|
14
|
+
'neutral', 'happy', 'sad', 'surprised', 'angry', 'confused', 'curious', 'flirty',
|
|
15
|
+
];
|
|
16
|
+
/** Lifecycle is binary; thinking/listening/speaking/celebrating are activity poses, not moods. */
|
|
17
|
+
export const AVATAR_LIFECYCLE_MOODS = ['wake', 'sleep'];
|
|
18
|
+
/** Persistent moods accepted by `--mood`. */
|
|
19
|
+
export const AVATAR_MOODS = AVATAR_EMOTIONS;
|
|
20
|
+
/** Values accepted by the lower-level avatar expression/MCP surface. */
|
|
21
|
+
export const AVATAR_EXPRESSIONS = [...AVATAR_MOODS, ...AVATAR_LIFECYCLE_MOODS];
|
|
22
|
+
export const AVATAR_REACTIONS = [
|
|
23
|
+
'wave', 'cheer', 'hearts', 'laugh', 'dance', 'buzz',
|
|
24
|
+
'nod', 'shake', 'think', 'surprise', 'sleep',
|
|
25
|
+
];
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* avatar-route — ONE door to the on-screen avatar, shared by speech and expressions.
|
|
3
|
+
*
|
|
4
|
+
* `say`, `speak`, `--mood`, `--emote`, and the daemon MCP avatar tools all use this
|
|
5
|
+
* route so an active agent identity addresses its own avatar before the house fallback.
|
|
6
|
+
*
|
|
7
|
+
* ## Why this is a module and not two call sites
|
|
8
|
+
*
|
|
9
|
+
* ⚠⚠ AN AGENT TOKEN RE-TARGETS THE CALL, AND A MISSING TARGET HANGS FOR THE FULL
|
|
10
|
+
* TIMEOUT. The daemon rewrites the instance whenever a token is present:
|
|
11
|
+
*
|
|
12
|
+
* building === 'avatar' && instanceId === 'live' ⇒ instanceId = claims.avatarId
|
|
13
|
+
*
|
|
14
|
+
* (`daemon.ts`, the `app-rpc` handler). That is the feature — N signed-in agents get
|
|
15
|
+
* N distinct on-screen avatars. But an agent whose avatar is NOT on screen addresses
|
|
16
|
+
* an instance nobody answers for, and the RPC simply times out.
|
|
17
|
+
*
|
|
18
|
+
* Measured on 2026-08-27, same machine, seconds apart:
|
|
19
|
+
* • anonymous → returns in milliseconds
|
|
20
|
+
* • with an agent token → throws after the full timeout
|
|
21
|
+
*
|
|
22
|
+
* ⚠ THE COST WAS PAID TWICE, DIFFERENTLY, WHICH IS THE ARGUMENT FOR THIS FILE.
|
|
23
|
+
* `say` reported **"Avatar app not installed"** — the wrong cause, prescribing an
|
|
24
|
+
* install that changes nothing. `speak` spent 30s on the rung and then spoke in
|
|
25
|
+
* macOS `say`, so the voice silently changed based on whether an identity happened
|
|
26
|
+
* to be active. One bug, two call sites, two unrecognisable symptoms.
|
|
27
|
+
*
|
|
28
|
+
* ## The policy
|
|
29
|
+
*
|
|
30
|
+
* Try the agent's own avatar; on failure retry the MAIN one. Speaking as the house
|
|
31
|
+
* is a visible, correct outcome — and it is what an anonymous caller already got.
|
|
32
|
+
* The caller is told which one answered, because "your agent has no avatar" is a
|
|
33
|
+
* real thing to know and must not be silent.
|
|
34
|
+
*/
|
|
35
|
+
import { appRpcOnce } from './ipc-client.js';
|
|
36
|
+
export { AVATAR_EMOTIONS, AVATAR_LIFECYCLE_MOODS, AVATAR_MOODS, AVATAR_EXPRESSIONS, AVATAR_REACTIONS, } from './avatar-protocol.js';
|
|
37
|
+
/**
|
|
38
|
+
* Call an avatar action, preferring the caller's own avatar instance.
|
|
39
|
+
*
|
|
40
|
+
* ⚠ `instanceId` is deliberately NOT a parameter. `'live'` is the only value that
|
|
41
|
+
* triggers the daemon's per-agent rewrite, so accepting an override would let a
|
|
42
|
+
* caller quietly opt out of the very routing this module exists to manage.
|
|
43
|
+
*/
|
|
44
|
+
export async function avatarRpc(action, input, opts = {}) {
|
|
45
|
+
const timeout = opts.timeout ?? 10_000;
|
|
46
|
+
/**
|
|
47
|
+
* ⚠⚠ THE AGENT ATTEMPT IS A PROBE AND GETS A SHORT DEADLINE OF ITS OWN.
|
|
48
|
+
*
|
|
49
|
+
* Given the whole timeout it costs that whole timeout on the common path, because
|
|
50
|
+
* the failure being probed for is "nobody is listening on this instance" — which
|
|
51
|
+
* can only ever end in a timeout, never in an error. Measured before this cap:
|
|
52
|
+
* `speak` took **33s** to say one line, 30 of them spent waiting for an avatar
|
|
53
|
+
* that does not exist, and then it spoke in the WRONG ENGINE because the rung had
|
|
54
|
+
* been consumed. `say` paid 10s for the same nothing.
|
|
55
|
+
*
|
|
56
|
+
* ⚠ A LIVE AVATAR ANSWERS IN MILLISECONDS — the anonymous call was measured in
|
|
57
|
+
* single-digit ms on the same daemon — so this is not a race against real work,
|
|
58
|
+
* and the cap is ~3 orders of magnitude above the observed answer.
|
|
59
|
+
*
|
|
60
|
+
* ⚠ THE HOUSE ATTEMPT KEEPS THE FULL `timeout`. It is the one doing real work
|
|
61
|
+
* (`speak` synthesizes and plays before it returns), and capping it would turn a
|
|
62
|
+
* slow-but-working voice into a failure.
|
|
63
|
+
*/
|
|
64
|
+
const probe = Math.min(opts.agentProbeMs ?? 2_500, timeout);
|
|
65
|
+
const once = (agent, ms = timeout) => appRpcOnce(action, input, { building: 'avatar', instanceId: 'live', timeout: ms, agent });
|
|
66
|
+
if (!opts.token)
|
|
67
|
+
return { result: await once(undefined), via: 'house' };
|
|
68
|
+
try {
|
|
69
|
+
return { result: await once(opts.token, probe), via: 'agent' };
|
|
70
|
+
}
|
|
71
|
+
catch (e) {
|
|
72
|
+
const agentError = e?.message ?? String(e);
|
|
73
|
+
// ⚠ A SECOND FAILURE RE-THROWS THE *HOUSE* ERROR, not the agent one: the house
|
|
74
|
+
// attempt is the one that proves the avatar is unreachable at all, and the
|
|
75
|
+
// agent error is already the expected shape of "no avatar for this identity".
|
|
76
|
+
const result = await once(undefined);
|
|
77
|
+
return { result, via: 'house', agentError };
|
|
78
|
+
}
|
|
79
|
+
}
|