@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.
@@ -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) {