@0xmaxma/claude-gateway 1.1.4 → 1.1.6
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/README.md +11 -0
- package/dist/agent/create-agent-prompts.d.ts +21 -0
- package/dist/agent/create-agent-prompts.d.ts.map +1 -0
- package/dist/agent/create-agent-prompts.js +109 -0
- package/dist/agent/create-agent-prompts.js.map +1 -0
- package/dist/agent/runner.d.ts.map +1 -1
- package/dist/agent/runner.js +8 -10
- package/dist/agent/runner.js.map +1 -1
- package/dist/api/router.d.ts.map +1 -1
- package/dist/api/router.js +671 -0
- package/dist/api/router.js.map +1 -1
- package/dist/api/wizard-state.d.ts +30 -0
- package/dist/api/wizard-state.d.ts.map +1 -0
- package/dist/api/wizard-state.js +68 -0
- package/dist/api/wizard-state.js.map +1 -0
- package/dist/skills/watcher.js +1 -1
- package/dist/skills/watcher.js.map +1 -1
- package/dist/types.d.ts +3 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/watch/factory.d.ts +2 -0
- package/dist/watch/factory.d.ts.map +1 -1
- package/dist/watch/factory.js +4 -0
- package/dist/watch/factory.js.map +1 -1
- package/package.json +2 -1
package/dist/api/router.js
CHANGED
|
@@ -40,10 +40,13 @@ const fs = __importStar(require("fs"));
|
|
|
40
40
|
const fsp = __importStar(require("fs/promises"));
|
|
41
41
|
const os = __importStar(require("os"));
|
|
42
42
|
const path = __importStar(require("path"));
|
|
43
|
+
const child_process_1 = require("child_process");
|
|
43
44
|
const runner_1 = require("../agent/runner");
|
|
44
45
|
const auth_1 = require("./auth");
|
|
45
46
|
const media_store_1 = require("../history/media-store");
|
|
46
47
|
const db_1 = require("../history/db");
|
|
48
|
+
const wizard_state_1 = require("./wizard-state");
|
|
49
|
+
const create_agent_prompts_1 = require("../agent/create-agent-prompts");
|
|
47
50
|
const MAX_MESSAGE_LENGTH = 10000;
|
|
48
51
|
const DEFAULT_TIMEOUT_MS = 60000;
|
|
49
52
|
const AGENT_ID_RE = /^[a-z][a-z0-9_-]{1,31}$/;
|
|
@@ -63,6 +66,113 @@ function detectMimeFromMagic(header) {
|
|
|
63
66
|
return 'application/pdf';
|
|
64
67
|
return null;
|
|
65
68
|
}
|
|
69
|
+
const AVATAR_MAX_BYTES = 5 * 1024 * 1024;
|
|
70
|
+
const AVATAR_MIME_EXT = {
|
|
71
|
+
'image/jpeg': 'jpg',
|
|
72
|
+
'image/png': 'png',
|
|
73
|
+
'image/gif': 'gif',
|
|
74
|
+
'image/webp': 'webp',
|
|
75
|
+
};
|
|
76
|
+
const TELEGRAM_API_BASE = process.env.TELEGRAM_API_BASE ?? 'https://api.telegram.org';
|
|
77
|
+
/** Max simultaneous wizard/start Claude subprocesses to prevent resource exhaustion. */
|
|
78
|
+
let wizardStartsInFlight = 0;
|
|
79
|
+
const WIZARD_MAX_CONCURRENT = 2;
|
|
80
|
+
/** Call Claude --print with stdin prompt; resolves with stdout on exit 0. */
|
|
81
|
+
function runClaude(prompt, timeoutMs = 120000) {
|
|
82
|
+
return new Promise((resolve, reject) => {
|
|
83
|
+
const child = (0, child_process_1.spawn)('claude', ['--print', '--dangerously-skip-permissions'], {
|
|
84
|
+
env: { ...process.env },
|
|
85
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
86
|
+
});
|
|
87
|
+
const out = [];
|
|
88
|
+
const err = [];
|
|
89
|
+
child.stdout.on('data', (c) => out.push(c));
|
|
90
|
+
child.stderr.on('data', (c) => err.push(c));
|
|
91
|
+
const timer = setTimeout(() => { child.kill(); reject(new Error('Claude generation timed out')); }, timeoutMs);
|
|
92
|
+
child.on('close', (code) => {
|
|
93
|
+
clearTimeout(timer);
|
|
94
|
+
if (code === 0)
|
|
95
|
+
resolve(Buffer.concat(out).toString('utf-8'));
|
|
96
|
+
else
|
|
97
|
+
reject(new Error(`Claude exited ${code}: ${Buffer.concat(err).toString('utf-8').slice(0, 200)}`));
|
|
98
|
+
});
|
|
99
|
+
child.on('error', (e) => { clearTimeout(timer); reject(e); });
|
|
100
|
+
child.stdin.write(prompt);
|
|
101
|
+
child.stdin.end();
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
/** Extract leading single emoji from first line of text. */
|
|
105
|
+
function extractLeadingEmoji(text) {
|
|
106
|
+
const m = text.match(/^(\p{Emoji_Presentation}|\p{Emoji}️)\s*\n/u);
|
|
107
|
+
if (m)
|
|
108
|
+
return { emoji: m[1], rest: text.slice(m[0].length) };
|
|
109
|
+
return { emoji: undefined, rest: text };
|
|
110
|
+
}
|
|
111
|
+
/** Read raw binary body up to maxBytes; rejects with 413 if exceeded. */
|
|
112
|
+
function readRawBody(req, res, maxBytes) {
|
|
113
|
+
return new Promise((resolve, reject) => {
|
|
114
|
+
const chunks = [];
|
|
115
|
+
let size = 0;
|
|
116
|
+
req.on('data', (chunk) => {
|
|
117
|
+
size += chunk.length;
|
|
118
|
+
if (size > maxBytes) {
|
|
119
|
+
if (!res.headersSent)
|
|
120
|
+
res.status(413).json({ error: `File too large (max ${maxBytes / 1024 / 1024}MB)` });
|
|
121
|
+
req.destroy();
|
|
122
|
+
reject(new Error('too_large'));
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
chunks.push(chunk);
|
|
126
|
+
});
|
|
127
|
+
req.on('end', () => resolve(Buffer.concat(chunks)));
|
|
128
|
+
req.on('error', reject);
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
/** Convert an absolute path back to a tilde-relative form when under $HOME. */
|
|
132
|
+
function absToTildePath(p) {
|
|
133
|
+
const home = os.homedir();
|
|
134
|
+
return p.startsWith(home + path.sep) ? path.join('~', p.slice(home.length + 1)) : p;
|
|
135
|
+
}
|
|
136
|
+
/** Verify a Telegram bot token via getMe; returns username on success. */
|
|
137
|
+
async function verifyTelegramToken(token) {
|
|
138
|
+
try {
|
|
139
|
+
const res = await fetch(`${TELEGRAM_API_BASE}/bot${token}/getMe`);
|
|
140
|
+
const json = await res.json();
|
|
141
|
+
return (json.ok && json.result?.username) ? json.result.username : null;
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Non-blocking Telegram getUpdates check.
|
|
149
|
+
* Returns match details + nextOffset on code match, { nextOffset } on no match, null on error.
|
|
150
|
+
* Always advancing the offset ensures we never re-process seen messages across poll calls.
|
|
151
|
+
*/
|
|
152
|
+
async function checkTelegramCode(token, expectedCode, offset) {
|
|
153
|
+
try {
|
|
154
|
+
const url = `${TELEGRAM_API_BASE}/bot${token}/getUpdates?offset=${offset}&timeout=0&limit=100`;
|
|
155
|
+
const res = await fetch(url);
|
|
156
|
+
const data = await res.json();
|
|
157
|
+
if (!data.ok)
|
|
158
|
+
return null;
|
|
159
|
+
let nextOffset = offset;
|
|
160
|
+
for (const upd of data.result) {
|
|
161
|
+
nextOffset = upd.update_id + 1;
|
|
162
|
+
if (upd.message?.chat.type === 'private' &&
|
|
163
|
+
upd.message.text?.trim().toUpperCase() === expectedCode.toUpperCase()) {
|
|
164
|
+
const chatId = String(upd.message.chat.id);
|
|
165
|
+
const senderId = upd.message.from ? String(upd.message.from.id) : chatId;
|
|
166
|
+
return { found: true, chatId, senderId, nextOffset };
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return { found: false, nextOffset };
|
|
170
|
+
}
|
|
171
|
+
catch (err) {
|
|
172
|
+
console.error('[wizard/verify] getUpdates failed:', err.message);
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
66
176
|
// ---------------------------------------------------------------------------
|
|
67
177
|
// In-memory rate limiter for media uploads (per API key)
|
|
68
178
|
// ---------------------------------------------------------------------------
|
|
@@ -344,6 +454,7 @@ function createApiRouter(agentRunners, agentConfigs, apiKeys, configPath, models
|
|
|
344
454
|
description: cfg.description,
|
|
345
455
|
model: cfg.claude?.model ?? null,
|
|
346
456
|
allow_tools: cfg.allow_tools ?? false,
|
|
457
|
+
avatarUrl: cfg.avatar ? `/api/v1/agents/${id}/avatar` : null,
|
|
347
458
|
}));
|
|
348
459
|
res.json({ agents });
|
|
349
460
|
});
|
|
@@ -456,6 +567,417 @@ function createApiRouter(agentRunners, agentConfigs, apiKeys, configPath, models
|
|
|
456
567
|
}
|
|
457
568
|
res.status(201).json({ agent: { id, description: newAgent.description, model: newAgent.claude.model } });
|
|
458
569
|
});
|
|
570
|
+
// ──────────────────────────────────────────────────────────────
|
|
571
|
+
// Wizard API — stateful multi-step agent creation
|
|
572
|
+
// ──────────────────────────────────────────────────────────────
|
|
573
|
+
function getAgentsBaseDir() {
|
|
574
|
+
return configPath
|
|
575
|
+
? path.join(path.dirname(configPath), 'agents')
|
|
576
|
+
: path.join(os.homedir(), '.claude-gateway', 'agents');
|
|
577
|
+
}
|
|
578
|
+
/**
|
|
579
|
+
* POST /api/v1/agents/wizard/start
|
|
580
|
+
* Start wizard: call Claude to generate workspace files, return wizardId + preview.
|
|
581
|
+
*/
|
|
582
|
+
router.post('/v1/agents/wizard/start', auth, async (req, res) => {
|
|
583
|
+
const apiKey = req.apiKey;
|
|
584
|
+
if (!(0, auth_1.isAdmin)(apiKey)) {
|
|
585
|
+
res.status(403).json({ error: 'Admin key required' });
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
if (!configPath) {
|
|
589
|
+
res.status(501).json({ error: 'Agent management not available (no configPath)' });
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
592
|
+
const body = req.body;
|
|
593
|
+
const { id, prompt } = body;
|
|
594
|
+
if (!id || typeof id !== 'string' || !AGENT_ID_RE.test(id)) {
|
|
595
|
+
res.status(400).json({ error: 'id must match pattern [a-z][a-z0-9_-]{1,31}' });
|
|
596
|
+
return;
|
|
597
|
+
}
|
|
598
|
+
if (!prompt || typeof prompt !== 'string' || !prompt.trim()) {
|
|
599
|
+
res.status(400).json({ error: 'prompt is required' });
|
|
600
|
+
return;
|
|
601
|
+
}
|
|
602
|
+
if (agentConfigs.has(id)) {
|
|
603
|
+
res.status(409).json({ error: `Agent '${id}' already exists` });
|
|
604
|
+
return;
|
|
605
|
+
}
|
|
606
|
+
if (wizard_state_1.wizardStore.findByAgentId(id)) {
|
|
607
|
+
res.status(409).json({ error: `Wizard for agent '${id}' is already in progress` });
|
|
608
|
+
return;
|
|
609
|
+
}
|
|
610
|
+
if (wizardStartsInFlight >= WIZARD_MAX_CONCURRENT) {
|
|
611
|
+
res.status(429).json({ error: 'Too many wizard starts in progress, please retry later' });
|
|
612
|
+
return;
|
|
613
|
+
}
|
|
614
|
+
const agentName = id.charAt(0).toUpperCase() + id.slice(1);
|
|
615
|
+
let rawOutput;
|
|
616
|
+
wizardStartsInFlight++;
|
|
617
|
+
try {
|
|
618
|
+
const genPrompt = (0, create_agent_prompts_1.buildGenerationPrompt)(agentName, prompt.trim());
|
|
619
|
+
rawOutput = await runClaude(genPrompt);
|
|
620
|
+
}
|
|
621
|
+
catch (err) {
|
|
622
|
+
res.status(500).json({ error: `Claude generation failed: ${err.message}` });
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
625
|
+
finally {
|
|
626
|
+
wizardStartsInFlight--;
|
|
627
|
+
}
|
|
628
|
+
let raw = rawOutput.trim();
|
|
629
|
+
const fenceMatch = raw.match(/^```(?:markdown|md)?\s*\n([\s\S]*?)\n```\s*$/);
|
|
630
|
+
if (fenceMatch)
|
|
631
|
+
raw = (fenceMatch[1] ?? '').trim();
|
|
632
|
+
const { emoji: signatureEmoji, rest } = extractLeadingEmoji(raw);
|
|
633
|
+
if (signatureEmoji)
|
|
634
|
+
raw = rest;
|
|
635
|
+
const parsedFiles = (0, create_agent_prompts_1.parseGeneratedFiles)(raw);
|
|
636
|
+
if (!parsedFiles.has('AGENTS.md')) {
|
|
637
|
+
const headingIdx = raw.indexOf('# ');
|
|
638
|
+
if (headingIdx >= 0) {
|
|
639
|
+
const content = raw.slice(headingIdx).trim();
|
|
640
|
+
if (content.length > 50)
|
|
641
|
+
parsedFiles.set('AGENTS.md', content);
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
for (const f of ['MEMORY.md', 'SOUL.md', 'USER.md']) {
|
|
645
|
+
if (!parsedFiles.has(f))
|
|
646
|
+
parsedFiles.set(f, '');
|
|
647
|
+
}
|
|
648
|
+
if (!parsedFiles.has('AGENTS.md')) {
|
|
649
|
+
parsedFiles.set('AGENTS.md', `# Agent: ${id}\n\n${prompt.trim().slice(0, 400)}\n`);
|
|
650
|
+
}
|
|
651
|
+
const files = Object.fromEntries(parsedFiles);
|
|
652
|
+
const state = wizard_state_1.wizardStore.create(id, prompt.trim(), files);
|
|
653
|
+
if (signatureEmoji)
|
|
654
|
+
wizard_state_1.wizardStore.update(state.wizardId, { signatureEmoji });
|
|
655
|
+
res.status(201).json({
|
|
656
|
+
wizardId: state.wizardId,
|
|
657
|
+
agentId: id,
|
|
658
|
+
files,
|
|
659
|
+
expiresAt: new Date(state.expiresAt).toISOString(),
|
|
660
|
+
});
|
|
661
|
+
});
|
|
662
|
+
/**
|
|
663
|
+
* PUT /api/v1/agents/wizard/:wizardId/avatar
|
|
664
|
+
* Upload avatar into wizard state (in-memory until confirm).
|
|
665
|
+
*/
|
|
666
|
+
router.put('/v1/agents/wizard/:wizardId/avatar', auth, async (req, res) => {
|
|
667
|
+
const apiKey = req.apiKey;
|
|
668
|
+
if (!(0, auth_1.isAdmin)(apiKey)) {
|
|
669
|
+
res.status(403).json({ error: 'Admin key required' });
|
|
670
|
+
return;
|
|
671
|
+
}
|
|
672
|
+
const { wizardId } = req.params;
|
|
673
|
+
const wizard = wizard_state_1.wizardStore.get(wizardId);
|
|
674
|
+
if (!wizard) {
|
|
675
|
+
res.status(404).json({ error: 'Wizard not found or expired' });
|
|
676
|
+
return;
|
|
677
|
+
}
|
|
678
|
+
if (wizard.step !== 'pending') {
|
|
679
|
+
res.status(409).json({ error: 'Avatar must be uploaded before confirm' });
|
|
680
|
+
return;
|
|
681
|
+
}
|
|
682
|
+
let buf;
|
|
683
|
+
try {
|
|
684
|
+
buf = await readRawBody(req, res, AVATAR_MAX_BYTES);
|
|
685
|
+
}
|
|
686
|
+
catch {
|
|
687
|
+
return;
|
|
688
|
+
}
|
|
689
|
+
if (!buf.length) {
|
|
690
|
+
res.status(400).json({ error: 'No file body received' });
|
|
691
|
+
return;
|
|
692
|
+
}
|
|
693
|
+
if (buf.length < 12) {
|
|
694
|
+
res.status(400).json({ error: 'File too small to detect type' });
|
|
695
|
+
return;
|
|
696
|
+
}
|
|
697
|
+
const mime = detectMimeFromMagic(buf.subarray(0, 12));
|
|
698
|
+
if (!mime || !AVATAR_MIME_EXT[mime]) {
|
|
699
|
+
res.status(415).json({ error: 'Unsupported image type. Allowed: jpeg, png, gif, webp' });
|
|
700
|
+
return;
|
|
701
|
+
}
|
|
702
|
+
wizard_state_1.wizardStore.update(wizardId, { avatarData: buf, avatarMime: mime });
|
|
703
|
+
res.json({ preview: true });
|
|
704
|
+
});
|
|
705
|
+
/**
|
|
706
|
+
* POST /api/v1/agents/wizard/:wizardId/confirm
|
|
707
|
+
* Write workspace files + optional avatar to disk, add agent to config.json.
|
|
708
|
+
*/
|
|
709
|
+
router.post('/v1/agents/wizard/:wizardId/confirm', auth, async (req, res) => {
|
|
710
|
+
const apiKey = req.apiKey;
|
|
711
|
+
if (!(0, auth_1.isAdmin)(apiKey)) {
|
|
712
|
+
res.status(403).json({ error: 'Admin key required' });
|
|
713
|
+
return;
|
|
714
|
+
}
|
|
715
|
+
if (!configPath) {
|
|
716
|
+
res.status(501).json({ error: 'Agent management not available (no configPath)' });
|
|
717
|
+
return;
|
|
718
|
+
}
|
|
719
|
+
const { wizardId } = req.params;
|
|
720
|
+
const wizard = wizard_state_1.wizardStore.get(wizardId);
|
|
721
|
+
if (!wizard) {
|
|
722
|
+
res.status(404).json({ error: 'Wizard not found or expired' });
|
|
723
|
+
return;
|
|
724
|
+
}
|
|
725
|
+
if (wizard.step !== 'pending') {
|
|
726
|
+
res.status(409).json({ error: `Wizard already in step: ${wizard.step}` });
|
|
727
|
+
return;
|
|
728
|
+
}
|
|
729
|
+
const body = req.body;
|
|
730
|
+
const rawFiles = typeof body.files === 'object' && body.files !== null
|
|
731
|
+
? body.files
|
|
732
|
+
: wizard.files;
|
|
733
|
+
const sanitizedFiles = {};
|
|
734
|
+
for (const [name, content] of Object.entries(rawFiles)) {
|
|
735
|
+
if (typeof name !== 'string' || typeof content !== 'string')
|
|
736
|
+
continue;
|
|
737
|
+
if (!/^[A-Z][A-Z0-9_.-]*\.md$/i.test(name))
|
|
738
|
+
continue;
|
|
739
|
+
sanitizedFiles[name] = content;
|
|
740
|
+
}
|
|
741
|
+
if (!sanitizedFiles['AGENTS.md']) {
|
|
742
|
+
res.status(400).json({ error: 'AGENTS.md is required in files' });
|
|
743
|
+
return;
|
|
744
|
+
}
|
|
745
|
+
const agentId = wizard.agentId;
|
|
746
|
+
if (agentConfigs.has(agentId)) {
|
|
747
|
+
res.status(409).json({ error: `Agent '${agentId}' already exists` });
|
|
748
|
+
return;
|
|
749
|
+
}
|
|
750
|
+
const agentsBase = getAgentsBaseDir();
|
|
751
|
+
const agentDirAbs = path.join(agentsBase, agentId);
|
|
752
|
+
const workspaceDirAbs = path.join(agentDirAbs, 'workspace');
|
|
753
|
+
const resolvedWorkspace = path.resolve(workspaceDirAbs);
|
|
754
|
+
try {
|
|
755
|
+
fs.mkdirSync(workspaceDirAbs, { recursive: true });
|
|
756
|
+
for (const [filename, content] of Object.entries(sanitizedFiles)) {
|
|
757
|
+
const filePath = path.resolve(path.join(workspaceDirAbs, filename));
|
|
758
|
+
if (!filePath.startsWith(resolvedWorkspace + path.sep))
|
|
759
|
+
continue;
|
|
760
|
+
await fsp.writeFile(filePath, content, 'utf-8');
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
catch (err) {
|
|
764
|
+
res.status(500).json({ error: `Failed to write workspace: ${err.message}` });
|
|
765
|
+
return;
|
|
766
|
+
}
|
|
767
|
+
let avatarFilename;
|
|
768
|
+
if (wizard.avatarData && wizard.avatarMime && AVATAR_MIME_EXT[wizard.avatarMime]) {
|
|
769
|
+
const ext = AVATAR_MIME_EXT[wizard.avatarMime];
|
|
770
|
+
avatarFilename = `avatar.${ext}`;
|
|
771
|
+
try {
|
|
772
|
+
await fsp.writeFile(path.join(agentDirAbs, avatarFilename), wizard.avatarData);
|
|
773
|
+
}
|
|
774
|
+
catch (err) {
|
|
775
|
+
console.error(`[wizard] Failed to write avatar for '${agentId}': ${err.message}`);
|
|
776
|
+
avatarFilename = undefined;
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
const defaultModel = (models ?? runner_1.DEFAULT_MODELS).find((m) => m.alias === 'sonnet')?.id
|
|
780
|
+
?? runner_1.DEFAULT_MODELS[2].id;
|
|
781
|
+
const newAgent = {
|
|
782
|
+
id: agentId,
|
|
783
|
+
description: wizard.prompt.slice(0, 200).trim(),
|
|
784
|
+
workspace: absToTildePath(workspaceDirAbs),
|
|
785
|
+
env: absToTildePath(path.join(workspaceDirAbs, '.env')),
|
|
786
|
+
claude: { model: defaultModel, dangerouslySkipPermissions: false, extraFlags: [] },
|
|
787
|
+
};
|
|
788
|
+
if (wizard.signatureEmoji)
|
|
789
|
+
newAgent.signatureEmoji = wizard.signatureEmoji;
|
|
790
|
+
if (avatarFilename)
|
|
791
|
+
newAgent.avatar = avatarFilename;
|
|
792
|
+
try {
|
|
793
|
+
await writeAgentsToConfig(configPath, (agents) => agents.push(newAgent), agentId);
|
|
794
|
+
}
|
|
795
|
+
catch (err) {
|
|
796
|
+
const code = err.code;
|
|
797
|
+
res.status(code === 'DUPLICATE' ? 409 : 500).json({
|
|
798
|
+
error: code === 'DUPLICATE' ? `Agent '${agentId}' already exists` : `Failed to write config: ${err.message}`,
|
|
799
|
+
});
|
|
800
|
+
return;
|
|
801
|
+
}
|
|
802
|
+
wizard_state_1.wizardStore.update(wizardId, { step: 'confirmed' });
|
|
803
|
+
const avatarUrl = avatarFilename ? `/api/v1/agents/${agentId}/avatar` : null;
|
|
804
|
+
res.json({
|
|
805
|
+
agentId,
|
|
806
|
+
avatarUrl,
|
|
807
|
+
next: `channel via POST /api/v1/agents/wizard/${wizardId}/channel, or skip via POST /api/v1/agents/wizard/${wizardId}/complete`,
|
|
808
|
+
});
|
|
809
|
+
});
|
|
810
|
+
/**
|
|
811
|
+
* POST /api/v1/agents/wizard/:wizardId/channel
|
|
812
|
+
* Verify bot token and generate pairing code.
|
|
813
|
+
*/
|
|
814
|
+
router.post('/v1/agents/wizard/:wizardId/channel', auth, async (req, res) => {
|
|
815
|
+
const apiKey = req.apiKey;
|
|
816
|
+
if (!(0, auth_1.isAdmin)(apiKey)) {
|
|
817
|
+
res.status(403).json({ error: 'Admin key required' });
|
|
818
|
+
return;
|
|
819
|
+
}
|
|
820
|
+
const { wizardId } = req.params;
|
|
821
|
+
const wizard = wizard_state_1.wizardStore.get(wizardId);
|
|
822
|
+
if (!wizard) {
|
|
823
|
+
res.status(404).json({ error: 'Wizard not found or expired' });
|
|
824
|
+
return;
|
|
825
|
+
}
|
|
826
|
+
if (wizard.step !== 'confirmed') {
|
|
827
|
+
res.status(409).json({ error: `Expected step 'confirmed', got '${wizard.step}'` });
|
|
828
|
+
return;
|
|
829
|
+
}
|
|
830
|
+
const body = req.body;
|
|
831
|
+
const channel = body.channel;
|
|
832
|
+
const botToken = typeof body.botToken === 'string' ? body.botToken.trim() : '';
|
|
833
|
+
if (channel !== 'telegram' && channel !== 'discord') {
|
|
834
|
+
res.status(400).json({ error: "channel must be 'telegram' or 'discord'" });
|
|
835
|
+
return;
|
|
836
|
+
}
|
|
837
|
+
if (!botToken) {
|
|
838
|
+
res.status(400).json({ error: 'botToken is required' });
|
|
839
|
+
return;
|
|
840
|
+
}
|
|
841
|
+
let botName;
|
|
842
|
+
if (channel === 'telegram') {
|
|
843
|
+
const username = await verifyTelegramToken(botToken);
|
|
844
|
+
if (!username) {
|
|
845
|
+
res.status(400).json({ error: 'Invalid Telegram bot token (getMe failed)' });
|
|
846
|
+
return;
|
|
847
|
+
}
|
|
848
|
+
botName = `@${username}`;
|
|
849
|
+
}
|
|
850
|
+
else {
|
|
851
|
+
try {
|
|
852
|
+
const r = await fetch('https://discord.com/api/v10/users/@me', {
|
|
853
|
+
headers: { Authorization: `Bot ${botToken}` },
|
|
854
|
+
});
|
|
855
|
+
const json = await r.json();
|
|
856
|
+
if (!r.ok || !json.username) {
|
|
857
|
+
res.status(400).json({ error: 'Invalid Discord bot token' });
|
|
858
|
+
return;
|
|
859
|
+
}
|
|
860
|
+
botName = `@${json.username}`;
|
|
861
|
+
}
|
|
862
|
+
catch {
|
|
863
|
+
res.status(400).json({ error: 'Failed to verify Discord bot token' });
|
|
864
|
+
return;
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
const pairingCode = (0, crypto_1.randomBytes)(3).toString('hex').toUpperCase();
|
|
868
|
+
wizard_state_1.wizardStore.update(wizardId, {
|
|
869
|
+
step: 'pairing',
|
|
870
|
+
channel: channel,
|
|
871
|
+
botToken,
|
|
872
|
+
pairingCode,
|
|
873
|
+
updateOffset: 0,
|
|
874
|
+
});
|
|
875
|
+
res.json({
|
|
876
|
+
channel,
|
|
877
|
+
botName,
|
|
878
|
+
pairingCode,
|
|
879
|
+
instruction: `Send this code as a DM to ${botName} to complete pairing`,
|
|
880
|
+
});
|
|
881
|
+
});
|
|
882
|
+
/**
|
|
883
|
+
* POST /api/v1/agents/wizard/:wizardId/channel/verify
|
|
884
|
+
* Poll for pairing code. Client polls this endpoint until { success: true }.
|
|
885
|
+
*/
|
|
886
|
+
router.post('/v1/agents/wizard/:wizardId/channel/verify', auth, async (req, res) => {
|
|
887
|
+
const apiKey = req.apiKey;
|
|
888
|
+
if (!(0, auth_1.isAdmin)(apiKey)) {
|
|
889
|
+
res.status(403).json({ error: 'Admin key required' });
|
|
890
|
+
return;
|
|
891
|
+
}
|
|
892
|
+
if (!configPath) {
|
|
893
|
+
res.status(501).json({ error: 'Agent management not available (no configPath)' });
|
|
894
|
+
return;
|
|
895
|
+
}
|
|
896
|
+
const { wizardId } = req.params;
|
|
897
|
+
const wizard = wizard_state_1.wizardStore.get(wizardId);
|
|
898
|
+
if (!wizard) {
|
|
899
|
+
res.status(404).json({ error: 'Wizard not found or expired' });
|
|
900
|
+
return;
|
|
901
|
+
}
|
|
902
|
+
if (wizard.step !== 'pairing') {
|
|
903
|
+
res.status(409).json({ error: `Expected step 'pairing', got '${wizard.step}'` });
|
|
904
|
+
return;
|
|
905
|
+
}
|
|
906
|
+
if (wizard.channel !== 'telegram') {
|
|
907
|
+
res.status(501).json({ error: 'Discord pairing verification via API is not yet supported' });
|
|
908
|
+
return;
|
|
909
|
+
}
|
|
910
|
+
const result = await checkTelegramCode(wizard.botToken, wizard.pairingCode, wizard.updateOffset ?? 0);
|
|
911
|
+
// Always advance offset on non-error responses to avoid re-processing seen messages
|
|
912
|
+
if (!result) {
|
|
913
|
+
// Network/API error — keep current offset; client may retry
|
|
914
|
+
res.json({ success: false, pending: true });
|
|
915
|
+
return;
|
|
916
|
+
}
|
|
917
|
+
if (!result.found) {
|
|
918
|
+
wizard_state_1.wizardStore.update(wizardId, { updateOffset: result.nextOffset });
|
|
919
|
+
res.json({ success: false, pending: true });
|
|
920
|
+
return;
|
|
921
|
+
}
|
|
922
|
+
// Code matched — commit config first, then advance offset so a retry can still succeed
|
|
923
|
+
// if the config write failed mid-way
|
|
924
|
+
try {
|
|
925
|
+
await writeAgentsToConfig(configPath, (agents) => {
|
|
926
|
+
const agent = agents.find((a) => a.id === wizard.agentId);
|
|
927
|
+
if (agent)
|
|
928
|
+
agent.telegram = { botToken: wizard.botToken };
|
|
929
|
+
});
|
|
930
|
+
}
|
|
931
|
+
catch (err) {
|
|
932
|
+
res.status(500).json({ error: `Failed to update config: ${err.message}` });
|
|
933
|
+
return;
|
|
934
|
+
}
|
|
935
|
+
wizard_state_1.wizardStore.update(wizardId, { updateOffset: result.nextOffset, step: 'complete' });
|
|
936
|
+
const agentsBase = getAgentsBaseDir();
|
|
937
|
+
const telegramStateDir = path.join(agentsBase, wizard.agentId, 'workspace', '.telegram-state');
|
|
938
|
+
try {
|
|
939
|
+
fs.mkdirSync(telegramStateDir, { recursive: true });
|
|
940
|
+
const access = JSON.stringify({ dmPolicy: 'allowlist', allowFrom: [result.senderId], groups: {}, pending: {} }, null, 2);
|
|
941
|
+
await fsp.writeFile(path.join(telegramStateDir, 'access.json'), access, { mode: 0o600 });
|
|
942
|
+
}
|
|
943
|
+
catch (err) {
|
|
944
|
+
console.error(`[wizard] access.json write failed for '${wizard.agentId}': ${err.message}`);
|
|
945
|
+
}
|
|
946
|
+
try {
|
|
947
|
+
await fetch(`${TELEGRAM_API_BASE}/bot${wizard.botToken}/sendMessage`, {
|
|
948
|
+
method: 'POST',
|
|
949
|
+
headers: { 'Content-Type': 'application/json' },
|
|
950
|
+
body: JSON.stringify({ chat_id: result.chatId, text: "You're connected! Send me a message to get started." }),
|
|
951
|
+
});
|
|
952
|
+
}
|
|
953
|
+
catch { /* non-fatal */ }
|
|
954
|
+
wizard_state_1.wizardStore.delete(wizardId);
|
|
955
|
+
res.json({ success: true, agentId: wizard.agentId });
|
|
956
|
+
});
|
|
957
|
+
/**
|
|
958
|
+
* POST /api/v1/agents/wizard/:wizardId/complete
|
|
959
|
+
* Skip channel setup and finalize wizard.
|
|
960
|
+
*/
|
|
961
|
+
router.post('/v1/agents/wizard/:wizardId/complete', auth, (req, res) => {
|
|
962
|
+
const apiKey = req.apiKey;
|
|
963
|
+
if (!(0, auth_1.isAdmin)(apiKey)) {
|
|
964
|
+
res.status(403).json({ error: 'Admin key required' });
|
|
965
|
+
return;
|
|
966
|
+
}
|
|
967
|
+
const { wizardId } = req.params;
|
|
968
|
+
const wizard = wizard_state_1.wizardStore.get(wizardId);
|
|
969
|
+
if (!wizard) {
|
|
970
|
+
res.status(404).json({ error: 'Wizard not found or expired' });
|
|
971
|
+
return;
|
|
972
|
+
}
|
|
973
|
+
if (wizard.step === 'pending') {
|
|
974
|
+
res.status(409).json({ error: 'Must confirm workspace before completing wizard' });
|
|
975
|
+
return;
|
|
976
|
+
}
|
|
977
|
+
const agentId = wizard.agentId;
|
|
978
|
+
wizard_state_1.wizardStore.delete(wizardId);
|
|
979
|
+
res.json({ agentId });
|
|
980
|
+
});
|
|
459
981
|
/**
|
|
460
982
|
* PATCH /api/v1/agents/:agentId
|
|
461
983
|
*
|
|
@@ -937,6 +1459,155 @@ function createApiRouter(agentRunners, agentConfigs, apiKeys, configPath, models
|
|
|
937
1459
|
}
|
|
938
1460
|
});
|
|
939
1461
|
// ──────────────────────────────────────────────────────────────
|
|
1462
|
+
// Avatar endpoints
|
|
1463
|
+
// ──────────────────────────────────────────────────────────────
|
|
1464
|
+
/**
|
|
1465
|
+
* PUT /api/v1/agents/:agentId/avatar
|
|
1466
|
+
* Upload or replace the agent's avatar image. Requires write permission.
|
|
1467
|
+
* Body: raw image binary (image/jpeg, image/png, image/webp, image/gif)
|
|
1468
|
+
*/
|
|
1469
|
+
router.put('/v1/agents/:agentId/avatar', auth, async (req, res) => {
|
|
1470
|
+
const apiKey = req.apiKey;
|
|
1471
|
+
const { agentId } = req.params;
|
|
1472
|
+
if (!(0, auth_1.canWriteAgent)(apiKey, agentId)) {
|
|
1473
|
+
res.status(403).json({ error: 'Write permission required' });
|
|
1474
|
+
return;
|
|
1475
|
+
}
|
|
1476
|
+
if (!configPath) {
|
|
1477
|
+
res.status(501).json({ error: 'Agent management not available (no configPath)' });
|
|
1478
|
+
return;
|
|
1479
|
+
}
|
|
1480
|
+
if (!agentConfigs.has(agentId)) {
|
|
1481
|
+
res.status(404).json({ error: `Agent '${agentId}' not found` });
|
|
1482
|
+
return;
|
|
1483
|
+
}
|
|
1484
|
+
let buf;
|
|
1485
|
+
try {
|
|
1486
|
+
buf = await readRawBody(req, res, AVATAR_MAX_BYTES);
|
|
1487
|
+
}
|
|
1488
|
+
catch {
|
|
1489
|
+
return;
|
|
1490
|
+
}
|
|
1491
|
+
if (!buf.length) {
|
|
1492
|
+
res.status(400).json({ error: 'No file body received' });
|
|
1493
|
+
return;
|
|
1494
|
+
}
|
|
1495
|
+
if (buf.length < 12) {
|
|
1496
|
+
res.status(400).json({ error: 'File too small to detect type' });
|
|
1497
|
+
return;
|
|
1498
|
+
}
|
|
1499
|
+
const mime = detectMimeFromMagic(buf.subarray(0, 12));
|
|
1500
|
+
if (!mime || !AVATAR_MIME_EXT[mime]) {
|
|
1501
|
+
res.status(415).json({ error: 'Unsupported image type. Allowed: jpeg, png, gif, webp' });
|
|
1502
|
+
return;
|
|
1503
|
+
}
|
|
1504
|
+
const ext = AVATAR_MIME_EXT[mime];
|
|
1505
|
+
const newFilename = `avatar.${ext}`;
|
|
1506
|
+
const agentDirAbs = path.join(getAgentsBaseDir(), agentId);
|
|
1507
|
+
// Remove old avatar file if extension differs
|
|
1508
|
+
const currentAvatar = agentConfigs.get(agentId)?.avatar;
|
|
1509
|
+
if (currentAvatar && currentAvatar !== newFilename) {
|
|
1510
|
+
const oldPath = path.join(agentDirAbs, currentAvatar);
|
|
1511
|
+
fsp.unlink(oldPath).catch(() => { });
|
|
1512
|
+
}
|
|
1513
|
+
try {
|
|
1514
|
+
fs.mkdirSync(agentDirAbs, { recursive: true });
|
|
1515
|
+
await fsp.writeFile(path.join(agentDirAbs, newFilename), buf);
|
|
1516
|
+
}
|
|
1517
|
+
catch (err) {
|
|
1518
|
+
res.status(500).json({ error: `Failed to write avatar: ${err.message}` });
|
|
1519
|
+
return;
|
|
1520
|
+
}
|
|
1521
|
+
try {
|
|
1522
|
+
await writeAgentsToConfig(configPath, (agents) => {
|
|
1523
|
+
const agent = agents.find((a) => a.id === agentId);
|
|
1524
|
+
if (agent)
|
|
1525
|
+
agent.avatar = newFilename;
|
|
1526
|
+
});
|
|
1527
|
+
}
|
|
1528
|
+
catch (err) {
|
|
1529
|
+
res.status(500).json({ error: `Failed to update config: ${err.message}` });
|
|
1530
|
+
return;
|
|
1531
|
+
}
|
|
1532
|
+
res.json({ avatarUrl: `/api/v1/agents/${agentId}/avatar` });
|
|
1533
|
+
});
|
|
1534
|
+
/**
|
|
1535
|
+
* DELETE /api/v1/agents/:agentId/avatar
|
|
1536
|
+
* Remove the agent's avatar. Requires write permission.
|
|
1537
|
+
*/
|
|
1538
|
+
router.delete('/v1/agents/:agentId/avatar', auth, async (req, res) => {
|
|
1539
|
+
const apiKey = req.apiKey;
|
|
1540
|
+
const { agentId } = req.params;
|
|
1541
|
+
if (!(0, auth_1.canWriteAgent)(apiKey, agentId)) {
|
|
1542
|
+
res.status(403).json({ error: 'Write permission required' });
|
|
1543
|
+
return;
|
|
1544
|
+
}
|
|
1545
|
+
if (!configPath) {
|
|
1546
|
+
res.status(501).json({ error: 'Agent management not available (no configPath)' });
|
|
1547
|
+
return;
|
|
1548
|
+
}
|
|
1549
|
+
const agentCfg = agentConfigs.get(agentId);
|
|
1550
|
+
if (!agentCfg) {
|
|
1551
|
+
res.status(404).json({ error: `Agent '${agentId}' not found` });
|
|
1552
|
+
return;
|
|
1553
|
+
}
|
|
1554
|
+
if (agentCfg.avatar) {
|
|
1555
|
+
const avatarPath = path.join(getAgentsBaseDir(), agentId, agentCfg.avatar);
|
|
1556
|
+
fsp.unlink(avatarPath).catch(() => { });
|
|
1557
|
+
}
|
|
1558
|
+
try {
|
|
1559
|
+
await writeAgentsToConfig(configPath, (agents) => {
|
|
1560
|
+
const agent = agents.find((a) => a.id === agentId);
|
|
1561
|
+
if (agent)
|
|
1562
|
+
delete agent.avatar;
|
|
1563
|
+
});
|
|
1564
|
+
}
|
|
1565
|
+
catch (err) {
|
|
1566
|
+
res.status(500).json({ error: `Failed to update config: ${err.message}` });
|
|
1567
|
+
return;
|
|
1568
|
+
}
|
|
1569
|
+
res.status(204).send();
|
|
1570
|
+
});
|
|
1571
|
+
/**
|
|
1572
|
+
* GET /api/v1/agents/:agentId/avatar
|
|
1573
|
+
* Serve the agent's avatar image.
|
|
1574
|
+
*/
|
|
1575
|
+
router.get('/v1/agents/:agentId/avatar', auth, (req, res) => {
|
|
1576
|
+
const apiKey = req.apiKey;
|
|
1577
|
+
const { agentId } = req.params;
|
|
1578
|
+
if (!(0, auth_1.canAccessAgent)(apiKey, agentId)) {
|
|
1579
|
+
res.status(403).json({ error: `API key has no access to agent '${agentId}'` });
|
|
1580
|
+
return;
|
|
1581
|
+
}
|
|
1582
|
+
const agentCfg = agentConfigs.get(agentId);
|
|
1583
|
+
if (!agentCfg) {
|
|
1584
|
+
res.status(404).json({ error: `Agent '${agentId}' not found` });
|
|
1585
|
+
return;
|
|
1586
|
+
}
|
|
1587
|
+
if (!agentCfg.avatar) {
|
|
1588
|
+
res.status(404).json({ error: 'No avatar set for this agent' });
|
|
1589
|
+
return;
|
|
1590
|
+
}
|
|
1591
|
+
const base = getAgentsBaseDir();
|
|
1592
|
+
const avatarPath = path.resolve(path.join(base, agentId, agentCfg.avatar));
|
|
1593
|
+
const agentDirResolved = path.resolve(path.join(base, agentId));
|
|
1594
|
+
if (!avatarPath.startsWith(agentDirResolved + path.sep)) {
|
|
1595
|
+
res.status(400).json({ error: 'Invalid avatar path' });
|
|
1596
|
+
return;
|
|
1597
|
+
}
|
|
1598
|
+
if (!fs.existsSync(avatarPath)) {
|
|
1599
|
+
res.status(404).json({ error: 'Avatar file not found' });
|
|
1600
|
+
return;
|
|
1601
|
+
}
|
|
1602
|
+
const ext = path.extname(avatarPath).slice(1).toLowerCase();
|
|
1603
|
+
const mimeMap = { jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif', webp: 'image/webp' };
|
|
1604
|
+
const contentType = mimeMap[ext] ?? 'application/octet-stream';
|
|
1605
|
+
res.setHeader('Cache-Control', 'private, max-age=3600');
|
|
1606
|
+
res.setHeader('Content-Type', contentType);
|
|
1607
|
+
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
1608
|
+
res.sendFile(avatarPath);
|
|
1609
|
+
});
|
|
1610
|
+
// ──────────────────────────────────────────────────────────────
|
|
940
1611
|
// API Session management (/v1/agents/:agentId/sessions/...)
|
|
941
1612
|
// ──────────────────────────────────────────────────────────────
|
|
942
1613
|
function resolveApiSession(req, res) {
|