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
|
@@ -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
|
+
}
|
package/supervisor/backend.ts
CHANGED
|
@@ -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
|
|
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:
|
|
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:
|
|
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 */
|
package/supervisor/index.ts
CHANGED
|
@@ -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
|
|