bloby-bot 0.25.2 → 0.25.5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bloby-bot",
3
- "version": "0.25.2",
3
+ "version": "0.25.5",
4
4
  "releaseNotes": [
5
5
  "1. new stuff",
6
6
  "2. ",
@@ -0,0 +1,224 @@
1
+ /**
2
+ * Agent API — exposes the Claude Agent SDK to workspace code.
3
+ *
4
+ * Single endpoint: POST /api/agent/query
5
+ *
6
+ * If `systemPromptPath` is provided → read it from workspace, use as systemPrompt.
7
+ * If omitted → use the `claude_code` preset (built-in Claude Code tools + prompt).
8
+ *
9
+ * Supports session persistence via `sessionId` (pass the one returned from a previous call).
10
+ *
11
+ * Auth: per-session secret injected into the workspace backend as BLOBY_AGENT_SECRET.
12
+ * Safety: localhost-only, path traversal prevention, concurrency + rate limits, timeouts.
13
+ */
14
+
15
+ import fs from 'fs';
16
+ import path from 'path';
17
+ import { query, type SDKMessage } from '@anthropic-ai/claude-agent-sdk';
18
+ import { WORKSPACE_DIR } from '../shared/paths.js';
19
+ import { log } from '../shared/logger.js';
20
+ import { getClaudeAccessToken } from '../worker/claude-auth.js';
21
+
22
+ // ── Types ──────────────────────────────────────────────────────────────────
23
+
24
+ export interface AgentQueryRequest {
25
+ message: string;
26
+ systemPromptPath?: string; // relative to workspace, e.g. "skills/my-skill/prompt.txt"
27
+ sessionId?: string; // resume a previous session
28
+ maxTurns?: number; // default 25, max 50
29
+ timeout?: number; // ms, default 120_000, max 300_000
30
+ }
31
+
32
+ export interface AgentQueryResponse {
33
+ ok: boolean;
34
+ response?: string;
35
+ sessionId?: string;
36
+ toolsUsed?: string[];
37
+ usedFileTools?: boolean;
38
+ error?: string;
39
+ }
40
+
41
+ // ── Concurrency & Rate Limiting ────────────────────────────────────────────
42
+
43
+ const MAX_CONCURRENT = 3;
44
+ const RATE_LIMIT_WINDOW = 60_000; // 1 minute
45
+ const RATE_LIMIT_MAX = 10;
46
+
47
+ let activeQueries = 0;
48
+ const requestTimestamps: number[] = [];
49
+
50
+ function checkRateLimit(): string | null {
51
+ // Concurrency check
52
+ if (activeQueries >= MAX_CONCURRENT) {
53
+ return `Too many concurrent queries (max ${MAX_CONCURRENT}). Try again shortly.`;
54
+ }
55
+
56
+ // Rate limit check
57
+ const now = Date.now();
58
+ // Prune old timestamps
59
+ while (requestTimestamps.length && requestTimestamps[0]! < now - RATE_LIMIT_WINDOW) {
60
+ requestTimestamps.shift();
61
+ }
62
+ if (requestTimestamps.length >= RATE_LIMIT_MAX) {
63
+ return `Rate limit exceeded (max ${RATE_LIMIT_MAX} requests per minute).`;
64
+ }
65
+
66
+ return null;
67
+ }
68
+
69
+ // ── Path Validation ────────────────────────────────────────────────────────
70
+
71
+ function resolveSystemPromptPath(relPath: string): { path: string } | { error: string } {
72
+ const resolved = path.resolve(WORKSPACE_DIR, relPath);
73
+ const workspaceBoundary = WORKSPACE_DIR + path.sep;
74
+
75
+ // Must be inside workspace (not parent traversal)
76
+ if (!resolved.startsWith(workspaceBoundary) && resolved !== WORKSPACE_DIR) {
77
+ return { error: 'System prompt path must be within the workspace directory.' };
78
+ }
79
+
80
+ if (!fs.existsSync(resolved)) {
81
+ return { error: `System prompt file not found: ${relPath}` };
82
+ }
83
+
84
+ return { path: resolved };
85
+ }
86
+
87
+ // ── Main Query Handler ─────────────────────────────────────────────────────
88
+
89
+ export async function handleAgentQuery(req: AgentQueryRequest): Promise<AgentQueryResponse> {
90
+ // ── Validate inputs ──
91
+ if (!req.message || typeof req.message !== 'string') {
92
+ return { ok: false, error: 'Missing or invalid "message" field.' };
93
+ }
94
+
95
+ const maxTurns = Math.min(Math.max(req.maxTurns || 25, 1), 50);
96
+ const timeout = Math.min(Math.max(req.timeout || 120_000, 5_000), 300_000);
97
+
98
+ // ── Rate limit ──
99
+ const rateLimitError = checkRateLimit();
100
+ if (rateLimitError) {
101
+ return { ok: false, error: rateLimitError };
102
+ }
103
+
104
+ // ── Resolve system prompt ──
105
+ let systemPrompt: string | { type: 'preset'; preset: 'claude_code' };
106
+
107
+ if (req.systemPromptPath) {
108
+ const result = resolveSystemPromptPath(req.systemPromptPath);
109
+ if ('error' in result) {
110
+ return { ok: false, error: result.error };
111
+ }
112
+ try {
113
+ const content = fs.readFileSync(result.path, 'utf-8').trim();
114
+ if (!content) {
115
+ return { ok: false, error: 'System prompt file is empty.' };
116
+ }
117
+ systemPrompt = content;
118
+ } catch (err: any) {
119
+ return { ok: false, error: `Failed to read system prompt: ${err.message}` };
120
+ }
121
+ } else {
122
+ systemPrompt = { type: 'preset', preset: 'claude_code' };
123
+ }
124
+
125
+ // ── OAuth token ──
126
+ const oauthToken = await getClaudeAccessToken();
127
+ if (!oauthToken) {
128
+ return { ok: false, error: 'Claude OAuth token not found. Please authenticate via the dashboard.' };
129
+ }
130
+
131
+ // ── Execute query ──
132
+ activeQueries++;
133
+ requestTimestamps.push(Date.now());
134
+
135
+ const abortController = new AbortController();
136
+ const timeoutHandle = setTimeout(() => abortController.abort(), timeout);
137
+
138
+ let fullText = '';
139
+ const usedTools = new Set<string>();
140
+ let sessionId: string | undefined;
141
+ let stderrBuf = '';
142
+
143
+ try {
144
+ log.info(`[agent-api] Query: msg="${req.message.slice(0, 80)}..." maxTurns=${maxTurns} timeout=${timeout}ms resume=${req.sessionId || 'none'}`);
145
+
146
+ const claudeQuery = query({
147
+ prompt: req.message,
148
+ options: {
149
+ cwd: WORKSPACE_DIR,
150
+ permissionMode: 'bypassPermissions',
151
+ allowDangerouslySkipPermissions: true,
152
+ maxTurns,
153
+ abortController,
154
+ systemPrompt: systemPrompt as any,
155
+ ...(req.sessionId ? { resume: req.sessionId } : {}),
156
+ stderr: (chunk: string) => { stderrBuf += chunk; },
157
+ env: {
158
+ ...process.env as Record<string, string>,
159
+ CLAUDE_CODE_OAUTH_TOKEN: oauthToken,
160
+ CLAUDE_CODE_BUBBLEWRAP: '1',
161
+ },
162
+ },
163
+ });
164
+
165
+ for await (const msg of claudeQuery) {
166
+ if (abortController.signal.aborted) break;
167
+
168
+ switch (msg.type) {
169
+ case 'assistant': {
170
+ const assistantMsg = (msg as any).message;
171
+ if (!assistantMsg?.content) break;
172
+ for (const block of assistantMsg.content) {
173
+ if (block.type === 'text' && block.text) {
174
+ if (fullText && !fullText.endsWith('\n')) fullText += '\n\n';
175
+ fullText += block.text;
176
+ } else if (block.type === 'tool_use') {
177
+ usedTools.add(block.name);
178
+ }
179
+ }
180
+ break;
181
+ }
182
+ case 'result': {
183
+ // Extract session_id from result for persistence
184
+ sessionId = (msg as any).session_id;
185
+
186
+ if (!fullText && (msg as any).subtype?.startsWith('error')) {
187
+ const errors = (msg as any).errors?.join('; ') || 'Agent query failed';
188
+ return {
189
+ ok: false,
190
+ error: errors,
191
+ sessionId,
192
+ toolsUsed: Array.from(usedTools),
193
+ };
194
+ }
195
+ break;
196
+ }
197
+ }
198
+ }
199
+
200
+ const FILE_TOOLS = ['Write', 'Edit'];
201
+ const usedFileTools = FILE_TOOLS.some((t) => usedTools.has(t));
202
+
203
+ log.info(`[agent-api] Done: ${fullText.length} chars, tools=[${Array.from(usedTools).join(',')}], session=${sessionId || 'unknown'}`);
204
+
205
+ return {
206
+ ok: true,
207
+ response: fullText,
208
+ sessionId,
209
+ toolsUsed: Array.from(usedTools),
210
+ usedFileTools,
211
+ };
212
+ } catch (err: any) {
213
+ if (abortController.signal.aborted) {
214
+ return { ok: false, error: 'Query timed out.', sessionId };
215
+ }
216
+ const detail = stderrBuf.trim();
217
+ const errMsg = detail ? `${err.message}\n\n${detail}` : err.message;
218
+ log.warn(`[agent-api] Error: ${errMsg}`);
219
+ return { ok: false, error: errMsg, sessionId };
220
+ } finally {
221
+ clearTimeout(timeoutHandle);
222
+ activeQueries--;
223
+ }
224
+ }
@@ -11,6 +11,14 @@ let intentionallyStopped = false;
11
11
  const MAX_RESTARTS = 3;
12
12
  const STABLE_THRESHOLD = 30_000; // 30s — if backend ran this long, it wasn't a crash loop
13
13
 
14
+ /** Extra env vars injected into every backend spawn (e.g. BLOBY_AGENT_SECRET) */
15
+ let extraEnv: Record<string, string> = {};
16
+
17
+ /** Set extra environment variables for the backend process. Applies to all future spawns including auto-restarts. */
18
+ export function setBackendEnv(env: Record<string, string>): void {
19
+ extraEnv = { ...extraEnv, ...env };
20
+ }
21
+
14
22
  const LOG_FILE = path.join(WORKSPACE_DIR, '.backend.log');
15
23
 
16
24
  export function getBackendPort(basePort: number): number {
@@ -59,7 +67,7 @@ export function spawnBackend(port: number): ChildProcess {
59
67
  child = spawn(process.execPath, ['--import', 'tsx/esm', '--input-type=module', '-e', wrapper], {
60
68
  cwd: WORKSPACE_DIR,
61
69
  stdio: ['ignore', 'pipe', 'pipe'],
62
- env: { ...process.env, BACKEND_PORT: String(port) },
70
+ env: { ...process.env, ...extraEnv, BACKEND_PORT: String(port) },
63
71
  });
64
72
 
65
73
  child.stdout?.on('data', (d) => {
@@ -247,7 +247,7 @@ export class ChannelManager {
247
247
 
248
248
  /** Format a bot reply with the agent's name prefix (for admin & assistant messages, NOT customer) */
249
249
  private formatBotReply(text: string, botName: string): string {
250
- return `🤖 *${botName}:*\n\n\`${text}\``;
250
+ return `🤖 *${botName}:*\n\n${text}`;
251
251
  }
252
252
 
253
253
  private handleStatusChange(status: ChannelStatus) {
@@ -409,6 +409,7 @@ export class ChannelManager {
409
409
  senderName,
410
410
  role: 'assistant',
411
411
  text: enrichedText,
412
+ displayText: cleanText,
412
413
  rawSender: sender,
413
414
  attachments: attachments.length > 0 ? attachments : undefined,
414
415
  };
@@ -476,11 +477,14 @@ export class ChannelManager {
476
477
  return;
477
478
  }
478
479
 
480
+ // Use display text for DB/chat (hides enriched agent context from the UI)
481
+ const displayContent = msg.displayText || msg.text;
482
+
479
483
  // Save user message to DB
480
484
  try {
481
485
  await workerApi(`/api/conversations/${convId}/messages`, 'POST', {
482
486
  role: 'user',
483
- content: msg.text,
487
+ content: displayContent,
484
488
  meta: { model, channel: msg.channel },
485
489
  });
486
490
  } catch (err: any) {
@@ -490,7 +494,7 @@ export class ChannelManager {
490
494
  // Broadcast to chat clients (mirroring)
491
495
  broadcastBloby('chat:sync', {
492
496
  conversationId: convId,
493
- message: { role: 'user', content: msg.text, timestamp: new Date().toISOString() },
497
+ message: { role: 'user', content: displayContent, timestamp: new Date().toISOString() },
494
498
  });
495
499
 
496
500
  // Fetch names and recent messages
@@ -29,8 +29,10 @@ export interface InboundMessage {
29
29
  senderName?: string;
30
30
  /** Resolved role of the sender */
31
31
  role: SenderRole;
32
- /** Message text content */
32
+ /** Message text content (may include enriched context for assistant triggers) */
33
33
  text: string;
34
+ /** Display-friendly text for DB/chat UI (when text contains enriched agent context) */
35
+ displayText?: string;
34
36
  /** Raw sender JID (channel-specific format, used for replies) */
35
37
  rawSender: string;
36
38
  /** Image attachments */
@@ -11,7 +11,8 @@ import { log } from '../shared/logger.js';
11
11
  import { startTunnel, stopTunnel, isTunnelAlive, restartTunnel, startNamedTunnel, restartNamedTunnel } from './tunnel.js';
12
12
  import { createWorkerApp } from '../worker/index.js';
13
13
  import { closeDb, getSession, getSetting } from '../worker/db.js';
14
- import { spawnBackend, stopBackend, getBackendPort, isBackendAlive, isBackendStopping, resetBackendRestarts } from './backend.js';
14
+ import { spawnBackend, stopBackend, getBackendPort, isBackendAlive, isBackendStopping, resetBackendRestarts, setBackendEnv } from './backend.js';
15
+ import { handleAgentQuery, type AgentQueryRequest } from './agent-api.js';
15
16
  import { updateTunnelUrl, startHeartbeat, stopHeartbeat, disconnect } from '../shared/relay.js';
16
17
  import {
17
18
  startConversation, pushMessage, hasConversation, endConversation, endAllConversations,
@@ -237,6 +238,13 @@ export async function startSupervisor() {
237
238
  const config = loadConfig();
238
239
  const backendPort = getBackendPort(config.port);
239
240
  const internalSecret = crypto.randomBytes(16).toString('hex');
241
+ const agentSecret = crypto.randomBytes(32).toString('hex');
242
+
243
+ // Inject agent secret + supervisor port into workspace backend env
244
+ setBackendEnv({
245
+ BLOBY_AGENT_SECRET: agentSecret,
246
+ SUPERVISOR_PORT: String(config.port),
247
+ });
240
248
 
241
249
  // Kill any stale processes from previous crashes/updates
242
250
  log.info(`[startup] Clearing ports ${config.port}, ${config.port + 2}, ${backendPort}...`);
@@ -803,6 +811,57 @@ ${!connected ? `<script>
803
811
  return;
804
812
  }
805
813
 
814
+ // ── Agent API — SDK gateway for workspace code ──
815
+ if (req.method === 'POST' && req.url?.startsWith('/api/agent/')) {
816
+ const agentPath = req.url.split('?')[0];
817
+ res.setHeader('Content-Type', 'application/json');
818
+ res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate');
819
+
820
+ // Localhost-only guard
821
+ const remoteIp = req.socket.remoteAddress || '';
822
+ if (remoteIp !== '127.0.0.1' && remoteIp !== '::1' && remoteIp !== '::ffff:127.0.0.1') {
823
+ res.writeHead(403);
824
+ res.end(JSON.stringify({ ok: false, error: 'Agent API is localhost-only.' }));
825
+ return;
826
+ }
827
+
828
+ // Auth: x-agent-secret header
829
+ if (req.headers['x-agent-secret'] !== agentSecret) {
830
+ res.writeHead(401);
831
+ res.end(JSON.stringify({ ok: false, error: 'Invalid or missing x-agent-secret header.' }));
832
+ return;
833
+ }
834
+
835
+ if (agentPath === '/api/agent/query') {
836
+ let body = '';
837
+ req.on('data', (chunk: Buffer) => { body += chunk.toString(); });
838
+ req.on('end', async () => {
839
+ try {
840
+ const parsed = JSON.parse(body) as AgentQueryRequest;
841
+ const result = await handleAgentQuery(parsed);
842
+
843
+ // If the agent wrote files, restart backend + broadcast HMR
844
+ if (result.usedFileTools) {
845
+ resetBackendRestarts();
846
+ stopBackend().then(() => spawnBackend(backendPort));
847
+ broadcastBloby('app:hmr-update', {});
848
+ }
849
+
850
+ res.writeHead(result.ok ? 200 : 400);
851
+ res.end(JSON.stringify(result));
852
+ } catch (err: any) {
853
+ res.writeHead(500);
854
+ res.end(JSON.stringify({ ok: false, error: err.message }));
855
+ }
856
+ });
857
+ return;
858
+ }
859
+
860
+ res.writeHead(404);
861
+ res.end(JSON.stringify({ ok: false, error: 'Unknown agent endpoint.' }));
862
+ return;
863
+ }
864
+
806
865
  // API routes → handled in-process by worker Express app
807
866
  if (req.url?.startsWith('/api')) {
808
867
  // Internal supervisor calls (workerApi) bypass auth — they carry a per-process secret
@@ -300,7 +300,7 @@ GET | `/api/marketplace/balance/bot` | Bearer (required) | Check your owner's cr
300
300
 
301
301
  Agents will soon have a USDC wallet for autonomous purchases. You'll be able to check your balance and buy skills directly without a redeem code. This is not yet available — for now, paid skills require your human to purchase from the web marketplace and give you a redeem code.
302
302
 
303
- For a machine-readable catalog: `GET https://bloby.bot/api/marketplace/products`
303
+ For a machine-readable catalog: `GET https://bloby.bot/api/marketplace/products` — **always include your relay token** (`Authorization: Bearer $RELAY_TOKEN`) on every request to the relay API, even public endpoints. This enables your presence in Bloby World so other users can see your bloby on the world map.
304
304
 
305
305
  ---
306
306