@0xmaxma/claude-gateway 1.5.8 → 1.6.0
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 +22 -0
- package/dist/agent/runner.d.ts +30 -0
- package/dist/agent/runner.d.ts.map +1 -1
- package/dist/agent/runner.js +223 -16
- package/dist/agent/runner.js.map +1 -1
- package/dist/api/apps-router.d.ts.map +1 -1
- package/dist/api/apps-router.js +7 -2
- package/dist/api/apps-router.js.map +1 -1
- package/dist/api/gateway-router.d.ts.map +1 -1
- package/dist/api/gateway-router.js +27 -0
- package/dist/api/gateway-router.js.map +1 -1
- package/dist/api/line-webhook-router.d.ts.map +1 -1
- package/dist/api/line-webhook-router.js +68 -0
- package/dist/api/line-webhook-router.js.map +1 -1
- package/dist/api/router.d.ts +2 -0
- package/dist/api/router.d.ts.map +1 -1
- package/dist/api/router.js +59 -3
- package/dist/api/router.js.map +1 -1
- package/dist/api/share-router.d.ts +11 -0
- package/dist/api/share-router.d.ts.map +1 -0
- package/dist/api/share-router.js +402 -0
- package/dist/api/share-router.js.map +1 -0
- package/dist/apps/installer.d.ts +55 -0
- package/dist/apps/installer.d.ts.map +1 -1
- package/dist/apps/installer.js +130 -0
- package/dist/apps/installer.js.map +1 -1
- package/dist/config/loader.d.ts.map +1 -1
- package/dist/config/loader.js +9 -0
- package/dist/config/loader.js.map +1 -1
- package/dist/config/public-url.d.ts +11 -0
- package/dist/config/public-url.d.ts.map +1 -0
- package/dist/config/public-url.js +41 -0
- package/dist/config/public-url.js.map +1 -0
- package/dist/config/watcher.d.ts.map +1 -1
- package/dist/config/watcher.js +1 -0
- package/dist/config/watcher.js.map +1 -1
- package/dist/history/db.d.ts +14 -0
- package/dist/history/db.d.ts.map +1 -1
- package/dist/history/db.js +48 -4
- package/dist/history/db.js.map +1 -1
- package/dist/history/types.d.ts +5 -0
- package/dist/history/types.d.ts.map +1 -1
- package/dist/session/process.d.ts.map +1 -1
- package/dist/session/process.js +17 -1
- package/dist/session/process.js.map +1 -1
- package/dist/session/store.d.ts.map +1 -1
- package/dist/session/store.js +9 -1
- package/dist/session/store.js.map +1 -1
- package/dist/share/session-image-catalog.d.ts +22 -0
- package/dist/share/session-image-catalog.d.ts.map +1 -0
- package/dist/share/session-image-catalog.js +151 -0
- package/dist/share/session-image-catalog.js.map +1 -0
- package/dist/share/share-store.d.ts +137 -0
- package/dist/share/share-store.d.ts.map +1 -0
- package/dist/share/share-store.js +340 -0
- package/dist/share/share-store.js.map +1 -0
- package/dist/types.d.ts +17 -7
- package/dist/types.d.ts.map +1 -1
- package/mcp/instructions.ts +1 -0
- package/mcp/server.ts +2 -0
- package/mcp/tools/image/module.ts +240 -26
- package/mcp/tools/line/module.ts +186 -0
- package/mcp/tools/share-image/module.ts +120 -0
- package/mcp/tools/shared/share-client.ts +197 -0
- package/package.json +3 -1
package/mcp/tools/line/module.ts
CHANGED
|
@@ -10,9 +10,34 @@
|
|
|
10
10
|
* is passed, and fall back to push if it's absent or expired/used (the single-use
|
|
11
11
|
* token has no guaranteed lifetime — ~1 min — so push remains the safety net).
|
|
12
12
|
*/
|
|
13
|
+
import * as fs from 'node:fs';
|
|
14
|
+
import * as path from 'node:path';
|
|
13
15
|
import type { ToolModule, McpToolDefinition, McpToolResult, ToolVisibility } from '../../types';
|
|
14
16
|
import { messagingApi } from '@line/bot-sdk';
|
|
15
17
|
import { chunkText, planLineSend, LINE_TEXT_LIMIT } from './pure';
|
|
18
|
+
import { createShares, ShareClientError, shareBridgeEnabled } from '../shared/share-client';
|
|
19
|
+
|
|
20
|
+
/** Default lifetime of a share URL handed to LINE (1 hour). */
|
|
21
|
+
const DEFAULT_MEDIA_URL_TTL_MS = 60 * 60 * 1000;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Convert a media path (absolute under the agent media root, or already relative
|
|
25
|
+
* like "media/<chat>/<file>.png") to the "media/…"-relative form the gateway's
|
|
26
|
+
* signed-URL route expects. Returns null if it cannot be resolved to the media root.
|
|
27
|
+
*/
|
|
28
|
+
function toRelativeMediaPath(p: string): string | null {
|
|
29
|
+
if (!p) return null;
|
|
30
|
+
const normalized = p.replace(/\\/g, '/');
|
|
31
|
+
if (!path.isAbsolute(normalized)) {
|
|
32
|
+
return normalized.startsWith('media/') ? normalized : `media/${normalized.replace(/^\/+/, '')}`;
|
|
33
|
+
}
|
|
34
|
+
const workspace = process.env.GATEWAY_WORKSPACE_DIR;
|
|
35
|
+
if (!workspace) return null;
|
|
36
|
+
const mediaRoot = path.resolve(workspace, '..', 'media');
|
|
37
|
+
const rel = path.relative(mediaRoot, path.resolve(normalized));
|
|
38
|
+
if (rel.startsWith('..') || path.isAbsolute(rel)) return null;
|
|
39
|
+
return `media/${rel.split(path.sep).join('/')}`;
|
|
40
|
+
}
|
|
16
41
|
|
|
17
42
|
export class LineModule implements ToolModule {
|
|
18
43
|
id = 'line';
|
|
@@ -54,11 +79,43 @@ export class LineModule implements ToolModule {
|
|
|
54
79
|
required: ['chat_id', 'text'],
|
|
55
80
|
},
|
|
56
81
|
},
|
|
82
|
+
{
|
|
83
|
+
name: 'line_image',
|
|
84
|
+
description:
|
|
85
|
+
'Send an image to the current LINE user. Pass chat_id (the LINE userId from the ' +
|
|
86
|
+
'<channel> tag) and image (a media path saved by the gateway, e.g. a generate_image ' +
|
|
87
|
+
'result path or a "media/…" path). LINE can only fetch public HTTPS URLs, so the tool ' +
|
|
88
|
+
'mints a signed, short-lived public URL for the file and sends it as an image message. ' +
|
|
89
|
+
'Pass reply_token from the <channel> tag when present to send for FREE (falls back to push).',
|
|
90
|
+
inputSchema: {
|
|
91
|
+
type: 'object',
|
|
92
|
+
properties: {
|
|
93
|
+
chat_id: {
|
|
94
|
+
type: 'string',
|
|
95
|
+
description: 'LINE userId to send to (the chat_id from the channel turn).',
|
|
96
|
+
},
|
|
97
|
+
image: {
|
|
98
|
+
type: 'string',
|
|
99
|
+
description: 'Media path of the image (absolute path under the agent media dir, or a "media/…" relative path).',
|
|
100
|
+
},
|
|
101
|
+
preview: {
|
|
102
|
+
type: 'string',
|
|
103
|
+
description: 'Optional separate preview-image media path. Defaults to the same image.',
|
|
104
|
+
},
|
|
105
|
+
reply_token: {
|
|
106
|
+
type: 'string',
|
|
107
|
+
description: 'Optional single-use reply token from the <channel> tag (sends free; falls back to push).',
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
required: ['chat_id', 'image'],
|
|
111
|
+
},
|
|
112
|
+
},
|
|
57
113
|
];
|
|
58
114
|
}
|
|
59
115
|
|
|
60
116
|
async handleTool(name: string, args: Record<string, unknown>): Promise<McpToolResult> {
|
|
61
117
|
if (name === 'line_reply') return this.handleReply(args);
|
|
118
|
+
if (name === 'line_image') return this.handleImage(args);
|
|
62
119
|
return { content: [{ type: 'text', text: `Unknown tool: ${name}` }], isError: true };
|
|
63
120
|
}
|
|
64
121
|
|
|
@@ -124,4 +181,133 @@ export class LineModule implements ToolModule {
|
|
|
124
181
|
};
|
|
125
182
|
}
|
|
126
183
|
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Send an image to LINE. LINE's servers fetch the image over public HTTPS (the
|
|
187
|
+
* normal media route is API-key-gated), so we mint a short-lived share token via
|
|
188
|
+
* the gateway share bridge — the SAME `/shared/:token` primitive the image tools
|
|
189
|
+
* use — and hand LINE the resulting public URL. The host comes from `.public-base`
|
|
190
|
+
* (derived by the gateway from the inbound webhook); the token is host-agnostic,
|
|
191
|
+
* so we simply prepend that base to `/shared/<token>`.
|
|
192
|
+
*/
|
|
193
|
+
private async handleImage(args: Record<string, unknown>): Promise<McpToolResult> {
|
|
194
|
+
const chatId = typeof args.chat_id === 'string' ? args.chat_id : '';
|
|
195
|
+
const image = typeof args.image === 'string' ? args.image : '';
|
|
196
|
+
const preview = typeof args.preview === 'string' && args.preview ? args.preview : image;
|
|
197
|
+
const replyToken = typeof args.reply_token === 'string' ? args.reply_token : '';
|
|
198
|
+
const token = process.env.LINE_CHANNEL_ACCESS_TOKEN ?? '';
|
|
199
|
+
|
|
200
|
+
if (!chatId) {
|
|
201
|
+
return { content: [{ type: 'text', text: 'line_image: missing chat_id' }], isError: true };
|
|
202
|
+
}
|
|
203
|
+
if (!image) {
|
|
204
|
+
return { content: [{ type: 'text', text: 'line_image: image path is required' }], isError: true };
|
|
205
|
+
}
|
|
206
|
+
// Fail fast on a missing LINE token BEFORE any file IO or share mint, so a
|
|
207
|
+
// misconfigured pod never mints a throwaway share it can't deliver.
|
|
208
|
+
if (!token) {
|
|
209
|
+
return { content: [{ type: 'text', text: 'line_image: missing LINE_CHANNEL_ACCESS_TOKEN' }], isError: true };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Public base URL is derived by the gateway from the inbound LINE webhook and
|
|
213
|
+
// written to `<workspace>/../.public-base` (no public-base-URL env var). Read it
|
|
214
|
+
// here at call-time; if it isn't there yet the pod hasn't seen a webhook, so
|
|
215
|
+
// ask the user to send another message rather than pushing a broken URL.
|
|
216
|
+
const workspace = process.env.GATEWAY_WORKSPACE_DIR;
|
|
217
|
+
if (!workspace) {
|
|
218
|
+
return {
|
|
219
|
+
content: [{ type: 'text', text: 'line_image: GATEWAY_WORKSPACE_DIR not set' }],
|
|
220
|
+
isError: true,
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
let publicBase = '';
|
|
224
|
+
try {
|
|
225
|
+
publicBase = fs
|
|
226
|
+
.readFileSync(path.resolve(workspace, '..', '.public-base'), 'utf8')
|
|
227
|
+
.trim()
|
|
228
|
+
.replace(/\/+$/, '');
|
|
229
|
+
} catch {
|
|
230
|
+
publicBase = '';
|
|
231
|
+
}
|
|
232
|
+
if (!publicBase) {
|
|
233
|
+
return {
|
|
234
|
+
content: [{ type: 'text', text: 'line_image: public base not resolved yet (send a message again)' }],
|
|
235
|
+
isError: true,
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
// The share bridge (the same authenticated local gateway API the image tools
|
|
239
|
+
// use) needs the session identity injected into this subprocess. Missing it =
|
|
240
|
+
// image delivery isn't wired up on this pod.
|
|
241
|
+
if (!shareBridgeEnabled()) {
|
|
242
|
+
return {
|
|
243
|
+
content: [{ type: 'text', text: 'line_image: image delivery not configured (share bridge unavailable)' }],
|
|
244
|
+
isError: true,
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const relImage = toRelativeMediaPath(image);
|
|
249
|
+
const relPreview = toRelativeMediaPath(preview);
|
|
250
|
+
if (!relImage || !relPreview) {
|
|
251
|
+
return {
|
|
252
|
+
content: [{ type: 'text', text: `line_image: could not resolve media path to the agent media root: ${image}` }],
|
|
253
|
+
isError: true,
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const ttlMs = Number(process.env.GATEWAY_MEDIA_URL_TTL_MS) > 0
|
|
258
|
+
? Number(process.env.GATEWAY_MEDIA_URL_TTL_MS)
|
|
259
|
+
: DEFAULT_MEDIA_URL_TTL_MS;
|
|
260
|
+
const ttlSeconds = Math.max(10, Math.ceil(ttlMs / 1000));
|
|
261
|
+
|
|
262
|
+
// Mint one share per ref (order preserved). Identical refs dedupe to the same
|
|
263
|
+
// token within the store's idempotency window — fine, LINE gets a valid URL.
|
|
264
|
+
let originalContentUrl: string;
|
|
265
|
+
let previewImageUrl: string;
|
|
266
|
+
try {
|
|
267
|
+
const items = await createShares(
|
|
268
|
+
[{ path: relImage }, { path: relPreview }],
|
|
269
|
+
{ purpose: 'line', ttlSeconds },
|
|
270
|
+
);
|
|
271
|
+
if (!items[0]?.token || !items[1]?.token) {
|
|
272
|
+
throw new ShareClientError('share_failed', 'share response missing token', 500);
|
|
273
|
+
}
|
|
274
|
+
originalContentUrl = `${publicBase}/shared/${items[0].token}`;
|
|
275
|
+
previewImageUrl = `${publicBase}/shared/${items[1].token}`;
|
|
276
|
+
} catch (err) {
|
|
277
|
+
const msg = err instanceof ShareClientError ? `${err.code}: ${err.message}` : String(err);
|
|
278
|
+
return {
|
|
279
|
+
content: [{ type: 'text', text: `line_image: failed to mint share URL — ${msg}` }],
|
|
280
|
+
isError: true,
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const message = {
|
|
285
|
+
type: 'image' as const,
|
|
286
|
+
originalContentUrl,
|
|
287
|
+
previewImageUrl,
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
const client = new messagingApi.MessagingApiClient({ channelAccessToken: token });
|
|
291
|
+
|
|
292
|
+
let via = 'push';
|
|
293
|
+
try {
|
|
294
|
+
if (replyToken) {
|
|
295
|
+
try {
|
|
296
|
+
await client.replyMessage({ replyToken, messages: [message] });
|
|
297
|
+
via = 'reply';
|
|
298
|
+
} catch {
|
|
299
|
+
await client.pushMessage({ to: chatId, messages: [message] });
|
|
300
|
+
via = 'push (reply fallback)';
|
|
301
|
+
}
|
|
302
|
+
} else {
|
|
303
|
+
await client.pushMessage({ to: chatId, messages: [message] });
|
|
304
|
+
}
|
|
305
|
+
return { content: [{ type: 'text', text: `Sent image to LINE (${via}).` }] };
|
|
306
|
+
} catch (err) {
|
|
307
|
+
return {
|
|
308
|
+
content: [{ type: 'text', text: `line_image failed: ${(err as Error).message}` }],
|
|
309
|
+
isError: true,
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
}
|
|
127
313
|
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import type { ToolModule, McpToolDefinition, McpToolResult, ToolVisibility } from '../../types';
|
|
2
|
+
import {
|
|
3
|
+
ShareClientError,
|
|
4
|
+
createShares,
|
|
5
|
+
revokeShare,
|
|
6
|
+
shareBridgeEnabled,
|
|
7
|
+
type ShareRef,
|
|
8
|
+
} from '../shared/share-client';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Standalone share_image MCP tool (#70, plan §15) — explicit create/revoke of
|
|
12
|
+
* short-lived public image URLs. There is deliberately NO list action (shares
|
|
13
|
+
* are unenumerable, §10). generate_image auto-normalizes refs through the same
|
|
14
|
+
* gateway API, so normal image editing never needs this tool; it exists for
|
|
15
|
+
* explicit workflows (e.g. handing a temporary URL to an external service).
|
|
16
|
+
*/
|
|
17
|
+
export class ShareImageModule implements ToolModule {
|
|
18
|
+
id = 'share-image';
|
|
19
|
+
toolVisibility: ToolVisibility = 'all-configured';
|
|
20
|
+
|
|
21
|
+
isEnabled(): boolean {
|
|
22
|
+
return shareBridgeEnabled();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
getTools(): McpToolDefinition[] {
|
|
26
|
+
return shareImageToolDefs;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async handleTool(name: string, args: Record<string, unknown>): Promise<McpToolResult> {
|
|
30
|
+
if (name !== 'share_image') {
|
|
31
|
+
return { content: [{ type: 'text', text: `Unknown tool: ${name}` }], isError: true };
|
|
32
|
+
}
|
|
33
|
+
const action = typeof args.action === 'string' ? args.action : 'create';
|
|
34
|
+
switch (action) {
|
|
35
|
+
case 'create':
|
|
36
|
+
return this.handleCreate(args);
|
|
37
|
+
case 'revoke':
|
|
38
|
+
return this.handleRevoke(args);
|
|
39
|
+
default:
|
|
40
|
+
return {
|
|
41
|
+
content: [{ type: 'text', text: `share_image: unknown action "${action}" (expected create | revoke)` }],
|
|
42
|
+
isError: true,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
private async handleCreate(args: Record<string, unknown>): Promise<McpToolResult> {
|
|
48
|
+
const single = typeof args.path === 'string' && args.path.trim() ? [args.path.trim()] : [];
|
|
49
|
+
const many = Array.isArray(args.paths)
|
|
50
|
+
? (args.paths.filter((p) => typeof p === 'string' && p.trim()) as string[])
|
|
51
|
+
: [];
|
|
52
|
+
if (single.length && many.length) {
|
|
53
|
+
return { content: [{ type: 'text', text: 'share_image: pass either "path" or "paths", not both.' }], isError: true };
|
|
54
|
+
}
|
|
55
|
+
const paths = single.length ? single : many;
|
|
56
|
+
if (!paths.length) {
|
|
57
|
+
return { content: [{ type: 'text', text: 'share_image: action="create" requires "path" or "paths".' }], isError: true };
|
|
58
|
+
}
|
|
59
|
+
const refs: ShareRef[] = paths.map((p) =>
|
|
60
|
+
p.startsWith('artifact:') ? { artifact_id: p.slice('artifact:'.length) } : { path: p },
|
|
61
|
+
);
|
|
62
|
+
const opts: { purpose?: string; ttlSeconds?: number } = {};
|
|
63
|
+
if (typeof args.purpose === 'string' && args.purpose.trim()) opts.purpose = args.purpose.trim();
|
|
64
|
+
if (typeof args.ttl_seconds === 'number' && args.ttl_seconds > 0) opts.ttlSeconds = args.ttl_seconds;
|
|
65
|
+
try {
|
|
66
|
+
const items = await createShares(refs, opts);
|
|
67
|
+
return { content: [{ type: 'text', text: JSON.stringify({ items }, null, 2) }] };
|
|
68
|
+
} catch (err) {
|
|
69
|
+
return this.mapError(err);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
private async handleRevoke(args: Record<string, unknown>): Promise<McpToolResult> {
|
|
74
|
+
const shareId = typeof args.share_id === 'string' ? args.share_id.trim() : '';
|
|
75
|
+
if (!shareId) {
|
|
76
|
+
return { content: [{ type: 'text', text: 'share_image: action="revoke" requires "share_id".' }], isError: true };
|
|
77
|
+
}
|
|
78
|
+
try {
|
|
79
|
+
await revokeShare(shareId);
|
|
80
|
+
return { content: [{ type: 'text', text: JSON.stringify({ revoked: true, share_id: shareId }) }] };
|
|
81
|
+
} catch (err) {
|
|
82
|
+
return this.mapError(err);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
private mapError(err: unknown): McpToolResult {
|
|
87
|
+
if (err instanceof ShareClientError) {
|
|
88
|
+
return { content: [{ type: 'text', text: `share_image: ${err.code}: ${err.message}` }], isError: true };
|
|
89
|
+
}
|
|
90
|
+
return {
|
|
91
|
+
content: [{ type: 'text', text: `share_image: gateway unavailable: ${(err as Error).message}` }],
|
|
92
|
+
isError: true,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const shareImageToolDefs: McpToolDefinition[] = [
|
|
98
|
+
{
|
|
99
|
+
name: 'share_image',
|
|
100
|
+
description:
|
|
101
|
+
'Create or revoke a SHORT-LIVED public URL for an image in this agent\'s media directory. ' +
|
|
102
|
+
'action="create" takes "path" (one media path or artifact:<id>) or "paths" (up to 5) and returns ' +
|
|
103
|
+
'{ share_id, url, expires_at } per image — the URL needs no auth and expires automatically (default 30 min). ' +
|
|
104
|
+
'action="revoke" takes "share_id" and invalidates the URL immediately. ' +
|
|
105
|
+
'You normally do NOT need this for image editing: generate_image converts local/artifact references ' +
|
|
106
|
+
'to share URLs automatically. There is no list action.',
|
|
107
|
+
inputSchema: {
|
|
108
|
+
type: 'object',
|
|
109
|
+
properties: {
|
|
110
|
+
action: { type: 'string', enum: ['create', 'revoke'], description: 'create (default) | revoke' },
|
|
111
|
+
path: { type: 'string', description: 'One media path (e.g. "media/session-1/image.png") or "artifact:<id>" to share.' },
|
|
112
|
+
paths: { type: 'array', items: { type: 'string' }, description: 'Multiple media paths / artifact refs (max 5).' },
|
|
113
|
+
ttl_seconds: { type: 'number', description: 'Optional lifetime in seconds (default 1800).' },
|
|
114
|
+
purpose: { type: 'string', description: 'Optional purpose tag (default "codex_ref").' },
|
|
115
|
+
share_id: { type: 'string', description: 'Share id to revoke (required for action="revoke").' },
|
|
116
|
+
},
|
|
117
|
+
required: [],
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
];
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Image share bridge client (#70) — the ONLY way MCP subprocesses touch the
|
|
3
|
+
* share/artifact store. They call the authenticated local Gateway HTTP API
|
|
4
|
+
* (GATEWAY_API_URL + GATEWAY_API_KEY, both already injected into the session
|
|
5
|
+
* subprocess env) and NEVER open the SQLite DB themselves (plan §9/§10).
|
|
6
|
+
*
|
|
7
|
+
* Self-contained on purpose: mcp/** ships as source without src/**, so this
|
|
8
|
+
* module must not import from src/ (see tests/unit/mcp-no-src-imports.test.ts).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const REQUEST_TIMEOUT_MS = 15_000;
|
|
12
|
+
|
|
13
|
+
export type ShareRef = { artifact_id?: string; path?: string };
|
|
14
|
+
|
|
15
|
+
export type ShareItem = { share_id: string; token: string; url?: string; expires_at: string };
|
|
16
|
+
|
|
17
|
+
export type ArtifactItem = { artifact_id: string; artifact_ref: string; index: number; path: string };
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* One entry of the session image catalog (#72). `index` is 1-based and stable —
|
|
21
|
+
* it is the order of FIRST appearance of that file in the session, so "image 1"
|
|
22
|
+
* / "the first image" maps to index 1 deterministically. `ref` is what to feed
|
|
23
|
+
* back into generate_image's image/images argument.
|
|
24
|
+
*/
|
|
25
|
+
export type CatalogItem = {
|
|
26
|
+
index: number;
|
|
27
|
+
ref: string;
|
|
28
|
+
relative_path: string;
|
|
29
|
+
origin: string;
|
|
30
|
+
ts: number;
|
|
31
|
+
available: boolean;
|
|
32
|
+
/** What the image is: the generation prompt (origin=generated) or the user
|
|
33
|
+
* text that accompanied the upload. Absent when neither exists. */
|
|
34
|
+
desc?: string;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/** Error carrying the gateway's stable machine-readable code (e.g. image_ref_not_found). */
|
|
38
|
+
export class ShareClientError extends Error {
|
|
39
|
+
readonly code: string;
|
|
40
|
+
readonly status: number;
|
|
41
|
+
constructor(code: string, message: string, status: number) {
|
|
42
|
+
super(message);
|
|
43
|
+
this.code = code;
|
|
44
|
+
this.status = status;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The bridge client is usable when the gateway API identity reached this
|
|
50
|
+
* subprocess. gateway.publicUrl is the sole server-side enable switch: when it
|
|
51
|
+
* is absent the private mint endpoint is not mounted and calls fail closed.
|
|
52
|
+
*/
|
|
53
|
+
export function shareBridgeEnabled(): boolean {
|
|
54
|
+
return (
|
|
55
|
+
!!process.env.GATEWAY_API_URL &&
|
|
56
|
+
!!process.env.GATEWAY_API_KEY &&
|
|
57
|
+
!!process.env.GATEWAY_AGENT_ID &&
|
|
58
|
+
!!process.env.GATEWAY_SESSION_ID
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function apiBase(): string {
|
|
63
|
+
return (process.env.GATEWAY_API_URL ?? '').replace(/\/+$/, '');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function callGateway(
|
|
67
|
+
method: 'GET' | 'POST' | 'DELETE',
|
|
68
|
+
pathname: string,
|
|
69
|
+
body?: Record<string, unknown>,
|
|
70
|
+
): Promise<{ status: number; json: Record<string, unknown> }> {
|
|
71
|
+
const res = await fetch(`${apiBase()}${pathname}`, {
|
|
72
|
+
method,
|
|
73
|
+
headers: {
|
|
74
|
+
'Content-Type': 'application/json',
|
|
75
|
+
Authorization: `Bearer ${process.env.GATEWAY_API_KEY}`,
|
|
76
|
+
},
|
|
77
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
78
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
79
|
+
});
|
|
80
|
+
const text = await res.text().catch(() => '');
|
|
81
|
+
let json: Record<string, unknown> = {};
|
|
82
|
+
try {
|
|
83
|
+
json = JSON.parse(text) as Record<string, unknown>;
|
|
84
|
+
} catch {
|
|
85
|
+
/* non-JSON body */
|
|
86
|
+
}
|
|
87
|
+
return { status: res.status, json };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Mint short-lived share URLs for local/artifact refs — order preserved. */
|
|
91
|
+
export async function createShares(
|
|
92
|
+
refs: ShareRef[],
|
|
93
|
+
opts: { purpose?: string; ttlSeconds?: number } = {},
|
|
94
|
+
): Promise<ShareItem[]> {
|
|
95
|
+
const body: Record<string, unknown> = {
|
|
96
|
+
agent_id: process.env.GATEWAY_AGENT_ID,
|
|
97
|
+
session_id: process.env.GATEWAY_SESSION_ID,
|
|
98
|
+
purpose: opts.purpose ?? 'codex_ref',
|
|
99
|
+
refs,
|
|
100
|
+
};
|
|
101
|
+
if (opts.ttlSeconds !== undefined) body.ttl_seconds = opts.ttlSeconds;
|
|
102
|
+
const { status, json } = await callGateway('POST', '/api/v1/shares', body);
|
|
103
|
+
if (status !== 201) {
|
|
104
|
+
const code = typeof json.code === 'string' ? json.code : 'share_failed';
|
|
105
|
+
const message = typeof json.error === 'string' ? json.error : `share request failed (HTTP ${status})`;
|
|
106
|
+
throw new ShareClientError(code, message, status);
|
|
107
|
+
}
|
|
108
|
+
const items = json.items;
|
|
109
|
+
if (!Array.isArray(items)) throw new ShareClientError('share_failed', 'invalid share response', status);
|
|
110
|
+
return items as ShareItem[];
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Read the ground-truth catalog of every image in this session (#72), numbered
|
|
115
|
+
* by first appearance. Read-only: no token is minted and nothing is persisted.
|
|
116
|
+
* Callers must check shareBridgeEnabled() first (same contract as createShares).
|
|
117
|
+
*/
|
|
118
|
+
export async function listSessionImages(): Promise<CatalogItem[]> {
|
|
119
|
+
const query =
|
|
120
|
+
`agent_id=${encodeURIComponent(process.env.GATEWAY_AGENT_ID ?? '')}` +
|
|
121
|
+
`&session_id=${encodeURIComponent(process.env.GATEWAY_SESSION_ID ?? '')}`;
|
|
122
|
+
const { status, json } = await callGateway('GET', `/api/v1/image-catalog?${query}`);
|
|
123
|
+
if (status !== 200) {
|
|
124
|
+
const { code, message } = extractError(json, `image catalog request failed (HTTP ${status})`, 'catalog_failed');
|
|
125
|
+
throw new ShareClientError(code, message, status);
|
|
126
|
+
}
|
|
127
|
+
const items = json.items;
|
|
128
|
+
if (!Array.isArray(items)) throw new ShareClientError('catalog_failed', 'invalid catalog response', status);
|
|
129
|
+
return items as CatalogItem[];
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Pull a { code, message } out of a gateway error body. The private routes are
|
|
134
|
+
* not uniform: some return { error: "text", code: "x" } (share mint) and some
|
|
135
|
+
* return { error: { code, message } } (image service style), so accept both and
|
|
136
|
+
* fall back to the supplied defaults.
|
|
137
|
+
*/
|
|
138
|
+
function extractError(
|
|
139
|
+
json: Record<string, unknown>,
|
|
140
|
+
fallbackMessage: string,
|
|
141
|
+
fallbackCode: string,
|
|
142
|
+
): { code: string; message: string } {
|
|
143
|
+
let code = typeof json.code === 'string' ? json.code : '';
|
|
144
|
+
let message = typeof json.error === 'string' ? json.error : '';
|
|
145
|
+
const err = json.error;
|
|
146
|
+
if (err && typeof err === 'object') {
|
|
147
|
+
const nested = err as { code?: unknown; message?: unknown };
|
|
148
|
+
if (!code && typeof nested.code === 'string') code = nested.code;
|
|
149
|
+
if (!message && typeof nested.message === 'string') message = nested.message;
|
|
150
|
+
}
|
|
151
|
+
return { code: code || fallbackCode, message: message || fallbackMessage };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Revoke a share by id. Throws ShareClientError on failure. */
|
|
155
|
+
export async function revokeShare(shareId: string): Promise<void> {
|
|
156
|
+
const { status, json } = await callGateway('DELETE', `/api/v1/shares/${encodeURIComponent(shareId)}`);
|
|
157
|
+
if (status !== 200) {
|
|
158
|
+
const message = typeof json.error === 'string' ? json.error : `revoke failed (HTTP ${status})`;
|
|
159
|
+
throw new ShareClientError('revoke_failed', message, status);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Best-effort revoke of freshly minted shares (used when submit fails, §18). */
|
|
164
|
+
export async function revokeSharesBestEffort(shareIds: string[]): Promise<void> {
|
|
165
|
+
for (const id of shareIds) {
|
|
166
|
+
try {
|
|
167
|
+
await revokeShare(id);
|
|
168
|
+
} catch {
|
|
169
|
+
/* best-effort — TTL still bounds exposure */
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Register generated image files as private artifacts (§8). Best-effort: on
|
|
176
|
+
* any failure returns null so image delivery itself never breaks.
|
|
177
|
+
*/
|
|
178
|
+
export async function registerArtifacts(
|
|
179
|
+
files: string[],
|
|
180
|
+
meta: { provider: string; model: string; taskId?: string; prompt?: string },
|
|
181
|
+
): Promise<ArtifactItem[] | null> {
|
|
182
|
+
try {
|
|
183
|
+
const { status, json } = await callGateway('POST', '/api/v1/image-artifacts', {
|
|
184
|
+
agent_id: process.env.GATEWAY_AGENT_ID,
|
|
185
|
+
session_id: process.env.GATEWAY_SESSION_ID,
|
|
186
|
+
provider: meta.provider,
|
|
187
|
+
model: meta.model,
|
|
188
|
+
...(meta.taskId ? { task_id: meta.taskId } : {}),
|
|
189
|
+
...(meta.prompt ? { prompt: meta.prompt } : {}),
|
|
190
|
+
files,
|
|
191
|
+
});
|
|
192
|
+
if (status !== 201 || !Array.isArray(json.items)) return null;
|
|
193
|
+
return json.items as ArtifactItem[];
|
|
194
|
+
} catch {
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@0xmaxma/claude-gateway",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.6.0",
|
|
4
4
|
"description": "Multi-agent gateway for Claude",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -18,6 +18,8 @@
|
|
|
18
18
|
"lib/",
|
|
19
19
|
"resource/",
|
|
20
20
|
"mcp/",
|
|
21
|
+
"!mcp/node_modules",
|
|
22
|
+
"!mcp/node_modules/**",
|
|
21
23
|
"README.md",
|
|
22
24
|
"config.template.json"
|
|
23
25
|
],
|