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
package/dist/relay.js
ADDED
|
@@ -0,0 +1,2723 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// Beehaven Office - Cloud Relay
|
|
3
|
+
// Persistent WebSocket connection to Cloudflare Durable Object relay.
|
|
4
|
+
// Falls back to legacy HTTP POST for old Cloud Function endpoints.
|
|
5
|
+
// ============================================================================
|
|
6
|
+
import { existsSync, readFileSync, statSync, writeFileSync, mkdirSync, readdirSync, rmSync, chmodSync, } from 'fs';
|
|
7
|
+
import { homedir, hostname, platform } from 'os';
|
|
8
|
+
import { devModeAvailable, devModeRefusal } from './build-channel.js';
|
|
9
|
+
import { join, dirname, resolve, sep } from 'path';
|
|
10
|
+
import { execSync, execFileSync } from 'child_process';
|
|
11
|
+
import WebSocket from 'ws';
|
|
12
|
+
import { logger } from './logger.js';
|
|
13
|
+
import { TerminalBlockParser } from './terminal/block-parser.js';
|
|
14
|
+
import { writeIntegration, cleanupIntegration } from './terminal/shell-integration.js';
|
|
15
|
+
import { transformPath } from './daemon-path-transform.js';
|
|
16
|
+
import { relayUrlFromConfig } from './relay-presets.js';
|
|
17
|
+
// Daemon Omni (Claude Agent SDK) loader removed (destroy-the-treehouse).
|
|
18
|
+
/** Sensitive env vars to strip from PTY sessions */
|
|
19
|
+
const SENSITIVE_ENV_PATTERNS = [
|
|
20
|
+
'AWS_SECRET_ACCESS_KEY',
|
|
21
|
+
'AWS_SESSION_TOKEN',
|
|
22
|
+
'GITHUB_TOKEN',
|
|
23
|
+
'GH_TOKEN',
|
|
24
|
+
'DATABASE_URL',
|
|
25
|
+
'ANTHROPIC_API_KEY',
|
|
26
|
+
'OPENAI_API_KEY',
|
|
27
|
+
'SENDGRID_API_KEY',
|
|
28
|
+
'FIREBASE_TOKEN',
|
|
29
|
+
'GOOGLE_APPLICATION_CREDENTIALS',
|
|
30
|
+
'STRIPE_SECRET_KEY',
|
|
31
|
+
'TWILIO_AUTH_TOKEN',
|
|
32
|
+
'NPM_TOKEN',
|
|
33
|
+
'DOCKER_PASSWORD',
|
|
34
|
+
'KUBECONFIG',
|
|
35
|
+
'MAILGUN_API_KEY',
|
|
36
|
+
'RESEND_API_KEY',
|
|
37
|
+
'OPENROUTER_API_KEY',
|
|
38
|
+
'ENCRYPTION_KEY',
|
|
39
|
+
'R2_SIGNING_KEY',
|
|
40
|
+
'WEBHOOK_SECRET',
|
|
41
|
+
];
|
|
42
|
+
const SENSITIVE_ENV_SUFFIXES = ['_KEY', '_SECRET', '_TOKEN', '_PASSWORD', '_CREDENTIALS', '_AUTH'];
|
|
43
|
+
/** Allowed endpoint patterns for WebSocket relay */
|
|
44
|
+
const ENDPOINT_WHITELIST = [/\.clearly\.sh$/, /\.workers\.dev$/];
|
|
45
|
+
const CONFIG_DIR = join(homedir(), '.beehaven');
|
|
46
|
+
const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
|
|
47
|
+
// Daemon relay endpoint. The Mac app sets `BEEHAVEN_RELAY_URL` when
|
|
48
|
+
// spawning daemon-entry.js so dev builds dial staging while production
|
|
49
|
+
// builds dial prod. Anyone running the CLI directly can also override
|
|
50
|
+
// this for local relay/staging iteration without rebuilding the Mac
|
|
51
|
+
// bundle. Falls back to prod when unset.
|
|
52
|
+
export const CLEARLY_RELAY_URL = typeof process !== 'undefined' &&
|
|
53
|
+
typeof process.env !== 'undefined' &&
|
|
54
|
+
typeof process.env.BEEHAVEN_RELAY_URL === 'string' &&
|
|
55
|
+
process.env.BEEHAVEN_RELAY_URL.length > 0
|
|
56
|
+
? process.env.BEEHAVEN_RELAY_URL
|
|
57
|
+
: 'wss://relay.clearly.sh';
|
|
58
|
+
/** Legacy Cloud Function URL — used as fallback when config.endpoint is this */
|
|
59
|
+
const LEGACY_RELAY_URL = 'https://us-central1-clearly-9bd39.cloudfunctions.net/beehiveRelay';
|
|
60
|
+
/** Flush interval — how often projects/docs buffer is sent to the cloud */
|
|
61
|
+
const FLUSH_INTERVAL_MS = 5_000;
|
|
62
|
+
/** Debounce for state sync — immediate-ish but coalesces rapid updates */
|
|
63
|
+
const STATE_DEBOUNCE_MS = 200;
|
|
64
|
+
export class Relay {
|
|
65
|
+
config = null;
|
|
66
|
+
connected = false;
|
|
67
|
+
/** True when stop()/unconfigure() was called — suppresses auto-reconnect
|
|
68
|
+
* on the ws close handler. Cleared when start() is called again. */
|
|
69
|
+
stopped = false;
|
|
70
|
+
tier = 'free';
|
|
71
|
+
profile = null;
|
|
72
|
+
deviceId;
|
|
73
|
+
// ── WebSocket ─────────────────────────────────────────────────────────
|
|
74
|
+
ws = null;
|
|
75
|
+
wsConnecting = false;
|
|
76
|
+
reconnectAttempts = 0;
|
|
77
|
+
reconnectTimer = null;
|
|
78
|
+
/** Watchdog timer + the moment we first noticed we were disconnected. If the
|
|
79
|
+
* relay stays down past the threshold, the watchdog forces a HARD reconnect
|
|
80
|
+
* (terminate any zombie socket + reset backoff + redial). This is the
|
|
81
|
+
* self-heal a HEADLESS / orphaned daemon needs — it has no Mac-app tray
|
|
82
|
+
* parent to restart it, so a wedged WS (e.g. a half-open socket after system
|
|
83
|
+
* sleep) would otherwise leave it "stuck reconnecting" forever. */
|
|
84
|
+
supervisorTimer = null;
|
|
85
|
+
disconnectedSince = null;
|
|
86
|
+
/** Request-response correlation — pending promises keyed by rid */
|
|
87
|
+
pendingRequests = new Map();
|
|
88
|
+
// ── Intervals ─────────────────────────────────────────────────────────
|
|
89
|
+
heartbeatInterval = null;
|
|
90
|
+
flushInterval = null;
|
|
91
|
+
stateDebounceTimer = null;
|
|
92
|
+
wsPingInterval = null;
|
|
93
|
+
wsWakeDetector = null;
|
|
94
|
+
lastTickAt = Date.now();
|
|
95
|
+
awaitingPong = false;
|
|
96
|
+
/** Consecutive app-level pings sent without a pong back, while the event
|
|
97
|
+
* loop was NOT stalled. The home DO's edge auto-pong goes briefly silent
|
|
98
|
+
* while its isolate cold-starts, so a SINGLE miss is normal and must not
|
|
99
|
+
* sever the socket — terminating on the first miss turned one DO
|
|
100
|
+
* cold-start into a relay reconnect that forced ANOTHER cold-start (a
|
|
101
|
+
* self-feeding churn loop). Reset on every pong / stall; terminate only
|
|
102
|
+
* once it reaches MAX_MISSED_PONGS. */
|
|
103
|
+
missedPongs = 0;
|
|
104
|
+
/** How many consecutive un-stalled missed pongs (~15s each) to tolerate
|
|
105
|
+
* before declaring the relay socket dead. 2 ⇒ ~30s of genuine silence,
|
|
106
|
+
* long enough to ride out a home-DO cold-start, short enough to recover
|
|
107
|
+
* a truly dead peer quickly. (The web HiveClient uses 3; the relay's
|
|
108
|
+
* reconnect is heavier — it cold-starts the DO — so 2 keeps it tighter.) */
|
|
109
|
+
MAX_MISSED_PONGS = 2;
|
|
110
|
+
/** Timestamp of the last APP-LEVEL pong ({"type":"pong"}). The keep-alive now
|
|
111
|
+
* heartbeats with the app-level {"type":"ping"} the edge auto-answers
|
|
112
|
+
* (the home DO's setWebSocketAutoResponse pairs ping→pong). A protocol-level
|
|
113
|
+
* ws.ping() is NOT auto-ponged once the DO hibernates, which produced false
|
|
114
|
+
* "No pong" reconnects; this app-level pong is the hibernation-safe signal. */
|
|
115
|
+
appPongAt = 0;
|
|
116
|
+
/** When the 15s pong-watchdog last actually ran. If the gap since the
|
|
117
|
+
* previous run is far larger than the interval, the event loop was
|
|
118
|
+
* frozen (App Nap / heavy work / brief sleep) and a "missed" pong is an
|
|
119
|
+
* artifact of that freeze — not a dead peer. Used to suppress the
|
|
120
|
+
* false-positive terminate that was dropping healthy daemon sockets. */
|
|
121
|
+
lastPongCheckAt = Date.now();
|
|
122
|
+
/** Disposer for the ~/.claude/.credentials.json watcher. Set on
|
|
123
|
+
* first WS open; cleared + re-set on reconnects. The watcher
|
|
124
|
+
* fires canvas-credential-detected to the worker when the user
|
|
125
|
+
* completes the plan-SDK OAuth flow via `claude setup-token`. */
|
|
126
|
+
credentialsWatcherDispose = null;
|
|
127
|
+
// ── Flush buffer ──────────────────────────────────────────────────────
|
|
128
|
+
buffer = { events: [], docs: [] };
|
|
129
|
+
/** Per-project sync details for UI */
|
|
130
|
+
projectSyncDetails = new Map();
|
|
131
|
+
/** Timestamp of most recent successful sync */
|
|
132
|
+
lastSyncAt = 0;
|
|
133
|
+
/** Stats for logging */
|
|
134
|
+
stats = { sent: 0, failed: 0, batches: 0 };
|
|
135
|
+
/** Callback when profile updates from heartbeat (only fires on change) */
|
|
136
|
+
onProfileUpdate = null;
|
|
137
|
+
lastProfileJson = '';
|
|
138
|
+
/** Callback when a browse-files request arrives from the web UI */
|
|
139
|
+
onBrowseFiles = null;
|
|
140
|
+
/** Callback when a file has been teleported from web to local filesystem */
|
|
141
|
+
onFileTeleported = null;
|
|
142
|
+
/** Callback when cloud bee status update arrives */
|
|
143
|
+
onBeeStatus = null;
|
|
144
|
+
/** Callback when cloud bee emits a thought */
|
|
145
|
+
onBeeThought = null;
|
|
146
|
+
/** Callback to route fetch requests to local app buildings */
|
|
147
|
+
onLocalAppFetch = null;
|
|
148
|
+
/**
|
|
149
|
+
* Callback to route cross-hive RPC requests from the cloud workspace
|
|
150
|
+
* to a loaded local hive (#74). The cloud DO emits
|
|
151
|
+
* `daemon-rpc-request { requestId, hive, action, input }`; the
|
|
152
|
+
* daemon's local app manager looks up `hive` (default instance id),
|
|
153
|
+
* invokes `building.call(action, input)`, and the relay sends back
|
|
154
|
+
* `daemon-rpc-response { requestId, ok, result?, error? }`.
|
|
155
|
+
*/
|
|
156
|
+
onDaemonRpcRequest = null;
|
|
157
|
+
/** Callback when a broadcast message arrives from the DO (forwarded to IPC clients) */
|
|
158
|
+
onBroadcast = null;
|
|
159
|
+
/** Fires every time the WebSocket transitions to OPEN — initial
|
|
160
|
+
* connect AND every reconnect after sleep / network blip. Use
|
|
161
|
+
* this to re-pull anything the daemon caches from the cloud
|
|
162
|
+
* (avatar settings, profile, …) so missed broadcasts during
|
|
163
|
+
* the offline window self-heal on the next reconnect. */
|
|
164
|
+
onConnected = null;
|
|
165
|
+
/** Callback when a file-changed broadcast arrives (cloud → local file sync) */
|
|
166
|
+
onFileChanged = null;
|
|
167
|
+
/** Callback when PTY produces output (for local IPC forwarding) */
|
|
168
|
+
onPtyOutput = null;
|
|
169
|
+
/** Number of web viewers currently watching (set by DO viewer-count message) */
|
|
170
|
+
viewerCount = 0;
|
|
171
|
+
/** Session events pending batch delivery (legacy HTTP mode only) */
|
|
172
|
+
sessionEventBatch = new Map();
|
|
173
|
+
/** Active PTY sessions: sessionId → pty process */
|
|
174
|
+
ptySessions = new Map();
|
|
175
|
+
/** Per-session OSC-133 block parsers: sessionId → TerminalBlockParser */
|
|
176
|
+
ptyParsers = new Map();
|
|
177
|
+
/** A managed PTY buffers input until its OSC-633 shell integration has printed its first prompt, so
|
|
178
|
+
* the FIRST command runs WITH markers → a terminal-block is emitted (not swallowed by the shell-init
|
|
179
|
+
* race). ptyReady = sessions past that point; ptyInputQueue = input typed before then; ptyReadyTimers
|
|
180
|
+
* = the no-integration fallback flush. */
|
|
181
|
+
ptyReady = new Set();
|
|
182
|
+
ptyInputQueue = new Map();
|
|
183
|
+
ptyReadyTimers = new Map();
|
|
184
|
+
constructor() {
|
|
185
|
+
this.config = Relay.loadConfig();
|
|
186
|
+
// Desktop hosts inject the anonymous install id so workspace RPCs can be joined back to the
|
|
187
|
+
// Mac funnel. Standalone CLI keeps the existing machine-scoped fallback. Both are bounded and
|
|
188
|
+
// never carry a username, path, serial number or account credential.
|
|
189
|
+
this.deviceId = process.env.BEEHAVEN_DEVICE_ID || `${hostname()}-${platform()}`;
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Start watching ~/.claude/.credentials.json for OAuth completion.
|
|
193
|
+
* Idempotent — calling repeatedly disposes the prior watcher.
|
|
194
|
+
* Dynamically imports the credential-store module so the relay
|
|
195
|
+
* startup path stays light.
|
|
196
|
+
*/
|
|
197
|
+
_startCredentialsWatcher() {
|
|
198
|
+
// Dispose any prior watcher (reconnect scenario).
|
|
199
|
+
if (this.credentialsWatcherDispose) {
|
|
200
|
+
try {
|
|
201
|
+
this.credentialsWatcherDispose();
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
/* idempotent */
|
|
205
|
+
}
|
|
206
|
+
this.credentialsWatcherDispose = null;
|
|
207
|
+
}
|
|
208
|
+
// Claude credentials-file watcher removed with the daemon Claude Code
|
|
209
|
+
// integration (destroy-the-treehouse).
|
|
210
|
+
}
|
|
211
|
+
// ── Config ────────────────────────────────────────────────────────────
|
|
212
|
+
/** Load relay config from ~/.beehaven/config.json */
|
|
213
|
+
static loadConfig() {
|
|
214
|
+
if (!existsSync(CONFIG_FILE))
|
|
215
|
+
return null;
|
|
216
|
+
try {
|
|
217
|
+
const raw = readFileSync(CONFIG_FILE, 'utf8');
|
|
218
|
+
const parsed = JSON.parse(raw);
|
|
219
|
+
let token = parsed.token;
|
|
220
|
+
if (parsed.tokenStorage === 'keychain') {
|
|
221
|
+
try {
|
|
222
|
+
// Use execSync to call macOS keychain directly (require() fails in ESM)
|
|
223
|
+
token =
|
|
224
|
+
execSync('security find-generic-password -s "beehaven" -a "daemon-token" -w', {
|
|
225
|
+
encoding: 'utf8',
|
|
226
|
+
timeout: 3000,
|
|
227
|
+
stdio: ['pipe', 'pipe', 'ignore'],
|
|
228
|
+
}).trim() || parsed.token;
|
|
229
|
+
}
|
|
230
|
+
catch {
|
|
231
|
+
token = parsed.token;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* ⚠⚠ THE DIAL TARGET IS RESOLVED, NEVER READ RAW — `endpoint` OR `relayUrl`.
|
|
236
|
+
*
|
|
237
|
+
* This line was `if (!token || !parsed.endpoint) return null`, and it is the ONLY reader in
|
|
238
|
+
* the codebase that demanded `endpoint` specifically. Everything else goes through
|
|
239
|
+
* `relayUrlFromConfig`, whose own docstring calls `relayUrl` a supported alias — so a config
|
|
240
|
+
* carrying only `relayUrl` was well-formed to every other surface and invisible to this one.
|
|
241
|
+
*
|
|
242
|
+
* ⚠ THE FAILURE IS TOTAL AND SILENT. `null` here means `isConfigured()` is false, so the
|
|
243
|
+
* daemon never dials the relay at all: the tray reads "agent offline", the avatar's menu says
|
|
244
|
+
* "Could not reach the daemon", and `refreshIdToken` — which starts by calling this — returns
|
|
245
|
+
* false and logs "Token refresh failed — will retry in 5 minutes". THREE unrelated-looking
|
|
246
|
+
* symptoms, one missing key, and none of them names it.
|
|
247
|
+
*
|
|
248
|
+
* ⚠⚠ AND `beehaven login` WROTE EXACTLY THAT CONFIG. It spreads `...existing` and never emits
|
|
249
|
+
* a dial target, so it PRESERVES `endpoint` when one is already there and cannot create one.
|
|
250
|
+
* After `logout` (which deletes `endpoint` by name) or on a fresh install where the Mac app's
|
|
251
|
+
* `install()` had not yet run, signing in from the terminal produced a config with valid
|
|
252
|
+
* tokens, a correct `relayUrl`, and no way to connect. Measured: adding the one key to a live
|
|
253
|
+
* broken config took `relayConnected` from false to true with no other change.
|
|
254
|
+
*
|
|
255
|
+
* ⚠ FIXING IT HERE rather than only at the writer is what makes it SELF-HEALING: every config
|
|
256
|
+
* already on a user's disk starts working on the next daemon start, with no migration.
|
|
257
|
+
*/
|
|
258
|
+
const endpoint = relayUrlFromConfig(parsed);
|
|
259
|
+
if (!token || !endpoint)
|
|
260
|
+
return null;
|
|
261
|
+
return { token, endpoint, refreshToken: parsed.refreshToken };
|
|
262
|
+
}
|
|
263
|
+
catch {
|
|
264
|
+
return null;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
isConfigured() {
|
|
268
|
+
return this.config !== null;
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* True iff we actually have a live transport to the cloud relay.
|
|
272
|
+
*
|
|
273
|
+
* For WebSocket mode we prefer `ws.readyState === OPEN` over the cached
|
|
274
|
+
* `.connected` flag — the flag is event-driven and can drift stale if an
|
|
275
|
+
* open/close event is missed (e.g. after system sleep or a silent reconnect
|
|
276
|
+
* race), which made `beehaven rpc` report "Not connected to cloud" while
|
|
277
|
+
* the WebSocket was genuinely up. For HTTP-heartbeat mode we fall back to
|
|
278
|
+
* the flag since there's no socket to inspect.
|
|
279
|
+
*/
|
|
280
|
+
isConnected() {
|
|
281
|
+
if (this.useWebSocket) {
|
|
282
|
+
return this.ws !== null && this.ws.readyState === WebSocket.OPEN;
|
|
283
|
+
}
|
|
284
|
+
return this.connected;
|
|
285
|
+
}
|
|
286
|
+
getTier() {
|
|
287
|
+
return this.tier;
|
|
288
|
+
}
|
|
289
|
+
getProfile() {
|
|
290
|
+
return this.profile;
|
|
291
|
+
}
|
|
292
|
+
/** Whether any web clients are watching the office (set by DO viewer-count) */
|
|
293
|
+
get hasViewers() {
|
|
294
|
+
return this.viewerCount > 0;
|
|
295
|
+
}
|
|
296
|
+
/** Configure relay with a new token */
|
|
297
|
+
configure(token, endpoint) {
|
|
298
|
+
this.config = { token, endpoint: endpoint || CLEARLY_RELAY_URL };
|
|
299
|
+
}
|
|
300
|
+
/** Clear relay configuration */
|
|
301
|
+
unconfigure() {
|
|
302
|
+
this.stop();
|
|
303
|
+
this.config = null;
|
|
304
|
+
this.profile = null;
|
|
305
|
+
this.connected = false;
|
|
306
|
+
this.tier = 'free';
|
|
307
|
+
}
|
|
308
|
+
/** Get sync status for UI display */
|
|
309
|
+
getSyncStatus() {
|
|
310
|
+
return {
|
|
311
|
+
connected: this.connected,
|
|
312
|
+
sent: this.stats.sent,
|
|
313
|
+
failed: this.stats.failed,
|
|
314
|
+
lastSyncAt: this.lastSyncAt,
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
// ── Endpoint Detection ────────────────────────────────────────────────
|
|
318
|
+
/** Whether this endpoint uses WebSocket (new Cloudflare relay) */
|
|
319
|
+
get useWebSocket() {
|
|
320
|
+
if (!this.config)
|
|
321
|
+
return false;
|
|
322
|
+
const ep = this.config.endpoint;
|
|
323
|
+
return ep.startsWith('wss://') || ep.includes('relay.clearly.sh') || ep.includes('workers.dev');
|
|
324
|
+
}
|
|
325
|
+
/** Get the WebSocket base URL (without auth params) */
|
|
326
|
+
wsBase() {
|
|
327
|
+
if (!this.config)
|
|
328
|
+
return '';
|
|
329
|
+
let base = this.config.endpoint;
|
|
330
|
+
if (base.startsWith('https://'))
|
|
331
|
+
base = base.replace('https://', 'wss://');
|
|
332
|
+
if (!base.startsWith('wss://'))
|
|
333
|
+
base = `wss://${base}`;
|
|
334
|
+
return base.replace(/\/$/, '');
|
|
335
|
+
}
|
|
336
|
+
/** Get the HTTPS base URL for ticket exchange */
|
|
337
|
+
ticketUrl() {
|
|
338
|
+
if (!this.config)
|
|
339
|
+
return '';
|
|
340
|
+
const base = this.wsBase();
|
|
341
|
+
return base.replace('wss://', 'https://').replace('ws://', 'http://') + '/auth/ticket';
|
|
342
|
+
}
|
|
343
|
+
/** Exchange relay token for a single-use ticket (30s TTL) */
|
|
344
|
+
async fetchTicket() {
|
|
345
|
+
if (!this.config)
|
|
346
|
+
return null;
|
|
347
|
+
try {
|
|
348
|
+
let resp = await fetch(this.ticketUrl(), {
|
|
349
|
+
method: 'POST',
|
|
350
|
+
headers: { Authorization: `Bearer ${this.config.token}` },
|
|
351
|
+
signal: AbortSignal.timeout(10_000),
|
|
352
|
+
});
|
|
353
|
+
// Token may have expired — try refreshing and retrying once
|
|
354
|
+
if (resp.status === 401 || resp.status === 403) {
|
|
355
|
+
console.log('[relay] Ticket fetch failed (auth) — refreshing token');
|
|
356
|
+
const refreshed = await this.refreshIdToken();
|
|
357
|
+
if (refreshed && this.config) {
|
|
358
|
+
resp = await fetch(this.ticketUrl(), {
|
|
359
|
+
method: 'POST',
|
|
360
|
+
headers: { Authorization: `Bearer ${this.config.token}` },
|
|
361
|
+
signal: AbortSignal.timeout(10_000),
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
if (!resp.ok)
|
|
366
|
+
return null;
|
|
367
|
+
const data = await resp.json();
|
|
368
|
+
return data.ticket;
|
|
369
|
+
}
|
|
370
|
+
catch {
|
|
371
|
+
return null;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
/** Get the HTTP URL for one-shot requests (verifyToken, building, etc.) */
|
|
375
|
+
httpUrl(endpoint) {
|
|
376
|
+
const ep = endpoint || this.config?.endpoint || '';
|
|
377
|
+
if (ep.startsWith('wss://'))
|
|
378
|
+
return ep.replace('wss://', 'https://').replace(/\/$/, '') + '/relay';
|
|
379
|
+
if (ep.startsWith('https://') &&
|
|
380
|
+
(ep.includes('relay.clearly.sh') || ep.includes('workers.dev')))
|
|
381
|
+
return ep.replace(/\/$/, '') + '/relay';
|
|
382
|
+
return ep; // Legacy Cloud Function URL
|
|
383
|
+
}
|
|
384
|
+
// ── Lifecycle ─────────────────────────────────────────────────────────
|
|
385
|
+
/** Start the relay — connects via WebSocket or legacy HTTP */
|
|
386
|
+
async start() {
|
|
387
|
+
if (!this.config)
|
|
388
|
+
return;
|
|
389
|
+
// Re-enable auto-reconnect in case a previous stop() set it.
|
|
390
|
+
this.stopped = false;
|
|
391
|
+
// Validate endpoint against whitelist
|
|
392
|
+
try {
|
|
393
|
+
const endpointHost = new URL(this.config.endpoint.replace('wss://', 'https://').replace('ws://', 'http://')).hostname;
|
|
394
|
+
const allowed = ENDPOINT_WHITELIST.some((re) => re.test(endpointHost)) || endpointHost === 'localhost';
|
|
395
|
+
if (!allowed) {
|
|
396
|
+
console.error(`[relay] Endpoint rejected: ${endpointHost} not in whitelist`);
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
catch {
|
|
401
|
+
console.error('[relay] Invalid endpoint URL');
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
if (this.useWebSocket) {
|
|
405
|
+
await this.connectWs();
|
|
406
|
+
this.startSupervisor();
|
|
407
|
+
}
|
|
408
|
+
else {
|
|
409
|
+
// Legacy HTTP mode
|
|
410
|
+
const ok = await this.sendHeartbeatHttp();
|
|
411
|
+
this.connected = ok;
|
|
412
|
+
if (!ok) {
|
|
413
|
+
console.log('[relay] Failed to connect — check token and endpoint');
|
|
414
|
+
this.scheduleReconnect();
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
console.log(`[relay] Connected to Clearly (tier: ${this.tier})`);
|
|
418
|
+
this.heartbeatInterval = setInterval(() => this.sendHeartbeatHttp(), 30_000);
|
|
419
|
+
this.flushInterval = setInterval(() => this.flush(), FLUSH_INTERVAL_MS);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
/** Stop the relay — close WebSocket, flush remaining buffer */
|
|
423
|
+
stop() {
|
|
424
|
+
// Set BEFORE terminate(): the ws close handler checks this to
|
|
425
|
+
// suppress auto-reconnect on intentional shutdown.
|
|
426
|
+
this.stopped = true;
|
|
427
|
+
if (this.heartbeatInterval)
|
|
428
|
+
clearInterval(this.heartbeatInterval);
|
|
429
|
+
if (this.flushInterval)
|
|
430
|
+
clearInterval(this.flushInterval);
|
|
431
|
+
if (this.stateDebounceTimer)
|
|
432
|
+
clearTimeout(this.stateDebounceTimer);
|
|
433
|
+
if (this.wsPingInterval)
|
|
434
|
+
clearInterval(this.wsPingInterval);
|
|
435
|
+
if (this.wsWakeDetector)
|
|
436
|
+
clearInterval(this.wsWakeDetector);
|
|
437
|
+
if (this.supervisorTimer)
|
|
438
|
+
clearInterval(this.supervisorTimer);
|
|
439
|
+
this.supervisorTimer = null;
|
|
440
|
+
this.disconnectedSince = null;
|
|
441
|
+
this.heartbeatInterval = null;
|
|
442
|
+
this.flushInterval = null;
|
|
443
|
+
this.stateDebounceTimer = null;
|
|
444
|
+
this.wsPingInterval = null;
|
|
445
|
+
this.wsWakeDetector = null;
|
|
446
|
+
if (this.reconnectTimer)
|
|
447
|
+
clearTimeout(this.reconnectTimer);
|
|
448
|
+
this.reconnectTimer = null;
|
|
449
|
+
// Reject pending requests
|
|
450
|
+
for (const [, pending] of this.pendingRequests) {
|
|
451
|
+
clearTimeout(pending.timer);
|
|
452
|
+
pending.reject(new Error('Relay stopped'));
|
|
453
|
+
}
|
|
454
|
+
this.pendingRequests.clear();
|
|
455
|
+
// Kill all PTY sessions
|
|
456
|
+
for (const [sid, ptyProcess] of this.ptySessions) {
|
|
457
|
+
try {
|
|
458
|
+
ptyProcess.kill();
|
|
459
|
+
}
|
|
460
|
+
catch { }
|
|
461
|
+
cleanupIntegration(sid);
|
|
462
|
+
}
|
|
463
|
+
this.ptySessions.clear();
|
|
464
|
+
this.ptyParsers.clear();
|
|
465
|
+
// Close WebSocket — use terminate() to avoid hanging on graceful close handshake
|
|
466
|
+
if (this.ws) {
|
|
467
|
+
try {
|
|
468
|
+
this.ws.terminate();
|
|
469
|
+
}
|
|
470
|
+
catch { }
|
|
471
|
+
this.ws = null;
|
|
472
|
+
}
|
|
473
|
+
this.connected = false;
|
|
474
|
+
this.wsConnecting = false;
|
|
475
|
+
// Final flush (fire-and-forget)
|
|
476
|
+
this.flush();
|
|
477
|
+
}
|
|
478
|
+
// ── WebSocket ─────────────────────────────────────────────────────────
|
|
479
|
+
/** Establish WebSocket connection to the Cloudflare relay.
|
|
480
|
+
* Resolves when the connection opens (or immediately on failure). */
|
|
481
|
+
async connectWs() {
|
|
482
|
+
if (!this.config || this.wsConnecting)
|
|
483
|
+
return;
|
|
484
|
+
this.wsConnecting = true;
|
|
485
|
+
// Re-read the persisted token first. The Mac app (or a prior refresh)
|
|
486
|
+
// may have written a fresh idToken while we were looping; without this
|
|
487
|
+
// we'd keep dialing with the stale in-memory copy and never self-heal
|
|
488
|
+
// short of a full daemon restart.
|
|
489
|
+
try {
|
|
490
|
+
const fresh = Relay.loadConfig();
|
|
491
|
+
if (fresh?.token && this.config)
|
|
492
|
+
this.config.token = fresh.token;
|
|
493
|
+
}
|
|
494
|
+
catch { }
|
|
495
|
+
// Use ?token= directly — ticket exchange is unreliable across Cloudflare isolates.
|
|
496
|
+
// The ?token= path is the server's fallback for non-browser clients (CLI, daemon).
|
|
497
|
+
// If token is expired, try refreshing first.
|
|
498
|
+
let token = this.config.token;
|
|
499
|
+
// Quick expiry check — decode JWT exp without verification
|
|
500
|
+
let expiredNoRefresh = false;
|
|
501
|
+
try {
|
|
502
|
+
const payload = JSON.parse(Buffer.from(token.split('.')[1], 'base64').toString());
|
|
503
|
+
const nowSec = Date.now() / 1000;
|
|
504
|
+
if (payload.exp && nowSec > payload.exp - 60) {
|
|
505
|
+
console.log('[relay] Token near/at expiry — refreshing before connect');
|
|
506
|
+
const refreshed = await this.refreshIdToken();
|
|
507
|
+
if (refreshed && this.config)
|
|
508
|
+
token = this.config.token;
|
|
509
|
+
else if (payload.exp && nowSec > payload.exp)
|
|
510
|
+
expiredNoRefresh = true;
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
catch { }
|
|
514
|
+
// If the token is already expired AND refresh failed, dialing would just
|
|
515
|
+
// get rejected — spinning the reconnect loop forever with a dead
|
|
516
|
+
// credential (the classic "daemon stuck reconnecting" state). Back off
|
|
517
|
+
// and wait for a fresh token (e.g. the Mac app re-minting one) instead.
|
|
518
|
+
if (expiredNoRefresh) {
|
|
519
|
+
console.warn('[relay] Token expired and refresh failed — backing off without dialing. Reopen the Clearly Dev Mac app to re-authenticate.');
|
|
520
|
+
this.wsConnecting = false;
|
|
521
|
+
if (this.config && !this.stopped)
|
|
522
|
+
this.scheduleReconnect();
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
525
|
+
// The daemon holds ONE socket — to the user's HIVE (`/my/hive/ws`, the per-user
|
|
526
|
+
// multiplexer). The hive opens a server-side portal to the caller's CANONICAL workspace
|
|
527
|
+
// (no `workspaceId` → the hive resolves it from `building_assignments`) and pipes it
|
|
528
|
+
// transparently, so every workspace RPC (canvas-invoke, actions, table sync) round-trips
|
|
529
|
+
// exactly as before — plus the daemon shares the same socket the web app uses. (Plain
|
|
530
|
+
// synchronous URL — no pre-connect HTTP hop — so nothing can stall the dial.)
|
|
531
|
+
const params = new URLSearchParams({
|
|
532
|
+
token,
|
|
533
|
+
client: process.env.BEEHAVEN_CLIENT || 'daemon',
|
|
534
|
+
clientType: process.env.BEEHAVEN_CLIENT_TYPE || 'daemon',
|
|
535
|
+
wsType: 'workspace',
|
|
536
|
+
});
|
|
537
|
+
if (this.deviceId)
|
|
538
|
+
params.set('deviceId', String(this.deviceId).slice(0, 64));
|
|
539
|
+
const url = `${this.wsBase()}/my/hive/ws?${params.toString()}`;
|
|
540
|
+
const ws = new WebSocket(url);
|
|
541
|
+
// Wait for open or first error/close before resolving
|
|
542
|
+
await new Promise((resolve) => {
|
|
543
|
+
const done = () => {
|
|
544
|
+
resolve();
|
|
545
|
+
};
|
|
546
|
+
ws.on('open', () => {
|
|
547
|
+
this.ws = ws;
|
|
548
|
+
this.wsConnecting = false;
|
|
549
|
+
this.connected = true;
|
|
550
|
+
this.reconnectAttempts = 0;
|
|
551
|
+
console.log(`[relay] WebSocket connected to ${this.config.endpoint}`);
|
|
552
|
+
// Notify subscribers that the WebSocket is open so they
|
|
553
|
+
// can pull fresh state. Wrapped because the callback
|
|
554
|
+
// might do RPCs that throw — we never want a subscriber
|
|
555
|
+
// error to crash the open handler.
|
|
556
|
+
try {
|
|
557
|
+
this.onConnected?.();
|
|
558
|
+
}
|
|
559
|
+
catch (err) {
|
|
560
|
+
console.warn(`[relay] onConnected callback threw: ${err?.message ?? err}`);
|
|
561
|
+
}
|
|
562
|
+
// Send heartbeat to get profile + trigger TTL extension
|
|
563
|
+
this.wsSend({ type: 'heartbeat', deviceId: this.deviceId });
|
|
564
|
+
// Announce environment so the bee agent knows about /local/
|
|
565
|
+
this.wsSend({
|
|
566
|
+
type: 'daemon-hello',
|
|
567
|
+
root: homedir(),
|
|
568
|
+
os: platform(),
|
|
569
|
+
shell: process.env.SHELL?.split('/').pop() || 'bash',
|
|
570
|
+
hostname: hostname(),
|
|
571
|
+
});
|
|
572
|
+
// Announce presence
|
|
573
|
+
this.wsSend({ type: 'presence', online: true, deviceId: this.deviceId, tier: this.tier });
|
|
574
|
+
// Start the ~/.claude/.credentials.json watcher — when
|
|
575
|
+
// the user completes the plan-SDK OAuth flow via
|
|
576
|
+
// `claude setup-token`, the CLI writes the file and our
|
|
577
|
+
// watcher fires canvas-credential-detected back to the
|
|
578
|
+
// worker, which flips the credential row + broadcasts to
|
|
579
|
+
// web clients. Idempotent across reconnects — calling
|
|
580
|
+
// start more than once just no-ops because the prior
|
|
581
|
+
// watcher object is replaced on each call.
|
|
582
|
+
this._startCredentialsWatcher();
|
|
583
|
+
// Start flush interval
|
|
584
|
+
if (this.flushInterval)
|
|
585
|
+
clearInterval(this.flushInterval);
|
|
586
|
+
this.flushInterval = setInterval(() => this.flush(), FLUSH_INTERVAL_MS);
|
|
587
|
+
// JSON heartbeat removed — was waking the home DO every 20s.
|
|
588
|
+
// Daemon liveness is already driven by WS connection state:
|
|
589
|
+
// `Workspace.onDisconnect` clears `daemonEnvs` + removes the
|
|
590
|
+
// session, and `deriveDaemonList()` reads only from currently
|
|
591
|
+
// connected sessions. No time-based prune exists, so the
|
|
592
|
+
// per-20s refresh was pure DO-wake cost with no signal value.
|
|
593
|
+
//
|
|
594
|
+
// The one-shot heartbeat on connect (line 421) still fires
|
|
595
|
+
// once so the server gets the initial profile snapshot.
|
|
596
|
+
// Heartbeat with the APP-LEVEL {type:'ping'} the edge auto-answers:
|
|
597
|
+
// the home DO's setWebSocketAutoResponse pairs {type:'ping'} →
|
|
598
|
+
// {type:'pong'} and replies AT THE EDGE without waking the DO. A
|
|
599
|
+
// protocol-level ws.ping() is NOT auto-ponged once the DO
|
|
600
|
+
// hibernates — that mismatch was the false "No pong" reconnect.
|
|
601
|
+
if (this.heartbeatInterval) {
|
|
602
|
+
clearInterval(this.heartbeatInterval);
|
|
603
|
+
this.heartbeatInterval = null;
|
|
604
|
+
}
|
|
605
|
+
// WebSocket-level ping every 15s — detects dead connections faster than TCP timeout
|
|
606
|
+
if (this.wsPingInterval)
|
|
607
|
+
clearInterval(this.wsPingInterval);
|
|
608
|
+
this.awaitingPong = false;
|
|
609
|
+
this.missedPongs = 0;
|
|
610
|
+
this.lastPongCheckAt = Date.now();
|
|
611
|
+
this.wsPingInterval = setInterval(() => {
|
|
612
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN)
|
|
613
|
+
return;
|
|
614
|
+
// Stall guard: if this 15s interval itself ran far late, the
|
|
615
|
+
// event loop was frozen (macOS App Nap on the backgrounded
|
|
616
|
+
// daemon, heavy sync work, or a brief sleep). A pong we
|
|
617
|
+
// "didn't receive" is then an artifact of that freeze, not a
|
|
618
|
+
// dead peer — terminating here was the false-positive that
|
|
619
|
+
// severed healthy daemon sockets (close 1006) every few
|
|
620
|
+
// minutes. Reset and re-probe instead of killing the socket.
|
|
621
|
+
const sinceLast = Date.now() - this.lastPongCheckAt;
|
|
622
|
+
this.lastPongCheckAt = Date.now();
|
|
623
|
+
if (sinceLast > 30_000) {
|
|
624
|
+
logger.warn('relay', `Ping interval ran ${(sinceLast / 1000).toFixed(0)}s late (event-loop stall) — re-probing, not terminating`);
|
|
625
|
+
this.awaitingPong = true;
|
|
626
|
+
this.missedPongs = 0; // the gap was OUR freeze, not a dead peer — don't count it
|
|
627
|
+
try {
|
|
628
|
+
this.ws.send(JSON.stringify({ type: 'ping' }));
|
|
629
|
+
}
|
|
630
|
+
catch { }
|
|
631
|
+
return;
|
|
632
|
+
}
|
|
633
|
+
if (this.awaitingPong) {
|
|
634
|
+
// Pinged last cycle, no pong this cycle, loop NOT stalled.
|
|
635
|
+
// Don't sever on the first miss: the home DO's edge
|
|
636
|
+
// auto-pong goes briefly silent while its isolate
|
|
637
|
+
// cold-starts, and terminating here forced a relay
|
|
638
|
+
// reconnect that triggered ANOTHER cold-start — a
|
|
639
|
+
// self-feeding churn loop that reconnected every client
|
|
640
|
+
// (web + mac-app) each cycle. Re-ping and only declare
|
|
641
|
+
// the peer dead after MAX_MISSED_PONGS consecutive
|
|
642
|
+
// un-stalled misses (~30s of genuine silence).
|
|
643
|
+
this.missedPongs++;
|
|
644
|
+
if (this.missedPongs >= this.MAX_MISSED_PONGS) {
|
|
645
|
+
logger.warn('relay', `No pong after ${this.missedPongs} pings (~${this.missedPongs * 15}s silent) — connection dead, reconnecting`);
|
|
646
|
+
try {
|
|
647
|
+
this.ws.terminate();
|
|
648
|
+
}
|
|
649
|
+
catch { }
|
|
650
|
+
return;
|
|
651
|
+
}
|
|
652
|
+
// Tolerated miss — re-probe and give it one more cycle.
|
|
653
|
+
try {
|
|
654
|
+
this.ws.send(JSON.stringify({ type: 'ping' }));
|
|
655
|
+
}
|
|
656
|
+
catch { }
|
|
657
|
+
return;
|
|
658
|
+
}
|
|
659
|
+
this.awaitingPong = true;
|
|
660
|
+
this.missedPongs = 0;
|
|
661
|
+
try {
|
|
662
|
+
this.ws.send(JSON.stringify({ type: 'ping' }));
|
|
663
|
+
}
|
|
664
|
+
catch { }
|
|
665
|
+
}, 15_000);
|
|
666
|
+
// Detect system wake — a >35s gap between 5s ticks means EITHER
|
|
667
|
+
// the system slept (socket dead) OR the process was merely
|
|
668
|
+
// throttled / the event loop stalled (macOS App Nap on the
|
|
669
|
+
// backgrounded daemon) — in which case the socket is still
|
|
670
|
+
// ALIVE. Blind-terminating here was severing healthy
|
|
671
|
+
// connections every few minutes (close 1006) and forcing
|
|
672
|
+
// reconnect churn. Verify first: ping, and only drop if no pong
|
|
673
|
+
// comes back within a short grace.
|
|
674
|
+
this.lastTickAt = Date.now();
|
|
675
|
+
if (this.wsWakeDetector)
|
|
676
|
+
clearInterval(this.wsWakeDetector);
|
|
677
|
+
this.wsWakeDetector = setInterval(() => {
|
|
678
|
+
const now = Date.now();
|
|
679
|
+
const drift = now - this.lastTickAt;
|
|
680
|
+
this.lastTickAt = now;
|
|
681
|
+
if (drift > 35_000 && this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
682
|
+
const sock = this.ws;
|
|
683
|
+
logger.warn('relay', `Timer drift ${(drift / 1000).toFixed(0)}s — verifying liveness before reconnect`);
|
|
684
|
+
const probeAt = Date.now();
|
|
685
|
+
try {
|
|
686
|
+
sock.send(JSON.stringify({ type: 'ping' }));
|
|
687
|
+
}
|
|
688
|
+
catch { }
|
|
689
|
+
setTimeout(() => {
|
|
690
|
+
// Alive iff the edge auto-ponged our app-level ping AFTER
|
|
691
|
+
// this probe — the hibernation-safe signal (a protocol-
|
|
692
|
+
// level pong never arrives once the home DO hibernates).
|
|
693
|
+
if (this.appPongAt >= probeAt) {
|
|
694
|
+
logger.info('relay', 'Alive after drift (throttle, not sleep) — keeping connection');
|
|
695
|
+
return;
|
|
696
|
+
}
|
|
697
|
+
// Only terminate the socket we probed, and only if
|
|
698
|
+
// it's still the current one (not already replaced).
|
|
699
|
+
if (sock === this.ws && sock.readyState === WebSocket.OPEN) {
|
|
700
|
+
logger.warn('relay', 'No pong after drift probe — connection dead, reconnecting');
|
|
701
|
+
try {
|
|
702
|
+
sock.terminate();
|
|
703
|
+
}
|
|
704
|
+
catch { }
|
|
705
|
+
}
|
|
706
|
+
}, 8_000);
|
|
707
|
+
}
|
|
708
|
+
}, 5_000);
|
|
709
|
+
done();
|
|
710
|
+
});
|
|
711
|
+
ws.on('error', () => {
|
|
712
|
+
done();
|
|
713
|
+
});
|
|
714
|
+
});
|
|
715
|
+
ws.on('pong', () => {
|
|
716
|
+
this.awaitingPong = false;
|
|
717
|
+
this.missedPongs = 0;
|
|
718
|
+
});
|
|
719
|
+
ws.on('message', (data) => {
|
|
720
|
+
try {
|
|
721
|
+
const msg = JSON.parse(data.toString());
|
|
722
|
+
// App-level heartbeat pong — the edge's setWebSocketAutoResponse
|
|
723
|
+
// answers our {type:'ping'} with {type:'pong'} WITHOUT waking the
|
|
724
|
+
// home DO, so this is the hibernation-safe liveness signal. (The
|
|
725
|
+
// protocol-level ws.on('pong') never fires once the DO hibernates;
|
|
726
|
+
// the edge auto-pongs only this app-level pair, not RFC 6455 frames.)
|
|
727
|
+
if (msg && msg.type === 'pong') {
|
|
728
|
+
this.awaitingPong = false;
|
|
729
|
+
this.missedPongs = 0;
|
|
730
|
+
this.appPongAt = Date.now();
|
|
731
|
+
return;
|
|
732
|
+
}
|
|
733
|
+
this.handleMessage(msg).catch(() => {
|
|
734
|
+
/* message handling error */
|
|
735
|
+
});
|
|
736
|
+
}
|
|
737
|
+
catch {
|
|
738
|
+
/* malformed message */
|
|
739
|
+
}
|
|
740
|
+
});
|
|
741
|
+
ws.on('close', (code, reason) => {
|
|
742
|
+
// Record the close code the daemon observed. 1006 (no close frame)
|
|
743
|
+
// = abrupt sever — either our own terminate() or an edge drop;
|
|
744
|
+
// 1000/1001 = graceful. Pairs with the server's
|
|
745
|
+
// sdk-diag.webSocketClose so a drop is visible from both ends
|
|
746
|
+
// (this lands in daemon.log; console.log here would go to /dev/null).
|
|
747
|
+
const reasonStr = (() => {
|
|
748
|
+
try {
|
|
749
|
+
return reason?.toString?.() ?? '';
|
|
750
|
+
}
|
|
751
|
+
catch {
|
|
752
|
+
return '';
|
|
753
|
+
}
|
|
754
|
+
})();
|
|
755
|
+
logger.warn('relay', `WS closed code=${code ?? '?'} reason="${reasonStr.slice(0, 80)}" wasConnected=${this.connected}`);
|
|
756
|
+
this.ws = null;
|
|
757
|
+
this.wsConnecting = false;
|
|
758
|
+
this.connected = false;
|
|
759
|
+
if (this.flushInterval)
|
|
760
|
+
clearInterval(this.flushInterval);
|
|
761
|
+
if (this.heartbeatInterval)
|
|
762
|
+
clearInterval(this.heartbeatInterval);
|
|
763
|
+
if (this.wsPingInterval)
|
|
764
|
+
clearInterval(this.wsPingInterval);
|
|
765
|
+
if (this.wsWakeDetector)
|
|
766
|
+
clearInterval(this.wsWakeDetector);
|
|
767
|
+
this.flushInterval = null;
|
|
768
|
+
this.heartbeatInterval = null;
|
|
769
|
+
this.wsPingInterval = null;
|
|
770
|
+
this.wsWakeDetector = null;
|
|
771
|
+
// Reject pending requests
|
|
772
|
+
for (const [, pending] of this.pendingRequests) {
|
|
773
|
+
clearTimeout(pending.timer);
|
|
774
|
+
pending.reject(new Error('WebSocket closed'));
|
|
775
|
+
}
|
|
776
|
+
this.pendingRequests.clear();
|
|
777
|
+
// Always schedule reconnect while we're still configured and not
|
|
778
|
+
// stopped. Previous guard `if (wasConnected)` broke the chain: if
|
|
779
|
+
// a reconnect attempt failed to open, the new ws's close handler
|
|
780
|
+
// saw connected=false and skipped scheduling — leaving us stuck
|
|
781
|
+
// forever with no WS and no pending retry. The `stopped` flag is
|
|
782
|
+
// set synchronously in stop()/unconfigure(), so intentional
|
|
783
|
+
// shutdowns still skip the reconnect.
|
|
784
|
+
if (this.config && !this.stopped) {
|
|
785
|
+
this.scheduleReconnect();
|
|
786
|
+
}
|
|
787
|
+
});
|
|
788
|
+
ws.on('error', (err) => {
|
|
789
|
+
if (this.wsConnecting) {
|
|
790
|
+
// Connection failed — close handler will trigger reconnect
|
|
791
|
+
this.wsConnecting = false;
|
|
792
|
+
}
|
|
793
|
+
// Avoid noisy logs for expected connection errors
|
|
794
|
+
if (err.message && !err.message.includes('ECONNREFUSED')) {
|
|
795
|
+
console.error(`[relay] WebSocket error: ${err.message}`);
|
|
796
|
+
}
|
|
797
|
+
});
|
|
798
|
+
}
|
|
799
|
+
/** Handle an incoming WebSocket message from the DO */
|
|
800
|
+
async handleMessage(msg) {
|
|
801
|
+
const type = msg.type;
|
|
802
|
+
// Handle request-response correlation (rid for legacy, requestId for workspace actions)
|
|
803
|
+
const correlationId = msg.requestId ?? msg.rid;
|
|
804
|
+
if (correlationId && this.pendingRequests.has(correlationId)) {
|
|
805
|
+
const pending = this.pendingRequests.get(correlationId);
|
|
806
|
+
clearTimeout(pending.timer);
|
|
807
|
+
this.pendingRequests.delete(correlationId);
|
|
808
|
+
pending.resolve(msg);
|
|
809
|
+
return;
|
|
810
|
+
}
|
|
811
|
+
switch (type) {
|
|
812
|
+
case 'welcome':
|
|
813
|
+
console.log(`[relay] Connected to Clearly (tier: ${msg.tier})`);
|
|
814
|
+
if (msg.tier)
|
|
815
|
+
this.tier = msg.tier;
|
|
816
|
+
break;
|
|
817
|
+
case 'heartbeat-ack': {
|
|
818
|
+
if (msg.tier)
|
|
819
|
+
this.tier = msg.tier;
|
|
820
|
+
const profileData = msg.profile;
|
|
821
|
+
if (profileData) {
|
|
822
|
+
this.profile = {
|
|
823
|
+
displayName: profileData.displayName || 'Clearly User',
|
|
824
|
+
username: profileData.username,
|
|
825
|
+
photoURL: profileData.photoURL,
|
|
826
|
+
email: profileData.email,
|
|
827
|
+
subscriptionPlan: profileData.subscriptionPlan || 'free',
|
|
828
|
+
subscriptionStatus: profileData.subscriptionStatus,
|
|
829
|
+
shareTerminal: profileData.shareTerminal,
|
|
830
|
+
shareTelemetry: profileData.shareTelemetry,
|
|
831
|
+
};
|
|
832
|
+
const json = JSON.stringify(this.profile);
|
|
833
|
+
if (json !== this.lastProfileJson) {
|
|
834
|
+
this.lastProfileJson = json;
|
|
835
|
+
this.onProfileUpdate?.(this.profile);
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
break;
|
|
839
|
+
}
|
|
840
|
+
case 'browse-files': {
|
|
841
|
+
const path = msg.path;
|
|
842
|
+
const rid = msg.rid;
|
|
843
|
+
if (path && rid && this.onBrowseFiles) {
|
|
844
|
+
this.onBrowseFiles(path, rid);
|
|
845
|
+
}
|
|
846
|
+
break;
|
|
847
|
+
}
|
|
848
|
+
case 'file-teleport-delivery': {
|
|
849
|
+
const fileId = msg.fileId;
|
|
850
|
+
const fileName = msg.fileName;
|
|
851
|
+
const size = msg.size;
|
|
852
|
+
const contentType = msg.contentType;
|
|
853
|
+
const downloadUrl = msg.downloadUrl;
|
|
854
|
+
const targetDir = msg.targetDir;
|
|
855
|
+
const uploadUrl = msg.uploadUrl;
|
|
856
|
+
const publicUrl = msg.publicUrl;
|
|
857
|
+
const localPath = msg.localPath;
|
|
858
|
+
// Reverse teleport: upload local file TO R2
|
|
859
|
+
if (uploadUrl && localPath) {
|
|
860
|
+
this.handleUploadLocalFile(localPath, uploadUrl, publicUrl || '', contentType, undefined)
|
|
861
|
+
.then(() => {
|
|
862
|
+
// Send ack with publicUrl so canvas can update the block
|
|
863
|
+
this.wsSend({
|
|
864
|
+
type: 'file-teleport-ack',
|
|
865
|
+
fileId,
|
|
866
|
+
status: 'completed',
|
|
867
|
+
localPath,
|
|
868
|
+
publicUrl,
|
|
869
|
+
});
|
|
870
|
+
})
|
|
871
|
+
.catch((err) => {
|
|
872
|
+
this.wsSend({
|
|
873
|
+
type: 'file-teleport-ack',
|
|
874
|
+
fileId,
|
|
875
|
+
status: 'failed',
|
|
876
|
+
error: err.message,
|
|
877
|
+
});
|
|
878
|
+
});
|
|
879
|
+
break;
|
|
880
|
+
}
|
|
881
|
+
this.handleFileTeleport(fileId, fileName, size, contentType, downloadUrl, targetDir);
|
|
882
|
+
break;
|
|
883
|
+
}
|
|
884
|
+
case 'upload-local-file-delivery': {
|
|
885
|
+
const filePath = msg.filePath;
|
|
886
|
+
const uploadUrl = msg.uploadUrl;
|
|
887
|
+
const publicUrl = msg.publicUrl;
|
|
888
|
+
const contentType = msg.contentType;
|
|
889
|
+
const rid = msg.rid;
|
|
890
|
+
this.handleUploadLocalFile(filePath, uploadUrl, publicUrl, contentType, rid);
|
|
891
|
+
break;
|
|
892
|
+
}
|
|
893
|
+
case 'agent-tool-exec': {
|
|
894
|
+
const toolId = msg.toolId;
|
|
895
|
+
const sessionId = msg.sessionId;
|
|
896
|
+
const toolName = msg.toolName;
|
|
897
|
+
const args = msg.args;
|
|
898
|
+
// `root` (optional) = the cloud agent's bound working folder. When
|
|
899
|
+
// present, executeAgentTool JAILS every path/cwd arg to it. (#local-folder)
|
|
900
|
+
const root = msg.root;
|
|
901
|
+
this.executeAgentTool(toolId, sessionId, toolName, args, root);
|
|
902
|
+
break;
|
|
903
|
+
}
|
|
904
|
+
// ── PTY Terminal Sessions ───────────────────────────────────────
|
|
905
|
+
case 'pty-start': {
|
|
906
|
+
const sid = msg.sessionId;
|
|
907
|
+
// Idempotent: the web client re-sends pty-start on EVERY submit so a command
|
|
908
|
+
// auto-re-binds after a daemon restart. If this session is already live, reuse it —
|
|
909
|
+
// never re-spawn or count it against the session limit.
|
|
910
|
+
if (this.ptySessions.has(sid))
|
|
911
|
+
break;
|
|
912
|
+
const ALLOWED_SHELLS = [
|
|
913
|
+
'/bin/zsh',
|
|
914
|
+
'/bin/bash',
|
|
915
|
+
'/bin/sh',
|
|
916
|
+
'/usr/bin/zsh',
|
|
917
|
+
'/usr/bin/bash',
|
|
918
|
+
];
|
|
919
|
+
const MAX_PTY_SESSIONS = 5;
|
|
920
|
+
if (this.ptySessions.size >= MAX_PTY_SESSIONS) {
|
|
921
|
+
console.warn(`[relay] PTY session limit reached (${MAX_PTY_SESSIONS})`);
|
|
922
|
+
this.wsSend({ type: 'pty-exit', sessionId: msg.sessionId, exitCode: 1 });
|
|
923
|
+
break;
|
|
924
|
+
}
|
|
925
|
+
const cols = msg.cols || 80;
|
|
926
|
+
const rows = msg.rows || 24;
|
|
927
|
+
const cwd = msg.cwd || homedir();
|
|
928
|
+
const requestedShell = msg.shell || process.env.SHELL || '/bin/zsh';
|
|
929
|
+
const shell = ALLOWED_SHELLS.includes(requestedShell) ? requestedShell : '/bin/zsh';
|
|
930
|
+
this.ptyStart(sid, shell, cols, rows, cwd);
|
|
931
|
+
break;
|
|
932
|
+
}
|
|
933
|
+
case 'pty-input': {
|
|
934
|
+
const sid = msg.sessionId;
|
|
935
|
+
const data = msg.data;
|
|
936
|
+
// Queue until the shell integration is live (see ptyStart) so the first command isn't swallowed
|
|
937
|
+
// by the init race; write directly once ready (or if this session isn't buffered).
|
|
938
|
+
if (this.ptyReady.has(sid) || !this.ptyInputQueue.has(sid))
|
|
939
|
+
this.ptySessions.get(sid)?.write(data);
|
|
940
|
+
else
|
|
941
|
+
this.ptyInputQueue.get(sid).push(data);
|
|
942
|
+
break;
|
|
943
|
+
}
|
|
944
|
+
case 'pty-resize': {
|
|
945
|
+
const sid = msg.sessionId;
|
|
946
|
+
const cols = msg.cols;
|
|
947
|
+
const rows = msg.rows;
|
|
948
|
+
this.ptySessions.get(sid)?.resize(cols, rows);
|
|
949
|
+
break;
|
|
950
|
+
}
|
|
951
|
+
case 'pty-kill': {
|
|
952
|
+
const sid = msg.sessionId;
|
|
953
|
+
const pty = this.ptySessions.get(sid);
|
|
954
|
+
if (pty) {
|
|
955
|
+
pty.kill();
|
|
956
|
+
this.ptySessions.delete(sid);
|
|
957
|
+
}
|
|
958
|
+
this.ptyParsers.delete(sid);
|
|
959
|
+
this._cleanupPtyReady(sid);
|
|
960
|
+
cleanupIntegration(sid);
|
|
961
|
+
break;
|
|
962
|
+
}
|
|
963
|
+
case 'viewer-count': {
|
|
964
|
+
this.viewerCount = msg.count || 0;
|
|
965
|
+
break;
|
|
966
|
+
}
|
|
967
|
+
case 'pong':
|
|
968
|
+
break;
|
|
969
|
+
case 'agent-status': {
|
|
970
|
+
this.onBeeStatus?.(msg);
|
|
971
|
+
break;
|
|
972
|
+
}
|
|
973
|
+
case 'bee-thought': {
|
|
974
|
+
this.onBeeThought?.(msg.thought);
|
|
975
|
+
break;
|
|
976
|
+
}
|
|
977
|
+
case 'local-app-fetch': {
|
|
978
|
+
const { buildingType, buildingId, url, method, headers, body, rid } = msg;
|
|
979
|
+
if (this.onLocalAppFetch) {
|
|
980
|
+
this.onLocalAppFetch(buildingType, buildingId, url || 'http://local/', method || 'GET', headers || {}, body)
|
|
981
|
+
.then((result) => {
|
|
982
|
+
this.wsSend({ type: 'local-app-fetch-result', rid, ...result });
|
|
983
|
+
})
|
|
984
|
+
.catch((err) => {
|
|
985
|
+
this.wsSend({ type: 'local-app-fetch-result', rid, status: 500, body: err.message });
|
|
986
|
+
});
|
|
987
|
+
}
|
|
988
|
+
else {
|
|
989
|
+
this.wsSend({
|
|
990
|
+
type: 'local-app-fetch-result',
|
|
991
|
+
rid,
|
|
992
|
+
status: 503,
|
|
993
|
+
body: 'Local app not available',
|
|
994
|
+
});
|
|
995
|
+
}
|
|
996
|
+
break;
|
|
997
|
+
}
|
|
998
|
+
// #74 — cross-hive RPC from the cloud Workspace.
|
|
999
|
+
// IN: { type: 'daemon-rpc-request', requestId, hive, action, input }
|
|
1000
|
+
// OUT: { type: 'daemon-rpc-response', requestId, ok, result?, error? }
|
|
1001
|
+
// The workspace's `_daemonRpc(hive, action, input)` opens a
|
|
1002
|
+
// pending promise keyed on requestId and resolves it when
|
|
1003
|
+
// it sees a matching response back through the same relay.
|
|
1004
|
+
// Fires before any cloud-driven cross-hive call (clipImport,
|
|
1005
|
+
// brandSync, projectCreate, clipSearch, etc).
|
|
1006
|
+
case 'daemon-rpc-request': {
|
|
1007
|
+
const requestId = msg?.requestId;
|
|
1008
|
+
const hive = msg?.hive;
|
|
1009
|
+
const action = msg?.action;
|
|
1010
|
+
const input = msg?.input ?? {};
|
|
1011
|
+
if (!requestId || !hive || !action) {
|
|
1012
|
+
this.wsSend({
|
|
1013
|
+
type: 'daemon-rpc-response',
|
|
1014
|
+
requestId: requestId ?? 'unknown',
|
|
1015
|
+
ok: false,
|
|
1016
|
+
error: 'malformed daemon-rpc-request: requestId, hive, action all required',
|
|
1017
|
+
});
|
|
1018
|
+
break;
|
|
1019
|
+
}
|
|
1020
|
+
if (!this.onDaemonRpcRequest) {
|
|
1021
|
+
this.wsSend({
|
|
1022
|
+
type: 'daemon-rpc-response',
|
|
1023
|
+
requestId,
|
|
1024
|
+
ok: false,
|
|
1025
|
+
error: 'daemon-rpc handler not wired (onDaemonRpcRequest is null) — daemon may need a restart',
|
|
1026
|
+
});
|
|
1027
|
+
break;
|
|
1028
|
+
}
|
|
1029
|
+
const startedAt = Date.now();
|
|
1030
|
+
this.onDaemonRpcRequest(hive, action, input)
|
|
1031
|
+
.then((result) => {
|
|
1032
|
+
const elapsed = Date.now() - startedAt;
|
|
1033
|
+
this.wsSend({
|
|
1034
|
+
type: 'daemon-rpc-response',
|
|
1035
|
+
requestId,
|
|
1036
|
+
ok: true,
|
|
1037
|
+
result,
|
|
1038
|
+
elapsedMs: elapsed,
|
|
1039
|
+
});
|
|
1040
|
+
})
|
|
1041
|
+
.catch((err) => {
|
|
1042
|
+
const elapsed = Date.now() - startedAt;
|
|
1043
|
+
this.wsSend({
|
|
1044
|
+
type: 'daemon-rpc-response',
|
|
1045
|
+
requestId,
|
|
1046
|
+
ok: false,
|
|
1047
|
+
error: String(err?.message ?? err).slice(0, 500),
|
|
1048
|
+
elapsedMs: elapsed,
|
|
1049
|
+
});
|
|
1050
|
+
});
|
|
1051
|
+
break;
|
|
1052
|
+
}
|
|
1053
|
+
case 'error':
|
|
1054
|
+
console.error(`[relay] Server error: ${msg.message}`);
|
|
1055
|
+
if (msg.code === 'auth_failed') {
|
|
1056
|
+
console.error('[relay] Token rejected — sign in from the Clearly Dev Mac app tray');
|
|
1057
|
+
this.connected = false;
|
|
1058
|
+
}
|
|
1059
|
+
break;
|
|
1060
|
+
case 'file-changed': {
|
|
1061
|
+
this.onFileChanged?.(msg);
|
|
1062
|
+
break;
|
|
1063
|
+
}
|
|
1064
|
+
default:
|
|
1065
|
+
// Forward unhandled messages as broadcasts (IPC clients may want them)
|
|
1066
|
+
this.onBroadcast?.(msg);
|
|
1067
|
+
break;
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
/** Send a message over WebSocket. Callers waiting for a reply must check the result. */
|
|
1071
|
+
wsSend(msg) {
|
|
1072
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN)
|
|
1073
|
+
return false;
|
|
1074
|
+
try {
|
|
1075
|
+
this.ws.send(JSON.stringify(msg));
|
|
1076
|
+
this.stats.sent++;
|
|
1077
|
+
return true;
|
|
1078
|
+
}
|
|
1079
|
+
catch {
|
|
1080
|
+
this.stats.failed++;
|
|
1081
|
+
return false;
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
/** Send a message and wait for a response with matching requestId */
|
|
1085
|
+
sendRequest(msg, timeout = 10_000) {
|
|
1086
|
+
return new Promise((resolve, reject) => {
|
|
1087
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
1088
|
+
return reject(new Error('WebSocket not connected'));
|
|
1089
|
+
}
|
|
1090
|
+
const requestId = Math.random().toString(36).slice(2) + Date.now().toString(36);
|
|
1091
|
+
const timer = setTimeout(() => {
|
|
1092
|
+
this.pendingRequests.delete(requestId);
|
|
1093
|
+
const action = msg.type === 'agent-invoke' ? msg.action : msg.type;
|
|
1094
|
+
reject(new Error(`Request timeout after ${timeout}ms (action=${action}, requestId=${requestId}); outcome unknown`));
|
|
1095
|
+
}, timeout);
|
|
1096
|
+
this.pendingRequests.set(requestId, { resolve, reject, timer });
|
|
1097
|
+
// Send both rid (legacy) and requestId (workspace actions) for compatibility
|
|
1098
|
+
if (!this.wsSend({ ...msg, rid: requestId, requestId })) {
|
|
1099
|
+
clearTimeout(timer);
|
|
1100
|
+
this.pendingRequests.delete(requestId);
|
|
1101
|
+
reject(new Error(`WebSocket send failed (requestId=${requestId}); send was not confirmed`));
|
|
1102
|
+
}
|
|
1103
|
+
});
|
|
1104
|
+
}
|
|
1105
|
+
// ── Reconnection ──────────────────────────────────────────────────────
|
|
1106
|
+
/**
|
|
1107
|
+
* Reconnect with exponential backoff + jitter.
|
|
1108
|
+
*
|
|
1109
|
+
* Backoff: 1s → 2s → 4s → 8s → 16s → 32s → 60s (capped). Jitter adds
|
|
1110
|
+
* 0-25% of the base delay so a fleet of clients reconnecting after a
|
|
1111
|
+
* server outage doesn't synchronize into a thundering herd. The cap
|
|
1112
|
+
* keeps idle daemons polling at most once a minute.
|
|
1113
|
+
*
|
|
1114
|
+
* Single-flight: a reconnect already scheduled is a no-op. The timer
|
|
1115
|
+
* is cleared on successful connect (reconnectAttempts → 0) and on
|
|
1116
|
+
* stop().
|
|
1117
|
+
*/
|
|
1118
|
+
scheduleReconnect() {
|
|
1119
|
+
if (this.reconnectTimer)
|
|
1120
|
+
return;
|
|
1121
|
+
const baseDelay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), 60_000);
|
|
1122
|
+
// Add 0-25% random jitter — `delay * (1 + 0..0.25)` keeps the
|
|
1123
|
+
// floor at the exponential value so we never reconnect FASTER
|
|
1124
|
+
// than the backoff intends, only slower.
|
|
1125
|
+
const jitter = Math.random() * 0.25 * baseDelay;
|
|
1126
|
+
const delay = Math.round(baseDelay + jitter);
|
|
1127
|
+
this.reconnectAttempts++;
|
|
1128
|
+
console.log(`[relay] Reconnecting in ${(delay / 1000).toFixed(1)}s (attempt ${this.reconnectAttempts})`);
|
|
1129
|
+
this.reconnectTimer = setTimeout(() => {
|
|
1130
|
+
this.reconnectTimer = null;
|
|
1131
|
+
if (this.useWebSocket) {
|
|
1132
|
+
this.connectWs();
|
|
1133
|
+
}
|
|
1134
|
+
else {
|
|
1135
|
+
this.reconnectLegacy();
|
|
1136
|
+
}
|
|
1137
|
+
}, delay);
|
|
1138
|
+
}
|
|
1139
|
+
/** Start the relay health watchdog (idempotent). Every 30s: if we've been
|
|
1140
|
+
* disconnected past `maxDownMs`, force a HARD reconnect. The self-heal a
|
|
1141
|
+
* headless/orphaned daemon needs (no Mac-app tray to restart it) — a wedged
|
|
1142
|
+
* WS (half-open zombie socket after sleep) otherwise sticks "reconnecting"
|
|
1143
|
+
* forever, which `beehaven restart` had to fix by hand. */
|
|
1144
|
+
startSupervisor(maxDownMs = 120_000) {
|
|
1145
|
+
if (this.supervisorTimer)
|
|
1146
|
+
return;
|
|
1147
|
+
this.supervisorTimer = setInterval(() => {
|
|
1148
|
+
if (this.stopped)
|
|
1149
|
+
return;
|
|
1150
|
+
if (this.connected) {
|
|
1151
|
+
this.disconnectedSince = null;
|
|
1152
|
+
return;
|
|
1153
|
+
}
|
|
1154
|
+
if (this.disconnectedSince == null) {
|
|
1155
|
+
this.disconnectedSince = Date.now();
|
|
1156
|
+
return;
|
|
1157
|
+
}
|
|
1158
|
+
if (Date.now() - this.disconnectedSince > maxDownMs) {
|
|
1159
|
+
console.warn(`[relay] Watchdog: relay down ${Math.round((Date.now() - this.disconnectedSince) / 1000)}s — forcing hard reconnect`);
|
|
1160
|
+
this.disconnectedSince = Date.now();
|
|
1161
|
+
this.forceReconnect();
|
|
1162
|
+
}
|
|
1163
|
+
}, 30_000);
|
|
1164
|
+
}
|
|
1165
|
+
/** Hard reconnect: drop any (possibly zombie) socket, clear pending reconnect
|
|
1166
|
+
* state, reset the backoff so the next dial is immediate, then redial. */
|
|
1167
|
+
forceReconnect() {
|
|
1168
|
+
try {
|
|
1169
|
+
this.ws?.terminate();
|
|
1170
|
+
}
|
|
1171
|
+
catch { }
|
|
1172
|
+
this.ws = null;
|
|
1173
|
+
this.connected = false;
|
|
1174
|
+
this.wsConnecting = false;
|
|
1175
|
+
if (this.reconnectTimer) {
|
|
1176
|
+
clearTimeout(this.reconnectTimer);
|
|
1177
|
+
this.reconnectTimer = null;
|
|
1178
|
+
}
|
|
1179
|
+
this.reconnectAttempts = 0;
|
|
1180
|
+
if (this.useWebSocket)
|
|
1181
|
+
this.connectWs();
|
|
1182
|
+
else
|
|
1183
|
+
this.reconnectLegacy();
|
|
1184
|
+
}
|
|
1185
|
+
async reconnectLegacy() {
|
|
1186
|
+
const ok = await this.sendHeartbeatHttp();
|
|
1187
|
+
if (ok) {
|
|
1188
|
+
this.connected = true;
|
|
1189
|
+
this.reconnectAttempts = 0;
|
|
1190
|
+
console.log(`[relay] Reconnected (tier: ${this.tier})`);
|
|
1191
|
+
if (this.heartbeatInterval)
|
|
1192
|
+
clearInterval(this.heartbeatInterval);
|
|
1193
|
+
this.heartbeatInterval = setInterval(() => this.sendHeartbeatHttp(), 30_000);
|
|
1194
|
+
if (this.flushInterval)
|
|
1195
|
+
clearInterval(this.flushInterval);
|
|
1196
|
+
this.flushInterval = setInterval(() => this.flush(), FLUSH_INTERVAL_MS);
|
|
1197
|
+
}
|
|
1198
|
+
else {
|
|
1199
|
+
this.scheduleReconnect();
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
// ── Public API ───────────────────────────────────────────────────────
|
|
1203
|
+
/** Send a raw Claude Code hook event to the DO for processing */
|
|
1204
|
+
sendRawEvent(event) {
|
|
1205
|
+
if (!this.config || !this.connected)
|
|
1206
|
+
return;
|
|
1207
|
+
if (this.useWebSocket) {
|
|
1208
|
+
this.wsSend({ type: 'raw-event', event });
|
|
1209
|
+
}
|
|
1210
|
+
else {
|
|
1211
|
+
this.buffer.events.push(event);
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
/** Send system telemetry, git status, and agent metrics */
|
|
1215
|
+
sendTelemetry(data) {
|
|
1216
|
+
if (!this.config || !this.connected)
|
|
1217
|
+
return;
|
|
1218
|
+
if (this.useWebSocket) {
|
|
1219
|
+
this.wsSend({ type: 'telemetry', ...data });
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1222
|
+
/**
|
|
1223
|
+
* Announce local app capabilities to cloud. The `apps` field carries
|
|
1224
|
+
* the Omni-agent catalog — compact metadata for every installed .app
|
|
1225
|
+
* on the machine, so the cloud agent knows which apps and which
|
|
1226
|
+
* recipes are available when reasoning about what to do for the user.
|
|
1227
|
+
*/
|
|
1228
|
+
sendLocalAppStatus(status) {
|
|
1229
|
+
this.wsSend({ type: 'local-app-status', ...status });
|
|
1230
|
+
}
|
|
1231
|
+
/**
|
|
1232
|
+
* Advertise this daemon as a BEEHIVE in the distributed hive mesh.
|
|
1233
|
+
*
|
|
1234
|
+
* The Home DO's `case 'swarm-register'` upserts a durable node row (keyed by
|
|
1235
|
+
* this stable deviceId) + maps deviceId → this live session, which is how the
|
|
1236
|
+
* swarm knows the node is online and how the queen targets a dispatch at it.
|
|
1237
|
+
* Re-sent on connect, whenever capabilities change, and on a periodic cadence
|
|
1238
|
+
* (see daemon.ts `registerBeehive`) so it also serves as the beehive's
|
|
1239
|
+
* keepalive and self-heals after a DO hibernation dropped the live map.
|
|
1240
|
+
*
|
|
1241
|
+
* role 'queen' (orchestrator) | 'worker' (executor)
|
|
1242
|
+
* kind 'local-mac' | 'headless' | 'cloud-container'
|
|
1243
|
+
* capabilities the hive/building names this node can run (dispatch match keys)
|
|
1244
|
+
*/
|
|
1245
|
+
sendSwarmRegister(opts) {
|
|
1246
|
+
if (!this.connected)
|
|
1247
|
+
return;
|
|
1248
|
+
this.wsSend({
|
|
1249
|
+
type: 'swarm-register',
|
|
1250
|
+
deviceId: this.deviceId,
|
|
1251
|
+
role: opts.role,
|
|
1252
|
+
kind: opts.kind,
|
|
1253
|
+
capabilities: opts.capabilities,
|
|
1254
|
+
hostname: hostname(),
|
|
1255
|
+
os: platform(),
|
|
1256
|
+
root: homedir(),
|
|
1257
|
+
});
|
|
1258
|
+
}
|
|
1259
|
+
/** Start periodic token self-refresh (every 50 min, for headless/long-running daemons) */
|
|
1260
|
+
tokenRefreshTimer = null;
|
|
1261
|
+
startTokenRefresh() {
|
|
1262
|
+
if (this.tokenRefreshTimer)
|
|
1263
|
+
return;
|
|
1264
|
+
const REFRESH_INTERVAL = 50 * 60 * 1000;
|
|
1265
|
+
const doRefresh = async () => {
|
|
1266
|
+
const ok = await this.refreshIdToken();
|
|
1267
|
+
if (!ok) {
|
|
1268
|
+
console.log('[relay] Token refresh failed — will retry in 5 minutes');
|
|
1269
|
+
setTimeout(doRefresh, 5 * 60 * 1000);
|
|
1270
|
+
}
|
|
1271
|
+
};
|
|
1272
|
+
// Initial refresh after a short delay (gives time for first connection)
|
|
1273
|
+
setTimeout(doRefresh, 30_000);
|
|
1274
|
+
this.tokenRefreshTimer = setInterval(doRefresh, REFRESH_INTERVAL);
|
|
1275
|
+
}
|
|
1276
|
+
/** Refresh the auth token via the relay's /auth/refresh endpoint.
|
|
1277
|
+
* Sends the stored refresh token to the relay Worker, which handles
|
|
1278
|
+
* the Firebase token exchange server-side. */
|
|
1279
|
+
async refreshIdToken() {
|
|
1280
|
+
const cfg = Relay.loadConfig();
|
|
1281
|
+
if (!cfg?.refreshToken) {
|
|
1282
|
+
return false;
|
|
1283
|
+
}
|
|
1284
|
+
try {
|
|
1285
|
+
const base = this.wsBase().replace('wss://', 'https://').replace('ws://', 'http://');
|
|
1286
|
+
const resp = await fetch(`${base}/auth/refresh`, {
|
|
1287
|
+
method: 'POST',
|
|
1288
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1289
|
+
body: JSON.stringify({ refreshToken: cfg.refreshToken }),
|
|
1290
|
+
signal: AbortSignal.timeout(10_000),
|
|
1291
|
+
});
|
|
1292
|
+
if (!resp.ok) {
|
|
1293
|
+
return false;
|
|
1294
|
+
}
|
|
1295
|
+
const data = (await resp.json());
|
|
1296
|
+
if (!data.token)
|
|
1297
|
+
return false;
|
|
1298
|
+
// Update config + keychain with the fresh tokens. Both must
|
|
1299
|
+
// be in sync — Relay.loadConfig() reads from keychain when
|
|
1300
|
+
// tokenStorage='keychain', so a stale keychain after a
|
|
1301
|
+
// successful in-memory refresh would cause every subsequent
|
|
1302
|
+
// daemon restart to dial the WebSocket with an expired
|
|
1303
|
+
// idToken and burn another refresh round-trip.
|
|
1304
|
+
try {
|
|
1305
|
+
const raw = readFileSync(CONFIG_FILE, 'utf8');
|
|
1306
|
+
const config = JSON.parse(raw);
|
|
1307
|
+
config.token = data.token;
|
|
1308
|
+
if (data.refreshToken)
|
|
1309
|
+
config.refreshToken = data.refreshToken;
|
|
1310
|
+
writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2));
|
|
1311
|
+
try {
|
|
1312
|
+
chmodSync(CONFIG_FILE, 0o600);
|
|
1313
|
+
}
|
|
1314
|
+
catch { }
|
|
1315
|
+
// Mirror to keychain via `security` so the next daemon
|
|
1316
|
+
// launch's loadConfig reads a fresh idToken. execSync
|
|
1317
|
+
// avoids importing keychain.ts (which is ESM-only and
|
|
1318
|
+
// would create circular import drama).
|
|
1319
|
+
if (config.tokenStorage === 'keychain') {
|
|
1320
|
+
try {
|
|
1321
|
+
execSync(`security delete-generic-password -s beehaven -a daemon-token`, {
|
|
1322
|
+
stdio: 'ignore',
|
|
1323
|
+
});
|
|
1324
|
+
}
|
|
1325
|
+
catch { } // entry didn't exist; fine
|
|
1326
|
+
execSync(`security add-generic-password -s beehaven -a daemon-token -w '${data.token.replace(/'/g, "'\\''")}'`, { stdio: ['ignore', 'ignore', 'pipe'] });
|
|
1327
|
+
}
|
|
1328
|
+
}
|
|
1329
|
+
catch { }
|
|
1330
|
+
// Update in-memory config
|
|
1331
|
+
if (this.config) {
|
|
1332
|
+
this.config.token = data.token;
|
|
1333
|
+
}
|
|
1334
|
+
console.log('[relay] Token refreshed successfully');
|
|
1335
|
+
return true;
|
|
1336
|
+
}
|
|
1337
|
+
catch {
|
|
1338
|
+
return false;
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1341
|
+
/** Queue docs for next flush */
|
|
1342
|
+
uploadDocs(project, projectRoot) {
|
|
1343
|
+
if (!this.config || !this.connected)
|
|
1344
|
+
return;
|
|
1345
|
+
const DOC_FILES = ['README.md', 'CLAUDE.md', 'package.json'];
|
|
1346
|
+
const docs = [];
|
|
1347
|
+
for (const name of DOC_FILES) {
|
|
1348
|
+
const filePath = join(projectRoot, name);
|
|
1349
|
+
if (!existsSync(filePath))
|
|
1350
|
+
continue;
|
|
1351
|
+
try {
|
|
1352
|
+
const content = readFileSync(filePath, 'utf8');
|
|
1353
|
+
if (content.length > 500_000)
|
|
1354
|
+
continue;
|
|
1355
|
+
docs.push({ name, content });
|
|
1356
|
+
}
|
|
1357
|
+
catch {
|
|
1358
|
+
continue;
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
1361
|
+
if (docs.length > 0) {
|
|
1362
|
+
this.buffer.docs.push({ project, docs });
|
|
1363
|
+
}
|
|
1364
|
+
}
|
|
1365
|
+
// ── Commands (web → local) ────────────────────────────────────────────
|
|
1366
|
+
// DISABLED: Programmatic Claude orchestration not permitted — Anthropic credential use policy
|
|
1367
|
+
// https://code.claude.com/docs/en/legal-and-compliance#authentication-and-credential-use
|
|
1368
|
+
// /** Commands arrive over WebSocket — no polling needed */
|
|
1369
|
+
// ── Hook Events (local → web) ────────────────────────────────────────
|
|
1370
|
+
/** Send a hook event to the web client — immediate over WebSocket */
|
|
1371
|
+
sendHookEvent(webSessionId, event) {
|
|
1372
|
+
if (!this.config || !this.connected)
|
|
1373
|
+
return;
|
|
1374
|
+
if (this.useWebSocket) {
|
|
1375
|
+
this.wsSend({
|
|
1376
|
+
type: 'hook-events',
|
|
1377
|
+
sessionId: webSessionId,
|
|
1378
|
+
events: [
|
|
1379
|
+
{
|
|
1380
|
+
hook_event_name: event.hook_event_name,
|
|
1381
|
+
tool_name: event.tool_name,
|
|
1382
|
+
tool_input: event.tool_input,
|
|
1383
|
+
error: event.error,
|
|
1384
|
+
prompt: event.prompt,
|
|
1385
|
+
agent_type: event.agent_type,
|
|
1386
|
+
message: event.message,
|
|
1387
|
+
task_subject: event.task_subject,
|
|
1388
|
+
timestamp: event.timestamp,
|
|
1389
|
+
},
|
|
1390
|
+
],
|
|
1391
|
+
});
|
|
1392
|
+
}
|
|
1393
|
+
}
|
|
1394
|
+
// ── File Browser (web → daemon → web) ────────────────────────────────
|
|
1395
|
+
/** Send browse-files result back to the web client via DO */
|
|
1396
|
+
sendBrowseFilesResult(path, entries, rid, error) {
|
|
1397
|
+
if (!this.connected)
|
|
1398
|
+
return;
|
|
1399
|
+
this.wsSend({
|
|
1400
|
+
type: 'browse-files-result',
|
|
1401
|
+
path,
|
|
1402
|
+
entries,
|
|
1403
|
+
error,
|
|
1404
|
+
rid,
|
|
1405
|
+
});
|
|
1406
|
+
}
|
|
1407
|
+
// ── Session Events (local → web) ─────────────────────────────────────
|
|
1408
|
+
/** Send a session event — immediate over WebSocket, batched for legacy HTTP */
|
|
1409
|
+
sendSessionEvent(sessionId, event) {
|
|
1410
|
+
if (!this.config || !this.connected) {
|
|
1411
|
+
return;
|
|
1412
|
+
}
|
|
1413
|
+
if (this.useWebSocket) {
|
|
1414
|
+
this.wsSend({ type: 'session-events', sessionId, events: [event] });
|
|
1415
|
+
}
|
|
1416
|
+
else {
|
|
1417
|
+
const batch = this.sessionEventBatch.get(sessionId) || [];
|
|
1418
|
+
batch.push(event);
|
|
1419
|
+
this.sessionEventBatch.set(sessionId, batch);
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1422
|
+
// ── One-off Requests ──────────────────────────────────────────────────
|
|
1423
|
+
/** Verify a token by sending a heartbeat (always uses HTTP) */
|
|
1424
|
+
async verifyToken(token, endpoint) {
|
|
1425
|
+
const url = this.httpUrl(endpoint);
|
|
1426
|
+
try {
|
|
1427
|
+
const resp = await fetch(url, {
|
|
1428
|
+
method: 'POST',
|
|
1429
|
+
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
|
1430
|
+
body: JSON.stringify({ type: 'heartbeat', deviceId: this.deviceId }),
|
|
1431
|
+
signal: AbortSignal.timeout(10000),
|
|
1432
|
+
});
|
|
1433
|
+
if (!resp.ok)
|
|
1434
|
+
return null;
|
|
1435
|
+
const json = await resp.json();
|
|
1436
|
+
if (!json.ok)
|
|
1437
|
+
return null;
|
|
1438
|
+
return json.profile || { verified: true };
|
|
1439
|
+
}
|
|
1440
|
+
catch {
|
|
1441
|
+
return null;
|
|
1442
|
+
}
|
|
1443
|
+
}
|
|
1444
|
+
/** Delete the relay session server-side (used on uninstall) */
|
|
1445
|
+
async deleteSession(token, endpoint) {
|
|
1446
|
+
const t = token || this.config?.token;
|
|
1447
|
+
if (!t)
|
|
1448
|
+
return;
|
|
1449
|
+
const url = this.httpUrl(endpoint);
|
|
1450
|
+
try {
|
|
1451
|
+
await fetch(url, {
|
|
1452
|
+
method: 'POST',
|
|
1453
|
+
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${t}` },
|
|
1454
|
+
body: JSON.stringify({ type: 'delete-session', deviceId: this.deviceId }),
|
|
1455
|
+
signal: AbortSignal.timeout(5000),
|
|
1456
|
+
});
|
|
1457
|
+
}
|
|
1458
|
+
catch {
|
|
1459
|
+
/* best effort */
|
|
1460
|
+
}
|
|
1461
|
+
}
|
|
1462
|
+
/** Upload a session transcript to Cloud Storage */
|
|
1463
|
+
async uploadTranscript(transcriptPath, sessionId, project) {
|
|
1464
|
+
if (!this.config || !this.connected)
|
|
1465
|
+
return;
|
|
1466
|
+
if (!existsSync(transcriptPath))
|
|
1467
|
+
return;
|
|
1468
|
+
try {
|
|
1469
|
+
const fileSize = statSync(transcriptPath).size;
|
|
1470
|
+
if (fileSize > 10 * 1024 * 1024)
|
|
1471
|
+
return; // Skip > 10MB
|
|
1472
|
+
let uploadUrl = null;
|
|
1473
|
+
if (this.useWebSocket) {
|
|
1474
|
+
const resp = await this.sendRequest({
|
|
1475
|
+
type: 'get-upload-url',
|
|
1476
|
+
path: `transcripts/${sessionId}.jsonl`,
|
|
1477
|
+
contentType: 'application/x-ndjson',
|
|
1478
|
+
});
|
|
1479
|
+
uploadUrl = resp?.uploadUrl || null;
|
|
1480
|
+
}
|
|
1481
|
+
else {
|
|
1482
|
+
const resp = await this.postHttp({
|
|
1483
|
+
type: 'get-upload-url',
|
|
1484
|
+
path: `transcripts/${sessionId}.jsonl`,
|
|
1485
|
+
contentType: 'application/x-ndjson',
|
|
1486
|
+
});
|
|
1487
|
+
uploadUrl = resp?.uploadUrl || null;
|
|
1488
|
+
}
|
|
1489
|
+
if (!uploadUrl)
|
|
1490
|
+
return;
|
|
1491
|
+
const content = readFileSync(transcriptPath);
|
|
1492
|
+
const uploadResp = await fetch(uploadUrl, {
|
|
1493
|
+
method: 'PUT',
|
|
1494
|
+
headers: { 'Content-Type': 'application/x-ndjson' },
|
|
1495
|
+
body: content,
|
|
1496
|
+
signal: AbortSignal.timeout(30_000),
|
|
1497
|
+
});
|
|
1498
|
+
if (uploadResp.ok) {
|
|
1499
|
+
this.lastSyncAt = Date.now();
|
|
1500
|
+
if (project) {
|
|
1501
|
+
const existing = this.projectSyncDetails.get(project);
|
|
1502
|
+
if (existing) {
|
|
1503
|
+
existing.transcriptUploaded = true;
|
|
1504
|
+
existing.lastSyncAt = Date.now();
|
|
1505
|
+
}
|
|
1506
|
+
}
|
|
1507
|
+
console.log(`[relay] Transcript uploaded: ${sessionId} (${(fileSize / 1024).toFixed(0)}KB)`);
|
|
1508
|
+
}
|
|
1509
|
+
}
|
|
1510
|
+
catch (err) {
|
|
1511
|
+
console.error(`[relay] Transcript upload error: ${err.message}`);
|
|
1512
|
+
}
|
|
1513
|
+
}
|
|
1514
|
+
/** Upload a saved session JSON file to R2 storage */
|
|
1515
|
+
async uploadSession(sessionPath, sessionId) {
|
|
1516
|
+
if (!this.config || !this.connected)
|
|
1517
|
+
return false;
|
|
1518
|
+
if (!existsSync(sessionPath))
|
|
1519
|
+
return false;
|
|
1520
|
+
try {
|
|
1521
|
+
const fileSize = statSync(sessionPath).size;
|
|
1522
|
+
if (fileSize > 5 * 1024 * 1024)
|
|
1523
|
+
return false;
|
|
1524
|
+
let uploadUrl = null;
|
|
1525
|
+
if (this.useWebSocket) {
|
|
1526
|
+
const resp = await this.sendRequest({
|
|
1527
|
+
type: 'get-upload-url',
|
|
1528
|
+
path: `sessions/${sessionId}.json`,
|
|
1529
|
+
contentType: 'application/json',
|
|
1530
|
+
});
|
|
1531
|
+
uploadUrl = resp?.uploadUrl || null;
|
|
1532
|
+
}
|
|
1533
|
+
else {
|
|
1534
|
+
const resp = await this.postHttp({
|
|
1535
|
+
type: 'get-upload-url',
|
|
1536
|
+
path: `sessions/${sessionId}.json`,
|
|
1537
|
+
contentType: 'application/json',
|
|
1538
|
+
});
|
|
1539
|
+
uploadUrl = resp?.uploadUrl || null;
|
|
1540
|
+
}
|
|
1541
|
+
if (!uploadUrl)
|
|
1542
|
+
return false;
|
|
1543
|
+
const content = readFileSync(sessionPath);
|
|
1544
|
+
const uploadResp = await fetch(uploadUrl, {
|
|
1545
|
+
method: 'PUT',
|
|
1546
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1547
|
+
body: content,
|
|
1548
|
+
signal: AbortSignal.timeout(30_000),
|
|
1549
|
+
});
|
|
1550
|
+
if (uploadResp.ok) {
|
|
1551
|
+
this.lastSyncAt = Date.now();
|
|
1552
|
+
return true;
|
|
1553
|
+
}
|
|
1554
|
+
return false;
|
|
1555
|
+
}
|
|
1556
|
+
catch {
|
|
1557
|
+
return false;
|
|
1558
|
+
}
|
|
1559
|
+
}
|
|
1560
|
+
/** Fetch building state (always uses HTTP) */
|
|
1561
|
+
async getBuilding() {
|
|
1562
|
+
if (!this.config)
|
|
1563
|
+
return null;
|
|
1564
|
+
try {
|
|
1565
|
+
const resp = await this.postHttp({ type: 'building' });
|
|
1566
|
+
return resp?.building || null;
|
|
1567
|
+
}
|
|
1568
|
+
catch {
|
|
1569
|
+
return null;
|
|
1570
|
+
}
|
|
1571
|
+
}
|
|
1572
|
+
/** Claim a desk in a building (always uses HTTP) */
|
|
1573
|
+
async selectDesk(floor, desk) {
|
|
1574
|
+
if (!this.config)
|
|
1575
|
+
return false;
|
|
1576
|
+
try {
|
|
1577
|
+
const resp = await this.postHttp({ type: 'claim-desk', floor, desk });
|
|
1578
|
+
return resp?.ok === true;
|
|
1579
|
+
}
|
|
1580
|
+
catch {
|
|
1581
|
+
return false;
|
|
1582
|
+
}
|
|
1583
|
+
}
|
|
1584
|
+
// ── Folder Sync ───────────────────────────────────────────────────────
|
|
1585
|
+
async syncFileUpload(brandId, relativePath, content, hash) {
|
|
1586
|
+
if (!this.config || !this.connected)
|
|
1587
|
+
return;
|
|
1588
|
+
try {
|
|
1589
|
+
const contentType = this.getMimeType(relativePath);
|
|
1590
|
+
let uploadUrl = null;
|
|
1591
|
+
if (this.useWebSocket) {
|
|
1592
|
+
const resp = await this.sendRequest({
|
|
1593
|
+
type: 'get-upload-url',
|
|
1594
|
+
path: `projects/${brandId}/assets/${relativePath}`,
|
|
1595
|
+
contentType,
|
|
1596
|
+
});
|
|
1597
|
+
uploadUrl = resp?.uploadUrl || null;
|
|
1598
|
+
}
|
|
1599
|
+
else {
|
|
1600
|
+
const result = await this.postHttp({
|
|
1601
|
+
type: 'get-upload-url',
|
|
1602
|
+
path: `projects/${brandId}/assets/${relativePath}`,
|
|
1603
|
+
contentType,
|
|
1604
|
+
});
|
|
1605
|
+
uploadUrl = result?.uploadUrl || null;
|
|
1606
|
+
}
|
|
1607
|
+
if (!uploadUrl)
|
|
1608
|
+
return;
|
|
1609
|
+
await fetch(uploadUrl, {
|
|
1610
|
+
method: 'PUT',
|
|
1611
|
+
headers: { 'Content-Type': contentType },
|
|
1612
|
+
body: new Uint8Array(content),
|
|
1613
|
+
signal: AbortSignal.timeout(30_000),
|
|
1614
|
+
});
|
|
1615
|
+
const fileMsg = {
|
|
1616
|
+
type: 'file-sync',
|
|
1617
|
+
brandId,
|
|
1618
|
+
action: 'upsert',
|
|
1619
|
+
file: {
|
|
1620
|
+
path: relativePath,
|
|
1621
|
+
hash,
|
|
1622
|
+
size: content.length,
|
|
1623
|
+
storagePath: `projects/${brandId}/assets/${relativePath}`,
|
|
1624
|
+
},
|
|
1625
|
+
};
|
|
1626
|
+
if (this.useWebSocket) {
|
|
1627
|
+
this.wsSend(fileMsg);
|
|
1628
|
+
}
|
|
1629
|
+
else {
|
|
1630
|
+
await this.postHttp(fileMsg);
|
|
1631
|
+
}
|
|
1632
|
+
this.lastSyncAt = Date.now();
|
|
1633
|
+
}
|
|
1634
|
+
catch (err) {
|
|
1635
|
+
console.error(`[relay] File upload failed (${relativePath}): ${err.message}`);
|
|
1636
|
+
}
|
|
1637
|
+
}
|
|
1638
|
+
// ── Security helpers for cloud-driven local file transfer ────────────
|
|
1639
|
+
// The cloud→daemon WS dispatches on msg.type with NO per-message auth, so
|
|
1640
|
+
// these handlers must assume a frame may be hostile (injected agent /
|
|
1641
|
+
// compromised relay). Restrict transfer endpoints + protected paths.
|
|
1642
|
+
static isAllowedTransferUrl(rawUrl) {
|
|
1643
|
+
try {
|
|
1644
|
+
const u = new URL(rawUrl);
|
|
1645
|
+
if (u.protocol !== 'https:')
|
|
1646
|
+
return false;
|
|
1647
|
+
const h = u.hostname.toLowerCase();
|
|
1648
|
+
return [
|
|
1649
|
+
/(^|\.)r2\.cloudflarestorage\.com$/,
|
|
1650
|
+
/(^|\.)r2\.dev$/,
|
|
1651
|
+
/(^|\.)clearly\.sh$/,
|
|
1652
|
+
/(^|\.)clearly-sh\.pages\.dev$/,
|
|
1653
|
+
].some((re) => re.test(h));
|
|
1654
|
+
}
|
|
1655
|
+
catch {
|
|
1656
|
+
return false;
|
|
1657
|
+
}
|
|
1658
|
+
}
|
|
1659
|
+
static isSensitivePath(p) {
|
|
1660
|
+
const s = String(p || '');
|
|
1661
|
+
return [
|
|
1662
|
+
/\/\.ssh\//,
|
|
1663
|
+
/\/\.aws\//,
|
|
1664
|
+
/\/\.config\/gcloud\//,
|
|
1665
|
+
/\/\.gnupg\//,
|
|
1666
|
+
/\/\.beehaven\//,
|
|
1667
|
+
/\/Library\/LaunchAgents\//,
|
|
1668
|
+
/\/Library\/LaunchDaemons\//,
|
|
1669
|
+
/id_rsa|id_ed25519/,
|
|
1670
|
+
/\.pem$|\.key$|\.p12$/,
|
|
1671
|
+
/(^|\/)\.env(\.|$)/,
|
|
1672
|
+
/(^|\/)\.(zshrc|zshenv|zprofile|bashrc|bash_profile|profile)$/,
|
|
1673
|
+
].some((re) => re.test(s));
|
|
1674
|
+
}
|
|
1675
|
+
/** Read a local file and upload it to R2 via presigned URL */
|
|
1676
|
+
async handleUploadLocalFile(filePath, uploadUrl, publicUrl, contentType, rid) {
|
|
1677
|
+
try {
|
|
1678
|
+
const { readFileSync, statSync } = await import('fs');
|
|
1679
|
+
const resolved = filePath.startsWith('~') ? join(homedir(), filePath.slice(1)) : filePath;
|
|
1680
|
+
// SECURITY: only upload to Clearly R2, and never read credential/key
|
|
1681
|
+
// material — otherwise an injected frame exfiltrates local secrets.
|
|
1682
|
+
if (!Relay.isAllowedTransferUrl(uploadUrl))
|
|
1683
|
+
throw new Error('upload destination not allowed');
|
|
1684
|
+
if (Relay.isSensitivePath(resolved))
|
|
1685
|
+
throw new Error('refusing to read protected path');
|
|
1686
|
+
const stat = statSync(resolved);
|
|
1687
|
+
const data = readFileSync(resolved);
|
|
1688
|
+
console.log(`[relay] Uploading local file to R2: ${resolved} (${(stat.size / 1024).toFixed(0)}KB)`);
|
|
1689
|
+
const resp = await fetch(uploadUrl, {
|
|
1690
|
+
method: 'PUT',
|
|
1691
|
+
headers: { 'Content-Type': contentType },
|
|
1692
|
+
body: data,
|
|
1693
|
+
});
|
|
1694
|
+
if (!resp.ok) {
|
|
1695
|
+
throw new Error(`R2 upload failed: ${resp.status} ${resp.statusText}`);
|
|
1696
|
+
}
|
|
1697
|
+
console.log(`[relay] Local file uploaded successfully: ${publicUrl}`);
|
|
1698
|
+
this.wsSend({
|
|
1699
|
+
type: 'upload-local-file-result',
|
|
1700
|
+
rid,
|
|
1701
|
+
filePath,
|
|
1702
|
+
publicUrl,
|
|
1703
|
+
status: 'completed',
|
|
1704
|
+
size: stat.size,
|
|
1705
|
+
});
|
|
1706
|
+
}
|
|
1707
|
+
catch (err) {
|
|
1708
|
+
console.error(`[relay] Failed to upload local file: ${err.message}`);
|
|
1709
|
+
this.wsSend({
|
|
1710
|
+
type: 'upload-local-file-result',
|
|
1711
|
+
rid,
|
|
1712
|
+
filePath,
|
|
1713
|
+
publicUrl,
|
|
1714
|
+
status: 'failed',
|
|
1715
|
+
error: err.message,
|
|
1716
|
+
});
|
|
1717
|
+
}
|
|
1718
|
+
}
|
|
1719
|
+
/** Download a teleported file from R2 and save to local filesystem */
|
|
1720
|
+
async handleFileTeleport(fileId, fileName, size, contentType, downloadUrl, targetDir) {
|
|
1721
|
+
try {
|
|
1722
|
+
// SECURITY: confine writes to the teleport root and only download
|
|
1723
|
+
// from Clearly R2. Previously an arbitrary absolute targetDir let a
|
|
1724
|
+
// cloud frame write ~/.zshrc, ~/Library/LaunchAgents, etc. → RCE.
|
|
1725
|
+
if (!Relay.isAllowedTransferUrl(downloadUrl))
|
|
1726
|
+
throw new Error('download source not allowed');
|
|
1727
|
+
const teleportRoot = join(homedir(), 'Downloads', 'clearly-teleport');
|
|
1728
|
+
// Honor a caller subdir only if it's a simple alnum name (cannot
|
|
1729
|
+
// escape the root); an arbitrary absolute targetDir is ignored.
|
|
1730
|
+
const sub = String(targetDir || '').replace(/[^a-z0-9_-]/gi, '');
|
|
1731
|
+
const baseDir = sub ? join(teleportRoot, sub) : teleportRoot;
|
|
1732
|
+
const safeName = fileName.replace(/\.\./g, '').replace(/[/\\]/g, '_');
|
|
1733
|
+
const savePath = join(baseDir, safeName);
|
|
1734
|
+
// Ensure directory exists
|
|
1735
|
+
mkdirSync(dirname(savePath), { recursive: true });
|
|
1736
|
+
// Check if file already exists with matching size — skip re-download
|
|
1737
|
+
if (existsSync(savePath)) {
|
|
1738
|
+
const existing = statSync(savePath);
|
|
1739
|
+
if (size > 0 && Math.abs(existing.size - size) / size < 0.01) {
|
|
1740
|
+
console.log(`[relay] File already exists, skipping download: ${savePath} (${(existing.size / 1024).toFixed(0)}KB)`);
|
|
1741
|
+
this.wsSend({
|
|
1742
|
+
type: 'file-teleport-ack',
|
|
1743
|
+
fileId,
|
|
1744
|
+
status: 'completed',
|
|
1745
|
+
localPath: savePath,
|
|
1746
|
+
});
|
|
1747
|
+
this.onFileTeleported?.({
|
|
1748
|
+
fileId,
|
|
1749
|
+
fileName,
|
|
1750
|
+
localPath: savePath,
|
|
1751
|
+
size: existing.size,
|
|
1752
|
+
contentType,
|
|
1753
|
+
});
|
|
1754
|
+
return;
|
|
1755
|
+
}
|
|
1756
|
+
console.log(`[relay] File exists but size differs (local: ${existing.size}, remote: ${size}), re-downloading`);
|
|
1757
|
+
}
|
|
1758
|
+
// Stream download from R2 presigned URL
|
|
1759
|
+
const resp = await fetch(downloadUrl, {
|
|
1760
|
+
signal: AbortSignal.timeout(300_000), // 5 min timeout for large files
|
|
1761
|
+
});
|
|
1762
|
+
if (!resp.ok) {
|
|
1763
|
+
throw new Error(`Download failed: ${resp.status} ${resp.statusText}`);
|
|
1764
|
+
}
|
|
1765
|
+
// Read as buffer and write to disk
|
|
1766
|
+
const buffer = Buffer.from(await resp.arrayBuffer());
|
|
1767
|
+
writeFileSync(savePath, buffer);
|
|
1768
|
+
console.log(`[relay] File teleported: ${savePath} (${(buffer.length / 1024).toFixed(0)}KB)`);
|
|
1769
|
+
// Send ack back to web
|
|
1770
|
+
this.wsSend({
|
|
1771
|
+
type: 'file-teleport-ack',
|
|
1772
|
+
fileId,
|
|
1773
|
+
status: 'completed',
|
|
1774
|
+
localPath: savePath,
|
|
1775
|
+
});
|
|
1776
|
+
// Notify callback
|
|
1777
|
+
this.onFileTeleported?.({
|
|
1778
|
+
fileId,
|
|
1779
|
+
fileName,
|
|
1780
|
+
localPath: savePath,
|
|
1781
|
+
size: buffer.length,
|
|
1782
|
+
contentType,
|
|
1783
|
+
});
|
|
1784
|
+
}
|
|
1785
|
+
catch (err) {
|
|
1786
|
+
console.error(`[relay] File teleport failed (${fileName}): ${err.message}`);
|
|
1787
|
+
this.wsSend({
|
|
1788
|
+
type: 'file-teleport-ack',
|
|
1789
|
+
fileId,
|
|
1790
|
+
status: 'failed',
|
|
1791
|
+
error: err.message,
|
|
1792
|
+
});
|
|
1793
|
+
}
|
|
1794
|
+
}
|
|
1795
|
+
// ── Agent Tool Execution (bee agent → local machine) ────────────────
|
|
1796
|
+
/** Execute a tool call from the bee agent on the local filesystem */
|
|
1797
|
+
async executeAgentTool(toolId, sessionId, toolName, args, root) {
|
|
1798
|
+
const startMs = Date.now();
|
|
1799
|
+
// Audit log
|
|
1800
|
+
const sanitizedPath = args?.path ? String(args.path).slice(0, 200) : '';
|
|
1801
|
+
console.log(`[agent-tool] ${toolName} ${sanitizedPath}`);
|
|
1802
|
+
// ── JAIL to the bound working folder (#local-folder Phase 2.5) ───────────
|
|
1803
|
+
// When the cloud agent is scoped to a folder, the DO sends `root`; confine
|
|
1804
|
+
// every path/cwd arg to it (lexical, strict) so an out-of-folder read /
|
|
1805
|
+
// write / edit / exec is REFUSED — including prompt-injected ones (a repo
|
|
1806
|
+
// file telling the model to read ~/Documents). Upstream reads are ungated,
|
|
1807
|
+
// so this is the real scope boundary; isSensitivePath + the HIVE_DEV_MODE
|
|
1808
|
+
// bash gate still apply on top. No `root` (other callers) → skip, so
|
|
1809
|
+
// existing behavior is unchanged. NOTE: lexical only — a symlink inside the
|
|
1810
|
+
// folder pointing out is not resolved (realpath hardening = follow-up).
|
|
1811
|
+
if (root && typeof root === 'string' && args && typeof args === 'object') {
|
|
1812
|
+
const confine = (field) => {
|
|
1813
|
+
const v = args[field];
|
|
1814
|
+
if (typeof v !== 'string' || !v)
|
|
1815
|
+
return null;
|
|
1816
|
+
const r = transformPath(v, { compositionCwd: root, outOfScopePolicy: 'strict' });
|
|
1817
|
+
if (r.absolutePath === null)
|
|
1818
|
+
return r.reason || `${field} is outside the working folder`;
|
|
1819
|
+
args[field] = r.absolutePath;
|
|
1820
|
+
return null;
|
|
1821
|
+
};
|
|
1822
|
+
// Default a missing directory to the folder root (else ls/grep/bash fall
|
|
1823
|
+
// back to $HOME — outside the jail).
|
|
1824
|
+
if ((toolName === 'ls' || toolName === 'grep') && !args.path)
|
|
1825
|
+
args.path = root;
|
|
1826
|
+
if (toolName === 'bash' && !args.cwd)
|
|
1827
|
+
args.cwd = root;
|
|
1828
|
+
let scopeErr = null;
|
|
1829
|
+
if (['read', 'write', 'edit', 'ls', 'grep'].includes(toolName))
|
|
1830
|
+
scopeErr = confine('path');
|
|
1831
|
+
else if (toolName === 'glob')
|
|
1832
|
+
scopeErr = confine('pattern') || confine('path');
|
|
1833
|
+
else if (toolName === 'bash')
|
|
1834
|
+
scopeErr = confine('cwd');
|
|
1835
|
+
if (scopeErr) {
|
|
1836
|
+
this.wsSend({
|
|
1837
|
+
type: 'agent-tool-exec-result',
|
|
1838
|
+
toolId,
|
|
1839
|
+
sessionId,
|
|
1840
|
+
result: '',
|
|
1841
|
+
error: `Refused: ${scopeErr}. This agent is scoped to ${root} — stay inside it, or ask the user to re-point the working folder.`,
|
|
1842
|
+
durationMs: Date.now() - startMs,
|
|
1843
|
+
});
|
|
1844
|
+
return;
|
|
1845
|
+
}
|
|
1846
|
+
}
|
|
1847
|
+
// SECURITY: bash / workspace-exec are arbitrary local command execution.
|
|
1848
|
+
// The cloud→daemon channel is unauthenticated per message, so gate the
|
|
1849
|
+
// RCE primitives behind explicit Developer Mode (HIVE_DEV_MODE=1).
|
|
1850
|
+
/**
|
|
1851
|
+
* ⚠ THE SAME PREDICATE THE DAEMON USES. This tested `HIVE_DEV_MODE !== '1'` while the daemon
|
|
1852
|
+
* accepted `'true'` as well, so `HIVE_DEV_MODE=true` produced a daemon in developer mode whose
|
|
1853
|
+
* relay path still refused `bash` — a split nothing reports, and which reads as a broken gate.
|
|
1854
|
+
* It also means a RELEASED build cannot unlock local command execution at all.
|
|
1855
|
+
*/
|
|
1856
|
+
if ((toolName === 'bash' || toolName === 'workspace-exec') && !devModeAvailable()) {
|
|
1857
|
+
this.wsSend({
|
|
1858
|
+
type: 'agent-tool-exec-result',
|
|
1859
|
+
toolId,
|
|
1860
|
+
sessionId,
|
|
1861
|
+
result: '',
|
|
1862
|
+
error: `tool '${toolName}' requires Developer Mode. ${devModeRefusal()}`,
|
|
1863
|
+
durationMs: Date.now() - startMs,
|
|
1864
|
+
});
|
|
1865
|
+
return;
|
|
1866
|
+
}
|
|
1867
|
+
try {
|
|
1868
|
+
let result;
|
|
1869
|
+
switch (toolName) {
|
|
1870
|
+
case 'read':
|
|
1871
|
+
result = this.agentRead(args);
|
|
1872
|
+
break;
|
|
1873
|
+
case 'write':
|
|
1874
|
+
result = this.agentWrite(args);
|
|
1875
|
+
break;
|
|
1876
|
+
case 'edit':
|
|
1877
|
+
result = this.agentEdit(args);
|
|
1878
|
+
break;
|
|
1879
|
+
case 'glob':
|
|
1880
|
+
result = this.agentGlob(args);
|
|
1881
|
+
break;
|
|
1882
|
+
case 'ls':
|
|
1883
|
+
result = this.agentLs(args);
|
|
1884
|
+
break;
|
|
1885
|
+
case 'grep':
|
|
1886
|
+
result = this.agentGrep(args);
|
|
1887
|
+
break;
|
|
1888
|
+
case 'bash':
|
|
1889
|
+
result = this.agentBash(args);
|
|
1890
|
+
break;
|
|
1891
|
+
case 'workspace-init':
|
|
1892
|
+
result = this.workspaceInit(args);
|
|
1893
|
+
break;
|
|
1894
|
+
case 'workspace-sync':
|
|
1895
|
+
result = await this.workspaceSync(args);
|
|
1896
|
+
break;
|
|
1897
|
+
case 'workspace-exec':
|
|
1898
|
+
result = this.workspaceExec(args);
|
|
1899
|
+
break;
|
|
1900
|
+
case 'workspace-upload':
|
|
1901
|
+
result = await this.workspaceUpload(args);
|
|
1902
|
+
break;
|
|
1903
|
+
default:
|
|
1904
|
+
result = `Unknown tool: ${toolName}`;
|
|
1905
|
+
}
|
|
1906
|
+
this.wsSend({
|
|
1907
|
+
type: 'agent-tool-exec-result',
|
|
1908
|
+
toolId,
|
|
1909
|
+
sessionId,
|
|
1910
|
+
result,
|
|
1911
|
+
durationMs: Date.now() - startMs,
|
|
1912
|
+
});
|
|
1913
|
+
}
|
|
1914
|
+
catch (err) {
|
|
1915
|
+
this.wsSend({
|
|
1916
|
+
type: 'agent-tool-exec-result',
|
|
1917
|
+
toolId,
|
|
1918
|
+
sessionId,
|
|
1919
|
+
result: '',
|
|
1920
|
+
error: err.message || String(err),
|
|
1921
|
+
durationMs: Date.now() - startMs,
|
|
1922
|
+
});
|
|
1923
|
+
}
|
|
1924
|
+
}
|
|
1925
|
+
agentRead(args) {
|
|
1926
|
+
const filePath = args.path;
|
|
1927
|
+
if (!filePath)
|
|
1928
|
+
return 'Error: path is required';
|
|
1929
|
+
if (Relay.isSensitivePath(filePath))
|
|
1930
|
+
return 'Error: refusing to read protected path';
|
|
1931
|
+
if (!existsSync(filePath))
|
|
1932
|
+
return `File not found: ${filePath}`;
|
|
1933
|
+
const stat = statSync(filePath);
|
|
1934
|
+
if (stat.isDirectory())
|
|
1935
|
+
return `Error: ${filePath} is a directory, not a file`;
|
|
1936
|
+
if (stat.size > 5 * 1024 * 1024)
|
|
1937
|
+
return `Error: File too large (${Math.round(stat.size / 1024)}KB). Max 5MB.`;
|
|
1938
|
+
const text = readFileSync(filePath, 'utf8');
|
|
1939
|
+
const lines = text.split('\n');
|
|
1940
|
+
const offset = Math.max(0, (args.offset || 1) - 1);
|
|
1941
|
+
const limit = args.limit || lines.length;
|
|
1942
|
+
const slice = lines.slice(offset, offset + limit);
|
|
1943
|
+
const numbered = slice
|
|
1944
|
+
.map((line, i) => {
|
|
1945
|
+
const lineNum = String(offset + i + 1).padStart(6, ' ');
|
|
1946
|
+
return `${lineNum}\t${line}`;
|
|
1947
|
+
})
|
|
1948
|
+
.join('\n');
|
|
1949
|
+
return numbered.length > 50_000 ? numbered.slice(0, 50_000) + '\n... (truncated)' : numbered;
|
|
1950
|
+
}
|
|
1951
|
+
agentWrite(args) {
|
|
1952
|
+
const filePath = args.path;
|
|
1953
|
+
const content = args.content;
|
|
1954
|
+
if (!filePath || content === undefined)
|
|
1955
|
+
return 'Error: path and content are required';
|
|
1956
|
+
if (Relay.isSensitivePath(filePath))
|
|
1957
|
+
return 'Error: refusing to write protected path';
|
|
1958
|
+
const dir = dirname(filePath);
|
|
1959
|
+
if (!existsSync(dir))
|
|
1960
|
+
mkdirSync(dir, { recursive: true });
|
|
1961
|
+
writeFileSync(filePath, content, 'utf8');
|
|
1962
|
+
return `File written: ${filePath} (${content.length} bytes)`;
|
|
1963
|
+
}
|
|
1964
|
+
agentEdit(args) {
|
|
1965
|
+
const filePath = args.path;
|
|
1966
|
+
const oldStr = args.old_string;
|
|
1967
|
+
const newStr = args.new_string;
|
|
1968
|
+
if (!filePath || !oldStr || newStr === undefined)
|
|
1969
|
+
return 'Error: path, old_string, and new_string are required';
|
|
1970
|
+
if (Relay.isSensitivePath(filePath))
|
|
1971
|
+
return 'Error: refusing to edit protected path';
|
|
1972
|
+
if (!existsSync(filePath))
|
|
1973
|
+
return `File not found: ${filePath}`;
|
|
1974
|
+
const text = readFileSync(filePath, 'utf8');
|
|
1975
|
+
const occurrences = text.split(oldStr).length - 1;
|
|
1976
|
+
if (occurrences === 0)
|
|
1977
|
+
return `Error: old_string not found in ${filePath}`;
|
|
1978
|
+
if (occurrences > 1)
|
|
1979
|
+
return `Error: old_string appears ${occurrences} times — must be unique`;
|
|
1980
|
+
const newText = text.replace(oldStr, newStr);
|
|
1981
|
+
writeFileSync(filePath, newText, 'utf8');
|
|
1982
|
+
return `File edited: ${filePath} (1 replacement)`;
|
|
1983
|
+
}
|
|
1984
|
+
agentGlob(args) {
|
|
1985
|
+
let pattern = args.pattern || args.path;
|
|
1986
|
+
if (!pattern)
|
|
1987
|
+
return 'Error: pattern is required';
|
|
1988
|
+
// Normalize double slashes (e.g. /home/user//* → /home/user/*)
|
|
1989
|
+
pattern = pattern.replace(/\/\//g, '/');
|
|
1990
|
+
const globStart = pattern.search(/[*?{[]/);
|
|
1991
|
+
const baseDir = globStart >= 0 ? pattern.slice(0, pattern.lastIndexOf('/', globStart) + 1) || '.' : pattern;
|
|
1992
|
+
if (!existsSync(baseDir))
|
|
1993
|
+
throw new Error(`Directory not found: ${baseDir}`);
|
|
1994
|
+
const matches = [];
|
|
1995
|
+
const globRegex = this.patternToRegex(pattern);
|
|
1996
|
+
this.walkDir(baseDir, matches, globRegex, 500, 8);
|
|
1997
|
+
return matches.length > 0
|
|
1998
|
+
? `Found ${matches.length} files:\n${matches.join('\n')}`
|
|
1999
|
+
: `No files matching "${pattern}"`;
|
|
2000
|
+
}
|
|
2001
|
+
agentLs(args) {
|
|
2002
|
+
const dirPath = args.path || homedir();
|
|
2003
|
+
if (!existsSync(dirPath))
|
|
2004
|
+
throw new Error(`Directory not found: ${dirPath}`);
|
|
2005
|
+
const stat = statSync(dirPath);
|
|
2006
|
+
if (!stat.isDirectory())
|
|
2007
|
+
throw new Error(`Not a directory: ${dirPath}`);
|
|
2008
|
+
try {
|
|
2009
|
+
const entries = readdirSync(dirPath, { withFileTypes: true });
|
|
2010
|
+
const lines = [];
|
|
2011
|
+
for (const entry of entries) {
|
|
2012
|
+
if (entry.name.startsWith('.'))
|
|
2013
|
+
continue; // skip hidden files
|
|
2014
|
+
const fullPath = join(dirPath, entry.name);
|
|
2015
|
+
if (entry.isDirectory()) {
|
|
2016
|
+
lines.push(`${entry.name}/`);
|
|
2017
|
+
}
|
|
2018
|
+
else {
|
|
2019
|
+
try {
|
|
2020
|
+
const fstat = statSync(fullPath);
|
|
2021
|
+
const sizeStr = fstat.size > 1024 ? `${Math.round(fstat.size / 1024)}KB` : `${fstat.size}B`;
|
|
2022
|
+
lines.push(`${entry.name} (${sizeStr})`);
|
|
2023
|
+
}
|
|
2024
|
+
catch {
|
|
2025
|
+
lines.push(entry.name);
|
|
2026
|
+
}
|
|
2027
|
+
}
|
|
2028
|
+
}
|
|
2029
|
+
if (lines.length === 0)
|
|
2030
|
+
return `Empty directory: ${dirPath}`;
|
|
2031
|
+
return lines.join('\n');
|
|
2032
|
+
}
|
|
2033
|
+
catch (err) {
|
|
2034
|
+
throw new Error(`Failed to read directory ${dirPath}: ${err.message}`);
|
|
2035
|
+
}
|
|
2036
|
+
}
|
|
2037
|
+
agentGrep(args) {
|
|
2038
|
+
const pattern = args.pattern;
|
|
2039
|
+
const searchPath = args.path || homedir();
|
|
2040
|
+
if (!pattern)
|
|
2041
|
+
return 'Error: pattern is required';
|
|
2042
|
+
try {
|
|
2043
|
+
const output = execFileSync('grep', ['-rn', '--include=*', pattern, searchPath], {
|
|
2044
|
+
cwd: searchPath,
|
|
2045
|
+
timeout: 30_000,
|
|
2046
|
+
maxBuffer: 1024 * 1024,
|
|
2047
|
+
encoding: 'utf8',
|
|
2048
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
2049
|
+
});
|
|
2050
|
+
return output.length > 50_000 ? output.slice(0, 50_000) + '\n... (truncated)' : output;
|
|
2051
|
+
}
|
|
2052
|
+
catch (err) {
|
|
2053
|
+
// grep returns exit code 1 when no matches found
|
|
2054
|
+
if (err.status === 1)
|
|
2055
|
+
return `No matches for "${pattern}" in ${searchPath}`;
|
|
2056
|
+
const stderr = err.stderr ? String(err.stderr).slice(0, 2000) : '';
|
|
2057
|
+
return stderr || err.message || 'grep failed';
|
|
2058
|
+
}
|
|
2059
|
+
}
|
|
2060
|
+
agentBash(args) {
|
|
2061
|
+
const command = args.command;
|
|
2062
|
+
if (!command)
|
|
2063
|
+
return 'Error: command is required';
|
|
2064
|
+
let cwd = args.cwd || homedir();
|
|
2065
|
+
// Resolve virtual /local/ prefix from cloud agent
|
|
2066
|
+
if (cwd.startsWith('/local/')) {
|
|
2067
|
+
cwd = cwd.slice('/local'.length); // /local/Users/foo → /Users/foo
|
|
2068
|
+
}
|
|
2069
|
+
// If path doesn't exist, try resolving relative to $HOME
|
|
2070
|
+
// (handles /Documents → ~/Documents, /projects → ~/projects, etc.)
|
|
2071
|
+
if (!existsSync(cwd) && cwd.startsWith('/')) {
|
|
2072
|
+
const asHome = join(homedir(), cwd);
|
|
2073
|
+
if (existsSync(asHome)) {
|
|
2074
|
+
cwd = asHome;
|
|
2075
|
+
}
|
|
2076
|
+
}
|
|
2077
|
+
if (!existsSync(cwd)) {
|
|
2078
|
+
return `Error: working directory does not exist: ${cwd} (also tried ${join(homedir(), cwd)})`;
|
|
2079
|
+
}
|
|
2080
|
+
const timeout = Math.min(args.timeout || 30_000, 120_000);
|
|
2081
|
+
try {
|
|
2082
|
+
const output = execSync(command, {
|
|
2083
|
+
cwd,
|
|
2084
|
+
timeout,
|
|
2085
|
+
maxBuffer: 1024 * 1024,
|
|
2086
|
+
encoding: 'utf8',
|
|
2087
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
2088
|
+
env: { ...process.env, TERM: 'dumb' },
|
|
2089
|
+
});
|
|
2090
|
+
return output.length > 50_000 ? output.slice(0, 50_000) + '\n... (truncated)' : output;
|
|
2091
|
+
}
|
|
2092
|
+
catch (err) {
|
|
2093
|
+
const stderr = err.stderr ? String(err.stderr).slice(0, 2000) : '';
|
|
2094
|
+
const stdout = err.stdout ? String(err.stdout).slice(0, 2000) : '';
|
|
2095
|
+
const output = stdout || stderr || err.message || 'Command failed';
|
|
2096
|
+
return `Exit code ${err.status || 1}\n${output}`;
|
|
2097
|
+
}
|
|
2098
|
+
}
|
|
2099
|
+
// ── PTY Terminal ────────────────────────────────────────────────────
|
|
2100
|
+
/** Mark a managed PTY ready (its shell integration is live) and flush any input typed before then. */
|
|
2101
|
+
_markPtyReady(sessionId) {
|
|
2102
|
+
if (this.ptyReady.has(sessionId))
|
|
2103
|
+
return;
|
|
2104
|
+
this.ptyReady.add(sessionId);
|
|
2105
|
+
const timer = this.ptyReadyTimers.get(sessionId);
|
|
2106
|
+
if (timer) {
|
|
2107
|
+
clearTimeout(timer);
|
|
2108
|
+
this.ptyReadyTimers.delete(sessionId);
|
|
2109
|
+
}
|
|
2110
|
+
const queued = this.ptyInputQueue.get(sessionId);
|
|
2111
|
+
this.ptyInputQueue.delete(sessionId);
|
|
2112
|
+
const pty = this.ptySessions.get(sessionId);
|
|
2113
|
+
if (pty && queued)
|
|
2114
|
+
for (const d of queued) {
|
|
2115
|
+
try {
|
|
2116
|
+
pty.write(d);
|
|
2117
|
+
}
|
|
2118
|
+
catch {
|
|
2119
|
+
/* ignore */
|
|
2120
|
+
}
|
|
2121
|
+
}
|
|
2122
|
+
}
|
|
2123
|
+
/** Drop a PTY's readiness / queue / timer state (on kill or exit). */
|
|
2124
|
+
_cleanupPtyReady(sessionId) {
|
|
2125
|
+
const timer = this.ptyReadyTimers.get(sessionId);
|
|
2126
|
+
if (timer)
|
|
2127
|
+
clearTimeout(timer);
|
|
2128
|
+
this.ptyReadyTimers.delete(sessionId);
|
|
2129
|
+
this.ptyInputQueue.delete(sessionId);
|
|
2130
|
+
this.ptyReady.delete(sessionId);
|
|
2131
|
+
}
|
|
2132
|
+
/** Spawn a PTY session with node-pty (optional dependency) */
|
|
2133
|
+
async ptyStart(sessionId, shell, cols, rows, cwd) {
|
|
2134
|
+
try {
|
|
2135
|
+
// @ts-ignore — optional native dependency
|
|
2136
|
+
const pty = await import('node-pty');
|
|
2137
|
+
// Resolve virtual /local/ prefix from cloud agent
|
|
2138
|
+
let resolvedCwd = cwd;
|
|
2139
|
+
if (resolvedCwd?.startsWith('/local/')) {
|
|
2140
|
+
resolvedCwd = resolvedCwd.slice('/local'.length);
|
|
2141
|
+
}
|
|
2142
|
+
// Try resolving relative to $HOME if absolute path doesn't exist
|
|
2143
|
+
if (resolvedCwd && !existsSync(resolvedCwd) && resolvedCwd.startsWith('/')) {
|
|
2144
|
+
const asHome = join(homedir(), resolvedCwd);
|
|
2145
|
+
if (existsSync(asHome))
|
|
2146
|
+
resolvedCwd = asHome;
|
|
2147
|
+
}
|
|
2148
|
+
if (!resolvedCwd || !existsSync(resolvedCwd)) {
|
|
2149
|
+
resolvedCwd = homedir();
|
|
2150
|
+
}
|
|
2151
|
+
// Filter sensitive environment variables before passing to PTY
|
|
2152
|
+
const filteredEnv = { ...process.env };
|
|
2153
|
+
for (const key of Object.keys(filteredEnv)) {
|
|
2154
|
+
if (SENSITIVE_ENV_PATTERNS.includes(key) ||
|
|
2155
|
+
SENSITIVE_ENV_SUFFIXES.some((s) => key.endsWith(s))) {
|
|
2156
|
+
delete filteredEnv[key];
|
|
2157
|
+
}
|
|
2158
|
+
}
|
|
2159
|
+
// Inject OSC-633 shell integration (sources the user's real rc first,
|
|
2160
|
+
// then adds prompt markers) so we can slice the stream into blocks.
|
|
2161
|
+
const integ = writeIntegration(sessionId, shell);
|
|
2162
|
+
// A canvas prompt-bar shell session is `prompt-shell-<compositionId>`. Expose that
|
|
2163
|
+
// composition id to the PTY so an interactive `claude` launched inside it inherits
|
|
2164
|
+
// CLEARLY_CANVAS_COMPOSITION — its UserPromptSubmit/PostToolUse/Stop hooks then post
|
|
2165
|
+
// prompt→tool→response blocks onto THIS composition (and stay silent elsewhere).
|
|
2166
|
+
const canvasComp = sessionId.startsWith('prompt-shell-')
|
|
2167
|
+
? sessionId.slice('prompt-shell-'.length)
|
|
2168
|
+
: '';
|
|
2169
|
+
const ptyProcess = pty.spawn(shell, integ.args, {
|
|
2170
|
+
name: 'xterm-256color',
|
|
2171
|
+
cols,
|
|
2172
|
+
rows,
|
|
2173
|
+
cwd: resolvedCwd,
|
|
2174
|
+
env: {
|
|
2175
|
+
...filteredEnv,
|
|
2176
|
+
...integ.env,
|
|
2177
|
+
...(canvasComp && canvasComp !== 'scratch'
|
|
2178
|
+
? { CLEARLY_CANVAS_COMPOSITION: canvasComp }
|
|
2179
|
+
: {}),
|
|
2180
|
+
},
|
|
2181
|
+
});
|
|
2182
|
+
this.ptySessions.set(sessionId, ptyProcess);
|
|
2183
|
+
// Block parser — fed the SAME bytes as pty-output (it never consumes them,
|
|
2184
|
+
// so the live xterm is unaffected); emits a terminal-block per completed
|
|
2185
|
+
// command, which the DO mints as a canvas frame.
|
|
2186
|
+
const parser = new TerminalBlockParser((block) => {
|
|
2187
|
+
this.wsSend({ type: 'terminal-block', sessionId, ...block });
|
|
2188
|
+
});
|
|
2189
|
+
this.ptyParsers.set(sessionId, parser);
|
|
2190
|
+
// Buffer input until the OSC-633 integration prints its first prompt (marker A/B) — the first
|
|
2191
|
+
// command otherwise races the shell's rc-sourcing and runs with no markers → no terminal-block.
|
|
2192
|
+
// Fallback: flush after a grace period if no marker ever arrives (integration absent).
|
|
2193
|
+
this.ptyInputQueue.set(sessionId, []);
|
|
2194
|
+
this.ptyReady.delete(sessionId);
|
|
2195
|
+
this.ptyReadyTimers.set(sessionId, setTimeout(() => this._markPtyReady(sessionId), 2500));
|
|
2196
|
+
ptyProcess.onData((data) => {
|
|
2197
|
+
this.wsSend({ type: 'pty-output', sessionId, data });
|
|
2198
|
+
this.onPtyOutput?.({ type: 'pty-output', sessionId, data });
|
|
2199
|
+
try {
|
|
2200
|
+
parser.write(data);
|
|
2201
|
+
}
|
|
2202
|
+
catch {
|
|
2203
|
+
/* parsing must never break the live stream */
|
|
2204
|
+
}
|
|
2205
|
+
if (!this.ptyReady.has(sessionId) && /\x1b\]633;[AB]/.test(data))
|
|
2206
|
+
this._markPtyReady(sessionId);
|
|
2207
|
+
});
|
|
2208
|
+
ptyProcess.onExit(({ exitCode }) => {
|
|
2209
|
+
this.ptySessions.delete(sessionId);
|
|
2210
|
+
this.ptyParsers.delete(sessionId);
|
|
2211
|
+
this._cleanupPtyReady(sessionId);
|
|
2212
|
+
cleanupIntegration(sessionId);
|
|
2213
|
+
this.wsSend({ type: 'pty-exit', sessionId, exitCode });
|
|
2214
|
+
this.onPtyOutput?.({ type: 'pty-exit', sessionId, exitCode });
|
|
2215
|
+
});
|
|
2216
|
+
console.log(`[relay] PTY started: ${sessionId} (${shell}, ${cols}x${rows}, ${cwd})`);
|
|
2217
|
+
}
|
|
2218
|
+
catch (err) {
|
|
2219
|
+
console.error(`[relay] PTY start failed: ${err.message}`);
|
|
2220
|
+
this.wsSend({ type: 'pty-exit', sessionId, exitCode: 1 });
|
|
2221
|
+
}
|
|
2222
|
+
}
|
|
2223
|
+
// ── Workspace Sync (Tier 2) ─────────────────────────────────────────
|
|
2224
|
+
/** Workspace state — maps workspaceId to temp dir path */
|
|
2225
|
+
workspaces = new Map();
|
|
2226
|
+
/** Auto-clean stale workspaces every 5 minutes */
|
|
2227
|
+
cleanupWorkspacesIfNeeded() {
|
|
2228
|
+
const now = Date.now();
|
|
2229
|
+
for (const [id, ws] of this.workspaces) {
|
|
2230
|
+
if (now - ws.lastUsed > 10 * 60_000) {
|
|
2231
|
+
// Clean up temp dir
|
|
2232
|
+
try {
|
|
2233
|
+
rmSync(ws.path, { recursive: true, force: true });
|
|
2234
|
+
}
|
|
2235
|
+
catch {
|
|
2236
|
+
/* ignore */
|
|
2237
|
+
}
|
|
2238
|
+
this.workspaces.delete(id);
|
|
2239
|
+
}
|
|
2240
|
+
}
|
|
2241
|
+
}
|
|
2242
|
+
workspaceInit(args) {
|
|
2243
|
+
const workspaceId = String(args.workspaceId ?? '').replace(/[^a-zA-Z0-9_-]/g, '');
|
|
2244
|
+
if (!workspaceId)
|
|
2245
|
+
return JSON.stringify({
|
|
2246
|
+
error: 'workspaceId is required (alphanumeric, hyphens, underscores only)',
|
|
2247
|
+
});
|
|
2248
|
+
this.cleanupWorkspacesIfNeeded();
|
|
2249
|
+
const existing = this.workspaces.get(workspaceId);
|
|
2250
|
+
if (existing && existsSync(existing.path)) {
|
|
2251
|
+
existing.lastUsed = Date.now();
|
|
2252
|
+
// Scan existing files for manifest
|
|
2253
|
+
const files = this.scanWorkspaceFiles(existing.path);
|
|
2254
|
+
return JSON.stringify({ path: existing.path, existingFiles: files });
|
|
2255
|
+
}
|
|
2256
|
+
// Create new workspace
|
|
2257
|
+
const tmpDir = join(homedir(), '.beehaven', 'workspaces', workspaceId);
|
|
2258
|
+
mkdirSync(tmpDir, { recursive: true });
|
|
2259
|
+
this.workspaces.set(workspaceId, { path: tmpDir, lastUsed: Date.now() });
|
|
2260
|
+
return JSON.stringify({ path: tmpDir, existingFiles: [] });
|
|
2261
|
+
}
|
|
2262
|
+
async workspaceSync(args) {
|
|
2263
|
+
const workspaceId = args.workspaceId;
|
|
2264
|
+
const files = args.files;
|
|
2265
|
+
if (!workspaceId || !files)
|
|
2266
|
+
return JSON.stringify({ synced: 0, errors: ['Missing args'] });
|
|
2267
|
+
const ws = this.workspaces.get(workspaceId);
|
|
2268
|
+
if (!ws)
|
|
2269
|
+
return JSON.stringify({ synced: 0, errors: ['Workspace not found'] });
|
|
2270
|
+
ws.lastUsed = Date.now();
|
|
2271
|
+
let synced = 0;
|
|
2272
|
+
const errors = [];
|
|
2273
|
+
// Download files in parallel (max 5 concurrent)
|
|
2274
|
+
const CONCURRENCY = 5;
|
|
2275
|
+
for (let i = 0; i < files.length; i += CONCURRENCY) {
|
|
2276
|
+
const batch = files.slice(i, i + CONCURRENCY);
|
|
2277
|
+
await Promise.all(batch.map(async (f) => {
|
|
2278
|
+
try {
|
|
2279
|
+
// H1: Reject path traversal
|
|
2280
|
+
if (!f.relativePath ||
|
|
2281
|
+
f.relativePath.includes('..') ||
|
|
2282
|
+
f.relativePath.startsWith('/')) {
|
|
2283
|
+
errors.push(`${f.relativePath}: rejected (path traversal)`);
|
|
2284
|
+
return;
|
|
2285
|
+
}
|
|
2286
|
+
// H4: Reject non-HTTPS download URLs and internal IPs
|
|
2287
|
+
try {
|
|
2288
|
+
const dlUrl = new URL(f.downloadUrl);
|
|
2289
|
+
if (dlUrl.protocol !== 'https:') {
|
|
2290
|
+
errors.push(`${f.relativePath}: rejected (non-HTTPS URL)`);
|
|
2291
|
+
return;
|
|
2292
|
+
}
|
|
2293
|
+
if (/^(127\.|10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.|169\.254\.|0\.)/.test(dlUrl.hostname) ||
|
|
2294
|
+
dlUrl.hostname === 'localhost') {
|
|
2295
|
+
errors.push(`${f.relativePath}: rejected (internal URL)`);
|
|
2296
|
+
return;
|
|
2297
|
+
}
|
|
2298
|
+
}
|
|
2299
|
+
catch {
|
|
2300
|
+
errors.push(`${f.relativePath}: rejected (invalid URL)`);
|
|
2301
|
+
return;
|
|
2302
|
+
}
|
|
2303
|
+
const resp = await fetch(f.downloadUrl);
|
|
2304
|
+
if (!resp.ok) {
|
|
2305
|
+
errors.push(`${f.relativePath}: HTTP ${resp.status}`);
|
|
2306
|
+
return;
|
|
2307
|
+
}
|
|
2308
|
+
const buffer = Buffer.from(await resp.arrayBuffer());
|
|
2309
|
+
const filePath = join(ws.path, f.relativePath);
|
|
2310
|
+
/**
|
|
2311
|
+
* ⚠⚠ THIS CONTAINMENT CHECK WAS `require('path').resolve(...)` AND THREW EVERY TIME.
|
|
2312
|
+
* The package is ESM, so `require` is not defined; the throw was caught by this loop's
|
|
2313
|
+
* own `catch` and reported as a per-file error, meaning `workspaceSync` has never
|
|
2314
|
+
* written a single file. It failed CLOSED — the write is below this line — so the cost
|
|
2315
|
+
* was a dead feature rather than an escape. Same trap as the IPC socket chmod.
|
|
2316
|
+
*
|
|
2317
|
+
* ⚠ AND THE COMPARISON WAS A BARE PREFIX MATCH. `startsWith(ws.path)` accepts a sibling
|
|
2318
|
+
* whose name merely begins the same way — a workspace at `/w/app` would admit writes
|
|
2319
|
+
* resolved into `/w/app-evil`. Comparing against the path plus a separator is what
|
|
2320
|
+
* makes it a DIRECTORY test rather than a string test.
|
|
2321
|
+
*/
|
|
2322
|
+
const root = resolve(ws.path);
|
|
2323
|
+
const resolved = resolve(filePath);
|
|
2324
|
+
if (resolved !== root && !resolved.startsWith(root + sep)) {
|
|
2325
|
+
errors.push(`${f.relativePath}: rejected (path escape)`);
|
|
2326
|
+
return;
|
|
2327
|
+
}
|
|
2328
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
2329
|
+
writeFileSync(filePath, buffer);
|
|
2330
|
+
synced++;
|
|
2331
|
+
}
|
|
2332
|
+
catch (err) {
|
|
2333
|
+
errors.push(`${f.relativePath}: ${err.message}`);
|
|
2334
|
+
}
|
|
2335
|
+
}));
|
|
2336
|
+
}
|
|
2337
|
+
return JSON.stringify({ synced, errors });
|
|
2338
|
+
}
|
|
2339
|
+
workspaceExec(args) {
|
|
2340
|
+
const workspaceId = args.workspaceId;
|
|
2341
|
+
const command = args.command;
|
|
2342
|
+
const cwdRelative = args.cwd || '.';
|
|
2343
|
+
const timeout = Math.min(args.timeout || 60_000, 120_000);
|
|
2344
|
+
const extraEnv = args.env || {};
|
|
2345
|
+
const ws = this.workspaces.get(workspaceId);
|
|
2346
|
+
if (!ws)
|
|
2347
|
+
return JSON.stringify({
|
|
2348
|
+
stdout: '',
|
|
2349
|
+
stderr: 'Workspace not found',
|
|
2350
|
+
exitCode: 1,
|
|
2351
|
+
changedFiles: [],
|
|
2352
|
+
});
|
|
2353
|
+
ws.lastUsed = Date.now();
|
|
2354
|
+
const cwd = join(ws.path, cwdRelative);
|
|
2355
|
+
if (!existsSync(cwd))
|
|
2356
|
+
mkdirSync(cwd, { recursive: true });
|
|
2357
|
+
// Snapshot file state before execution
|
|
2358
|
+
const filesBefore = this.scanWorkspaceFiles(ws.path);
|
|
2359
|
+
const beforeMap = new Map(filesBefore.map((f) => [f.relativePath, f.size]));
|
|
2360
|
+
let stdout = '';
|
|
2361
|
+
let stderr = '';
|
|
2362
|
+
let exitCode = 0;
|
|
2363
|
+
try {
|
|
2364
|
+
stdout = execSync(command, {
|
|
2365
|
+
cwd,
|
|
2366
|
+
timeout,
|
|
2367
|
+
maxBuffer: 2 * 1024 * 1024, // 2MB for workspace commands
|
|
2368
|
+
encoding: 'utf8',
|
|
2369
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
2370
|
+
env: { ...process.env, ...extraEnv, TERM: 'dumb' },
|
|
2371
|
+
});
|
|
2372
|
+
if (stdout.length > 50_000)
|
|
2373
|
+
stdout = stdout.slice(0, 50_000) + '\n... (truncated)';
|
|
2374
|
+
}
|
|
2375
|
+
catch (err) {
|
|
2376
|
+
exitCode = err.status || 1;
|
|
2377
|
+
stderr =
|
|
2378
|
+
(err.stderr ? String(err.stderr).slice(0, 5000) : '') ||
|
|
2379
|
+
(err.stdout ? String(err.stdout).slice(0, 5000) : '') ||
|
|
2380
|
+
err.message ||
|
|
2381
|
+
'Command failed';
|
|
2382
|
+
stdout = err.stdout ? String(err.stdout).slice(0, 50_000) : '';
|
|
2383
|
+
}
|
|
2384
|
+
// Diff filesystem to find changed files
|
|
2385
|
+
const filesAfter = this.scanWorkspaceFiles(ws.path);
|
|
2386
|
+
const changedFiles = [];
|
|
2387
|
+
for (const f of filesAfter) {
|
|
2388
|
+
const beforeSize = beforeMap.get(f.relativePath);
|
|
2389
|
+
if (beforeSize === undefined || beforeSize !== f.size) {
|
|
2390
|
+
changedFiles.push(f);
|
|
2391
|
+
}
|
|
2392
|
+
}
|
|
2393
|
+
return JSON.stringify({ stdout, stderr, exitCode, changedFiles });
|
|
2394
|
+
}
|
|
2395
|
+
async workspaceUpload(args) {
|
|
2396
|
+
const workspaceId = args.workspaceId;
|
|
2397
|
+
const files = args.files;
|
|
2398
|
+
if (!workspaceId || !files)
|
|
2399
|
+
return JSON.stringify({ uploaded: 0, errors: ['Missing args'] });
|
|
2400
|
+
const ws = this.workspaces.get(workspaceId);
|
|
2401
|
+
if (!ws)
|
|
2402
|
+
return JSON.stringify({ uploaded: 0, errors: ['Workspace not found'] });
|
|
2403
|
+
ws.lastUsed = Date.now();
|
|
2404
|
+
let uploaded = 0;
|
|
2405
|
+
const errors = [];
|
|
2406
|
+
const CONCURRENCY = 5;
|
|
2407
|
+
for (let i = 0; i < files.length; i += CONCURRENCY) {
|
|
2408
|
+
const batch = files.slice(i, i + CONCURRENCY);
|
|
2409
|
+
await Promise.all(batch.map(async (f) => {
|
|
2410
|
+
try {
|
|
2411
|
+
const filePath = join(ws.path, f.relativePath);
|
|
2412
|
+
if (!existsSync(filePath)) {
|
|
2413
|
+
errors.push(`${f.relativePath}: not found`);
|
|
2414
|
+
return;
|
|
2415
|
+
}
|
|
2416
|
+
const content = readFileSync(filePath);
|
|
2417
|
+
const resp = await fetch(f.uploadUrl, {
|
|
2418
|
+
method: 'PUT',
|
|
2419
|
+
body: content,
|
|
2420
|
+
headers: { 'Content-Type': 'application/octet-stream' },
|
|
2421
|
+
});
|
|
2422
|
+
if (!resp.ok) {
|
|
2423
|
+
errors.push(`${f.relativePath}: upload HTTP ${resp.status}`);
|
|
2424
|
+
return;
|
|
2425
|
+
}
|
|
2426
|
+
uploaded++;
|
|
2427
|
+
}
|
|
2428
|
+
catch (err) {
|
|
2429
|
+
errors.push(`${f.relativePath}: ${err.message}`);
|
|
2430
|
+
}
|
|
2431
|
+
}));
|
|
2432
|
+
}
|
|
2433
|
+
return JSON.stringify({ uploaded, errors });
|
|
2434
|
+
}
|
|
2435
|
+
/** Scan all files in workspace, returning relative paths and sizes */
|
|
2436
|
+
scanWorkspaceFiles(rootDir, maxFiles = 500) {
|
|
2437
|
+
const files = [];
|
|
2438
|
+
const walk = (dir, depth) => {
|
|
2439
|
+
if (depth > 10 || files.length >= maxFiles)
|
|
2440
|
+
return;
|
|
2441
|
+
try {
|
|
2442
|
+
const entries = readdirSync(dir, { withFileTypes: true });
|
|
2443
|
+
for (const entry of entries) {
|
|
2444
|
+
if (files.length >= maxFiles)
|
|
2445
|
+
return;
|
|
2446
|
+
if (entry.name === 'node_modules' || entry.name === '.git')
|
|
2447
|
+
continue;
|
|
2448
|
+
const fullPath = join(dir, entry.name);
|
|
2449
|
+
if (entry.isDirectory()) {
|
|
2450
|
+
walk(fullPath, depth + 1);
|
|
2451
|
+
}
|
|
2452
|
+
else {
|
|
2453
|
+
const stat = statSync(fullPath);
|
|
2454
|
+
const relativePath = fullPath.slice(rootDir.length + 1);
|
|
2455
|
+
files.push({ relativePath, size: stat.size });
|
|
2456
|
+
}
|
|
2457
|
+
}
|
|
2458
|
+
}
|
|
2459
|
+
catch {
|
|
2460
|
+
/* permission denied */
|
|
2461
|
+
}
|
|
2462
|
+
};
|
|
2463
|
+
walk(rootDir, 0);
|
|
2464
|
+
return files;
|
|
2465
|
+
}
|
|
2466
|
+
/** Walk a directory recursively, collecting paths that match the regex */
|
|
2467
|
+
walkDir(dir, results, regex, maxResults, maxDepth, depth = 0) {
|
|
2468
|
+
if (depth > maxDepth || results.length >= maxResults)
|
|
2469
|
+
return;
|
|
2470
|
+
try {
|
|
2471
|
+
const entries = readdirSync(dir, { withFileTypes: true });
|
|
2472
|
+
for (const entry of entries) {
|
|
2473
|
+
if (results.length >= maxResults)
|
|
2474
|
+
return;
|
|
2475
|
+
if (entry.name.startsWith('.') || entry.name === 'node_modules')
|
|
2476
|
+
continue;
|
|
2477
|
+
const fullPath = join(dir, entry.name);
|
|
2478
|
+
if (entry.isDirectory()) {
|
|
2479
|
+
this.walkDir(fullPath, results, regex, maxResults, maxDepth, depth + 1);
|
|
2480
|
+
}
|
|
2481
|
+
else if (regex.test(fullPath)) {
|
|
2482
|
+
results.push(fullPath);
|
|
2483
|
+
}
|
|
2484
|
+
}
|
|
2485
|
+
}
|
|
2486
|
+
catch {
|
|
2487
|
+
/* permission denied or similar */
|
|
2488
|
+
}
|
|
2489
|
+
}
|
|
2490
|
+
/** Convert a glob pattern to a regex */
|
|
2491
|
+
patternToRegex(pattern) {
|
|
2492
|
+
const escaped = pattern
|
|
2493
|
+
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
|
2494
|
+
.replace(/\*\*/g, '{{GLOBSTAR}}')
|
|
2495
|
+
.replace(/\*/g, '[^/]*')
|
|
2496
|
+
.replace(/\?/g, '[^/]')
|
|
2497
|
+
.replace(/\{\{GLOBSTAR\}\}/g, '.*');
|
|
2498
|
+
return new RegExp(`^${escaped}$`);
|
|
2499
|
+
}
|
|
2500
|
+
async syncFileDelete(brandId, relativePath) {
|
|
2501
|
+
if (!this.config || !this.connected)
|
|
2502
|
+
return;
|
|
2503
|
+
try {
|
|
2504
|
+
const msg = { type: 'file-sync', brandId, action: 'delete', file: { path: relativePath } };
|
|
2505
|
+
if (this.useWebSocket) {
|
|
2506
|
+
this.wsSend(msg);
|
|
2507
|
+
}
|
|
2508
|
+
else {
|
|
2509
|
+
await this.postHttp(msg);
|
|
2510
|
+
}
|
|
2511
|
+
}
|
|
2512
|
+
catch (err) {
|
|
2513
|
+
console.error(`[relay] File delete failed (${relativePath}): ${err.message}`);
|
|
2514
|
+
}
|
|
2515
|
+
}
|
|
2516
|
+
async updateBrandSync(brandId, data) {
|
|
2517
|
+
if (!this.config)
|
|
2518
|
+
return;
|
|
2519
|
+
try {
|
|
2520
|
+
const msg = { type: 'brand-sync', brandId, ...data };
|
|
2521
|
+
if (this.useWebSocket) {
|
|
2522
|
+
this.wsSend(msg);
|
|
2523
|
+
}
|
|
2524
|
+
else {
|
|
2525
|
+
await this.postHttp(msg);
|
|
2526
|
+
}
|
|
2527
|
+
}
|
|
2528
|
+
catch (err) {
|
|
2529
|
+
console.error(`[relay] Brand sync update failed: ${err.message}`);
|
|
2530
|
+
}
|
|
2531
|
+
}
|
|
2532
|
+
/**
|
|
2533
|
+
* ⚠⚠ `project-list`, NOT `list-projects` — THE LATTER IS NOT A REGISTERED ACTION.
|
|
2534
|
+
*
|
|
2535
|
+
* Measured 2026-09-04: `beehaven link` answered *"No projects found in your Clearly account.
|
|
2536
|
+
* Create a project at https://clearly.sh first."* against a workspace holding **50 live
|
|
2537
|
+
* projects**. The daemon was answering *"Unknown action: list-projects. Did you mean: … "* the
|
|
2538
|
+
* whole time, and the bare `catch { return null }` below turned that into an empty list, which
|
|
2539
|
+
* `link` reports as "you have none".
|
|
2540
|
+
*
|
|
2541
|
+
* ⚠ THIRD INSTANCE OF ONE PATTERN IN ONE SESSION — `remember`, `recall` and now this: an action
|
|
2542
|
+
* renamed, the caller left pointing at the old name, and the failure rendered as a confident
|
|
2543
|
+
* empty answer rather than an error. A verb that cannot tell "nothing matched" from "the call
|
|
2544
|
+
* failed" will always report the first.
|
|
2545
|
+
*
|
|
2546
|
+
* ⚠ THE CATCH NO LONGER SWALLOWS. It returns `null` for a genuine failure and `[]` only when the
|
|
2547
|
+
* workspace really has no projects — two different answers, so the caller can say which happened.
|
|
2548
|
+
*/
|
|
2549
|
+
async listProjects() {
|
|
2550
|
+
if (!this.config)
|
|
2551
|
+
return null;
|
|
2552
|
+
try {
|
|
2553
|
+
const resp = this.useWebSocket
|
|
2554
|
+
? await this.sendRequest({ type: 'project-list', limit: 500 })
|
|
2555
|
+
: await this.postHttp({ type: 'project-list', limit: 500 });
|
|
2556
|
+
// ⚠ An `{ error }` comes back as a VALUE on this transport — it never throws.
|
|
2557
|
+
if (!resp || resp.error)
|
|
2558
|
+
return null;
|
|
2559
|
+
return Array.isArray(resp.projects) ? resp.projects : [];
|
|
2560
|
+
}
|
|
2561
|
+
catch {
|
|
2562
|
+
return null;
|
|
2563
|
+
}
|
|
2564
|
+
}
|
|
2565
|
+
getMimeType(filePath) {
|
|
2566
|
+
const ext = filePath.split('.').pop()?.toLowerCase() || '';
|
|
2567
|
+
const types = {
|
|
2568
|
+
png: 'image/png',
|
|
2569
|
+
jpg: 'image/jpeg',
|
|
2570
|
+
jpeg: 'image/jpeg',
|
|
2571
|
+
gif: 'image/gif',
|
|
2572
|
+
webp: 'image/webp',
|
|
2573
|
+
svg: 'image/svg+xml',
|
|
2574
|
+
avif: 'image/avif',
|
|
2575
|
+
ico: 'image/x-icon',
|
|
2576
|
+
bmp: 'image/bmp',
|
|
2577
|
+
woff: 'font/woff',
|
|
2578
|
+
woff2: 'font/woff2',
|
|
2579
|
+
ttf: 'font/ttf',
|
|
2580
|
+
otf: 'font/otf',
|
|
2581
|
+
json: 'application/json',
|
|
2582
|
+
css: 'text/css',
|
|
2583
|
+
md: 'text/markdown',
|
|
2584
|
+
pdf: 'application/pdf',
|
|
2585
|
+
yaml: 'text/yaml',
|
|
2586
|
+
yml: 'text/yaml',
|
|
2587
|
+
txt: 'text/plain',
|
|
2588
|
+
scss: 'text/x-scss',
|
|
2589
|
+
less: 'text/x-less',
|
|
2590
|
+
};
|
|
2591
|
+
return types[ext] || 'application/octet-stream';
|
|
2592
|
+
}
|
|
2593
|
+
// ── Internals ─────────────────────────────────────────────────────────
|
|
2594
|
+
/** Flush buffer — sends accumulated events + docs */
|
|
2595
|
+
async flush() {
|
|
2596
|
+
if (!this.config)
|
|
2597
|
+
return;
|
|
2598
|
+
const { events, docs } = this.buffer;
|
|
2599
|
+
const hasData = events.length > 0 || docs.length > 0;
|
|
2600
|
+
if (!hasData && this.sessionEventBatch.size === 0)
|
|
2601
|
+
return;
|
|
2602
|
+
// Snapshot and clear buffer
|
|
2603
|
+
const payload = {};
|
|
2604
|
+
if (events.length > 0)
|
|
2605
|
+
payload.events = events.slice(0, 20);
|
|
2606
|
+
if (docs.length > 0)
|
|
2607
|
+
payload.docs = docs;
|
|
2608
|
+
this.buffer = { events: [], docs: [] };
|
|
2609
|
+
let ok = false;
|
|
2610
|
+
if (hasData) {
|
|
2611
|
+
if (this.useWebSocket) {
|
|
2612
|
+
this.wsSend({ type: 'batch-sync', ...payload });
|
|
2613
|
+
ok = this.connected;
|
|
2614
|
+
}
|
|
2615
|
+
else {
|
|
2616
|
+
const resp = await this.postHttp({ type: 'batch', ...payload });
|
|
2617
|
+
ok = !!resp;
|
|
2618
|
+
}
|
|
2619
|
+
}
|
|
2620
|
+
if (ok) {
|
|
2621
|
+
this.stats.batches++;
|
|
2622
|
+
this.lastSyncAt = Date.now();
|
|
2623
|
+
}
|
|
2624
|
+
// Legacy mode: flush session event batch
|
|
2625
|
+
if (!this.useWebSocket) {
|
|
2626
|
+
await this.flushSessionEventBatch();
|
|
2627
|
+
}
|
|
2628
|
+
}
|
|
2629
|
+
/** Flush batched session events via HTTP (legacy mode only) */
|
|
2630
|
+
async flushSessionEventBatch() {
|
|
2631
|
+
if (!this.config || this.sessionEventBatch.size === 0)
|
|
2632
|
+
return;
|
|
2633
|
+
const sessionIds = [...this.sessionEventBatch.keys()];
|
|
2634
|
+
for (const sessionId of sessionIds) {
|
|
2635
|
+
const events = this.sessionEventBatch.get(sessionId);
|
|
2636
|
+
if (!events || events.length === 0) {
|
|
2637
|
+
this.sessionEventBatch.delete(sessionId);
|
|
2638
|
+
continue;
|
|
2639
|
+
}
|
|
2640
|
+
const batch = events.splice(0, 50);
|
|
2641
|
+
const resp = await this.postHttp({ type: 'session-events', sessionId, events: batch });
|
|
2642
|
+
if (!resp) {
|
|
2643
|
+
const remaining = this.sessionEventBatch.get(sessionId) || [];
|
|
2644
|
+
this.sessionEventBatch.set(sessionId, [...batch, ...remaining]);
|
|
2645
|
+
}
|
|
2646
|
+
const current = this.sessionEventBatch.get(sessionId);
|
|
2647
|
+
if (current && current.length === 0)
|
|
2648
|
+
this.sessionEventBatch.delete(sessionId);
|
|
2649
|
+
}
|
|
2650
|
+
}
|
|
2651
|
+
/** Send heartbeat via HTTP (legacy mode) */
|
|
2652
|
+
async sendHeartbeatHttp() {
|
|
2653
|
+
try {
|
|
2654
|
+
const resp = await this.postHttp({ type: 'heartbeat', deviceId: this.deviceId });
|
|
2655
|
+
if (!resp)
|
|
2656
|
+
return false;
|
|
2657
|
+
if (resp.tier)
|
|
2658
|
+
this.tier = resp.tier;
|
|
2659
|
+
const profileData = resp.profile;
|
|
2660
|
+
if (profileData) {
|
|
2661
|
+
this.profile = {
|
|
2662
|
+
displayName: profileData.displayName || 'Clearly User',
|
|
2663
|
+
username: profileData.username,
|
|
2664
|
+
photoURL: profileData.photoURL,
|
|
2665
|
+
email: profileData.email,
|
|
2666
|
+
subscriptionPlan: profileData.subscriptionPlan || 'free',
|
|
2667
|
+
subscriptionStatus: profileData.subscriptionStatus,
|
|
2668
|
+
shareTerminal: profileData.shareTerminal,
|
|
2669
|
+
shareTelemetry: profileData.shareTelemetry,
|
|
2670
|
+
};
|
|
2671
|
+
const json = JSON.stringify(this.profile);
|
|
2672
|
+
if (json !== this.lastProfileJson) {
|
|
2673
|
+
this.lastProfileJson = json;
|
|
2674
|
+
this.onProfileUpdate?.(this.profile);
|
|
2675
|
+
}
|
|
2676
|
+
}
|
|
2677
|
+
return true;
|
|
2678
|
+
}
|
|
2679
|
+
catch {
|
|
2680
|
+
return false;
|
|
2681
|
+
}
|
|
2682
|
+
}
|
|
2683
|
+
/** Single POST to the relay endpoint (legacy HTTP mode) */
|
|
2684
|
+
async postHttp(data) {
|
|
2685
|
+
if (!this.config)
|
|
2686
|
+
return null;
|
|
2687
|
+
try {
|
|
2688
|
+
const resp = await fetch(this.httpUrl(), {
|
|
2689
|
+
method: 'POST',
|
|
2690
|
+
headers: {
|
|
2691
|
+
'Content-Type': 'application/json',
|
|
2692
|
+
Authorization: `Bearer ${this.config.token}`,
|
|
2693
|
+
},
|
|
2694
|
+
body: JSON.stringify(data),
|
|
2695
|
+
signal: AbortSignal.timeout(10_000),
|
|
2696
|
+
});
|
|
2697
|
+
if (!resp.ok) {
|
|
2698
|
+
this.stats.failed++;
|
|
2699
|
+
if (resp.status === 401) {
|
|
2700
|
+
console.error('[relay] Token rejected — sign in from the Clearly Dev Mac app tray');
|
|
2701
|
+
this.connected = false;
|
|
2702
|
+
}
|
|
2703
|
+
else if (resp.status === 429) {
|
|
2704
|
+
console.warn('[relay] Rate limited — backing off');
|
|
2705
|
+
}
|
|
2706
|
+
return null;
|
|
2707
|
+
}
|
|
2708
|
+
this.stats.sent++;
|
|
2709
|
+
return await resp.json();
|
|
2710
|
+
}
|
|
2711
|
+
catch (err) {
|
|
2712
|
+
this.stats.failed++;
|
|
2713
|
+
if (err.name !== 'AbortError') {
|
|
2714
|
+
console.error(`[relay] POST error: ${err.message}`);
|
|
2715
|
+
}
|
|
2716
|
+
if (this.stats.failed > 5 && this.connected) {
|
|
2717
|
+
this.connected = false;
|
|
2718
|
+
this.scheduleReconnect();
|
|
2719
|
+
}
|
|
2720
|
+
return null;
|
|
2721
|
+
}
|
|
2722
|
+
}
|
|
2723
|
+
}
|