@hubium/hubium-mcp 0.1.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 +15 -0
- package/dist/cli.d.ts +22 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +270 -0
- package/dist/cli.js.map +1 -0
- package/dist/cli.test.d.ts +2 -0
- package/dist/cli.test.d.ts.map +1 -0
- package/dist/cli.test.js +191 -0
- package/dist/cli.test.js.map +1 -0
- package/dist/context/contextManager.d.ts +24 -0
- package/dist/context/contextManager.d.ts.map +1 -0
- package/dist/context/contextManager.js +129 -0
- package/dist/context/contextManager.js.map +1 -0
- package/dist/context/contextManager.test.d.ts +2 -0
- package/dist/context/contextManager.test.d.ts.map +1 -0
- package/dist/context/contextManager.test.js +101 -0
- package/dist/context/contextManager.test.js.map +1 -0
- package/dist/context/contextStore.d.ts +11 -0
- package/dist/context/contextStore.d.ts.map +1 -0
- package/dist/context/contextStore.js +116 -0
- package/dist/context/contextStore.js.map +1 -0
- package/dist/context/index.d.ts +5 -0
- package/dist/context/index.d.ts.map +1 -0
- package/dist/context/index.js +4 -0
- package/dist/context/index.js.map +1 -0
- package/dist/context/paths.d.ts +3 -0
- package/dist/context/paths.d.ts.map +1 -0
- package/dist/context/paths.js +23 -0
- package/dist/context/paths.js.map +1 -0
- package/dist/context/types.d.ts +9 -0
- package/dist/context/types.d.ts.map +1 -0
- package/dist/context/types.js +2 -0
- package/dist/context/types.js.map +1 -0
- package/dist/doctor/doctor.d.ts +84 -0
- package/dist/doctor/doctor.d.ts.map +1 -0
- package/dist/doctor/doctor.js +338 -0
- package/dist/doctor/doctor.js.map +1 -0
- package/dist/doctor/doctor.test.d.ts +5 -0
- package/dist/doctor/doctor.test.d.ts.map +1 -0
- package/dist/doctor/doctor.test.js +264 -0
- package/dist/doctor/doctor.test.js.map +1 -0
- package/dist/install/cursorInstaller.d.ts +15 -0
- package/dist/install/cursorInstaller.d.ts.map +1 -0
- package/dist/install/cursorInstaller.js +110 -0
- package/dist/install/cursorInstaller.js.map +1 -0
- package/dist/install/cursorInstaller.test.d.ts +2 -0
- package/dist/install/cursorInstaller.test.d.ts.map +1 -0
- package/dist/install/cursorInstaller.test.js +120 -0
- package/dist/install/cursorInstaller.test.js.map +1 -0
- package/dist/server.d.ts +72 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +809 -0
- package/dist/server.js.map +1 -0
- package/dist/server.test.d.ts +2 -0
- package/dist/server.test.d.ts.map +1 -0
- package/dist/server.test.js +9 -0
- package/dist/server.test.js.map +1 -0
- package/dist/server.tools.test.d.ts +2 -0
- package/dist/server.tools.test.d.ts.map +1 -0
- package/dist/server.tools.test.js +991 -0
- package/dist/server.tools.test.js.map +1 -0
- package/dist/token/index.d.ts +5 -0
- package/dist/token/index.d.ts.map +1 -0
- package/dist/token/index.js +4 -0
- package/dist/token/index.js.map +1 -0
- package/dist/token/keychain.d.ts +4 -0
- package/dist/token/keychain.d.ts.map +1 -0
- package/dist/token/keychain.js +11 -0
- package/dist/token/keychain.js.map +1 -0
- package/dist/token/redactor.d.ts +8 -0
- package/dist/token/redactor.d.ts.map +1 -0
- package/dist/token/redactor.js +88 -0
- package/dist/token/redactor.js.map +1 -0
- package/dist/token/tokenStore.d.ts +17 -0
- package/dist/token/tokenStore.d.ts.map +1 -0
- package/dist/token/tokenStore.js +98 -0
- package/dist/token/tokenStore.js.map +1 -0
- package/dist/token/tokenStore.test.d.ts +2 -0
- package/dist/token/tokenStore.test.d.ts.map +1 -0
- package/dist/token/tokenStore.test.js +89 -0
- package/dist/token/tokenStore.test.js.map +1 -0
- package/dist/trustBanner.d.ts +7 -0
- package/dist/trustBanner.d.ts.map +1 -0
- package/dist/trustBanner.js +14 -0
- package/dist/trustBanner.js.map +1 -0
- package/dist/trustBanner.test.d.ts +2 -0
- package/dist/trustBanner.test.d.ts.map +1 -0
- package/dist/trustBanner.test.js +12 -0
- package/dist/trustBanner.test.js.map +1 -0
- package/package.json +27 -0
|
@@ -0,0 +1,991 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
5
|
+
import { ContextManager } from './context/contextManager.js';
|
|
6
|
+
import { buildToolHandlers, LocalRateLimiter } from './server.js';
|
|
7
|
+
import { HubiumClientError } from '@hubium/client';
|
|
8
|
+
const originalEnv = { ...process.env };
|
|
9
|
+
let tempDir = '';
|
|
10
|
+
beforeEach(async () => {
|
|
11
|
+
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'hubium-mcp-tools-'));
|
|
12
|
+
process.env = { ...originalEnv, HUBIUM_MCP_CONFIG_DIR: tempDir };
|
|
13
|
+
});
|
|
14
|
+
afterEach(async () => {
|
|
15
|
+
process.env = { ...originalEnv };
|
|
16
|
+
if (tempDir) {
|
|
17
|
+
await fs.rm(tempDir, { recursive: true, force: true });
|
|
18
|
+
}
|
|
19
|
+
});
|
|
20
|
+
function parseResult(result) {
|
|
21
|
+
return JSON.parse(result.content[0].text);
|
|
22
|
+
}
|
|
23
|
+
function createHandlers(options) {
|
|
24
|
+
const manager = new ContextManager({ baseDir: tempDir });
|
|
25
|
+
const token = options?.token ?? null;
|
|
26
|
+
let followupToken = null;
|
|
27
|
+
const tokenStore = {
|
|
28
|
+
getToken: async () => (token ? { token, source: 'keychain' } : null),
|
|
29
|
+
getFollowupToken: () => followupToken,
|
|
30
|
+
setFollowupToken: (value) => {
|
|
31
|
+
followupToken = value;
|
|
32
|
+
},
|
|
33
|
+
clearFollowupToken: () => {
|
|
34
|
+
followupToken = null;
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
const whoamiResponse = options?.whoamiResponse ?? { actor: { id: 'agent-1' } };
|
|
38
|
+
const searchResponse = options?.searchResponse ?? {
|
|
39
|
+
items: [],
|
|
40
|
+
total: 0,
|
|
41
|
+
limit: 10,
|
|
42
|
+
offset: 0,
|
|
43
|
+
next_offset: null,
|
|
44
|
+
has_more: false,
|
|
45
|
+
};
|
|
46
|
+
const searchError = options?.searchError ?? null;
|
|
47
|
+
const getCaseResponse = options?.getCaseResponse ?? { case: { id: 1 } };
|
|
48
|
+
const getCaseError = options?.getCaseError ?? null;
|
|
49
|
+
const createCaseResponse = options?.createCaseResponse ?? {
|
|
50
|
+
case: { id: 1 },
|
|
51
|
+
audit_id: 'audit',
|
|
52
|
+
};
|
|
53
|
+
const createCaseError = options?.createCaseError ?? null;
|
|
54
|
+
const feedbackResponse = options?.feedbackResponse ?? {
|
|
55
|
+
feedback: { id: 1 },
|
|
56
|
+
audit_id: 'audit',
|
|
57
|
+
};
|
|
58
|
+
const feedbackError = options?.feedbackError ?? null;
|
|
59
|
+
let lastSearchParams = null;
|
|
60
|
+
let lastSearchOptions = null;
|
|
61
|
+
let lastGetCase = null;
|
|
62
|
+
let lastCreateCase = null;
|
|
63
|
+
let lastFeedback = null;
|
|
64
|
+
let lastClientToken = undefined;
|
|
65
|
+
const hubiumClientFactory = (input) => {
|
|
66
|
+
lastClientToken = input.token;
|
|
67
|
+
return ({
|
|
68
|
+
whoami: async () => whoamiResponse,
|
|
69
|
+
search: async (params, options) => {
|
|
70
|
+
lastSearchParams = params;
|
|
71
|
+
lastSearchOptions = options ?? null;
|
|
72
|
+
if (searchError) {
|
|
73
|
+
throw searchError;
|
|
74
|
+
}
|
|
75
|
+
return searchResponse;
|
|
76
|
+
},
|
|
77
|
+
getCase: async (caseId, options) => {
|
|
78
|
+
lastGetCase = { caseId, options };
|
|
79
|
+
if (getCaseError) {
|
|
80
|
+
throw getCaseError;
|
|
81
|
+
}
|
|
82
|
+
return getCaseResponse;
|
|
83
|
+
},
|
|
84
|
+
createCase: async (payload, options) => {
|
|
85
|
+
lastCreateCase = { payload, options };
|
|
86
|
+
if (createCaseError) {
|
|
87
|
+
throw createCaseError;
|
|
88
|
+
}
|
|
89
|
+
return createCaseResponse;
|
|
90
|
+
},
|
|
91
|
+
addFeedback: async (caseId, payload, options) => {
|
|
92
|
+
lastFeedback = { caseId, payload, options };
|
|
93
|
+
if (feedbackError) {
|
|
94
|
+
throw feedbackError;
|
|
95
|
+
}
|
|
96
|
+
return feedbackResponse;
|
|
97
|
+
},
|
|
98
|
+
});
|
|
99
|
+
};
|
|
100
|
+
const handlers = buildToolHandlers({
|
|
101
|
+
contextManager: manager,
|
|
102
|
+
tokenStore,
|
|
103
|
+
hubiumClientFactory,
|
|
104
|
+
safeMode: options?.safeMode ?? false,
|
|
105
|
+
rateLimiter: options?.rateLimiter ?? new LocalRateLimiter(1000, 60_000),
|
|
106
|
+
fetchImpl: options?.fetchImpl,
|
|
107
|
+
});
|
|
108
|
+
return {
|
|
109
|
+
handlers,
|
|
110
|
+
manager,
|
|
111
|
+
tokenStore,
|
|
112
|
+
getLastSearchParams: () => lastSearchParams,
|
|
113
|
+
getLastSearchOptions: () => lastSearchOptions,
|
|
114
|
+
getLastGetCase: () => lastGetCase,
|
|
115
|
+
getLastCreateCase: () => lastCreateCase,
|
|
116
|
+
getLastFeedback: () => lastFeedback,
|
|
117
|
+
getLastClientToken: () => lastClientToken,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
describe('MCP tools', () => {
|
|
121
|
+
it('hubium.get_context returns defaults when file missing', async () => {
|
|
122
|
+
const { handlers } = createHandlers();
|
|
123
|
+
const result = await handlers.callTool({ params: { name: 'hubium.get_context' } });
|
|
124
|
+
const data = parseResult(result);
|
|
125
|
+
expect(data.context.version).toBe(1);
|
|
126
|
+
});
|
|
127
|
+
it('hubium.doctor returns structured diagnostics and never prints token', async () => {
|
|
128
|
+
const mockFetch = vi.fn().mockRejectedValue(new Error('ENOTFOUND'));
|
|
129
|
+
const { handlers } = createHandlers({
|
|
130
|
+
token: 'secret-token-xyz',
|
|
131
|
+
fetchImpl: mockFetch,
|
|
132
|
+
});
|
|
133
|
+
const result = await handlers.callTool({ params: { name: 'hubium.doctor', arguments: {} } });
|
|
134
|
+
const data = parseResult(result);
|
|
135
|
+
expect(data).toHaveProperty('ok');
|
|
136
|
+
expect(data).toHaveProperty('checks');
|
|
137
|
+
expect(data).toHaveProperty('resolved');
|
|
138
|
+
expect(data).toHaveProperty('auth');
|
|
139
|
+
expect(data).toHaveProperty('backend');
|
|
140
|
+
expect(data).toHaveProperty('next_steps');
|
|
141
|
+
expect(data).toHaveProperty('summary');
|
|
142
|
+
const json = JSON.stringify(data);
|
|
143
|
+
expect(json).not.toContain('secret-token');
|
|
144
|
+
expect(json).not.toContain('xyz');
|
|
145
|
+
expect(data.next_steps.length).toBeGreaterThan(0);
|
|
146
|
+
});
|
|
147
|
+
it('hubium.set_context persists base_url and workspace_ids', async () => {
|
|
148
|
+
const { handlers } = createHandlers();
|
|
149
|
+
await handlers.callTool({
|
|
150
|
+
params: {
|
|
151
|
+
name: 'hubium.set_context',
|
|
152
|
+
arguments: { base_url: 'https://hubium.dev', workspace_ids: ['w1', 'w2'] },
|
|
153
|
+
},
|
|
154
|
+
});
|
|
155
|
+
const result = await handlers.callTool({ params: { name: 'hubium.get_context' } });
|
|
156
|
+
const data = parseResult(result);
|
|
157
|
+
expect(data.context.base_url).toBe('https://hubium.dev');
|
|
158
|
+
expect(data.context.workspace_ids).toEqual(['w1', 'w2']);
|
|
159
|
+
});
|
|
160
|
+
it('mutual exclusivity enforced in hubium.set_context', async () => {
|
|
161
|
+
const { handlers } = createHandlers();
|
|
162
|
+
const result = await handlers.callTool({
|
|
163
|
+
params: {
|
|
164
|
+
name: 'hubium.set_context',
|
|
165
|
+
arguments: { workspace_id: 'w1', workspace_ids: ['w2'] },
|
|
166
|
+
},
|
|
167
|
+
});
|
|
168
|
+
const data = parseResult(result);
|
|
169
|
+
expect(data.error).toContain('Provide only one of workspace_id or workspace_ids');
|
|
170
|
+
});
|
|
171
|
+
it('enforces https unless allow_insecure_http is true', async () => {
|
|
172
|
+
const { handlers } = createHandlers();
|
|
173
|
+
const result = await handlers.callTool({
|
|
174
|
+
params: {
|
|
175
|
+
name: 'hubium.set_context',
|
|
176
|
+
arguments: { base_url: 'http://hubium.dev' },
|
|
177
|
+
},
|
|
178
|
+
});
|
|
179
|
+
const data = parseResult(result);
|
|
180
|
+
expect(data.error).toContain('base_url must be https://');
|
|
181
|
+
const ok = await handlers.callTool({
|
|
182
|
+
params: {
|
|
183
|
+
name: 'hubium.set_context',
|
|
184
|
+
arguments: { base_url: 'http://hubium.dev', allow_insecure_http: true },
|
|
185
|
+
},
|
|
186
|
+
});
|
|
187
|
+
const okData = parseResult(ok);
|
|
188
|
+
expect(okData.context?.base_url).toBe('http://hubium.dev');
|
|
189
|
+
});
|
|
190
|
+
it('hubium.clear_context removes file and returns ok', async () => {
|
|
191
|
+
const { handlers } = createHandlers();
|
|
192
|
+
await handlers.callTool({
|
|
193
|
+
params: { name: 'hubium.set_context', arguments: { base_url: 'https://hubium.dev' } },
|
|
194
|
+
});
|
|
195
|
+
const cleared = await handlers.callTool({ params: { name: 'hubium.clear_context' } });
|
|
196
|
+
expect(parseResult(cleared)).toEqual({ ok: true });
|
|
197
|
+
const result = await handlers.callTool({ params: { name: 'hubium.get_context' } });
|
|
198
|
+
const data = parseResult(result);
|
|
199
|
+
expect(data.context.base_url).toBeUndefined();
|
|
200
|
+
});
|
|
201
|
+
it('tool output never includes token env values', async () => {
|
|
202
|
+
process.env.HUBIUM_TOKEN = 'secret';
|
|
203
|
+
const { handlers } = createHandlers();
|
|
204
|
+
const result = await handlers.callTool({ params: { name: 'hubium.get_context' } });
|
|
205
|
+
const output = result.content[0].text;
|
|
206
|
+
expect(output).not.toContain('secret');
|
|
207
|
+
});
|
|
208
|
+
it('hubium.whoami returns missing_base_url when unresolved', async () => {
|
|
209
|
+
const prev = { ...process.env };
|
|
210
|
+
try {
|
|
211
|
+
delete process.env.HUBIUM_BASE_URL;
|
|
212
|
+
delete process.env.HUBIUM_API_BASE_URL;
|
|
213
|
+
const { handlers } = createHandlers({ token: 'token' });
|
|
214
|
+
const result = await handlers.callTool({ params: { name: 'hubium.whoami' } });
|
|
215
|
+
const data = parseResult(result);
|
|
216
|
+
expect(data.ok).toBe(false);
|
|
217
|
+
expect(data.error).toBe('missing_base_url');
|
|
218
|
+
}
|
|
219
|
+
finally {
|
|
220
|
+
process.env = prev;
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
it('hubium.whoami returns missing_token when token unavailable', async () => {
|
|
224
|
+
const { handlers, manager } = createHandlers({ token: null });
|
|
225
|
+
await manager.save({ base_url: 'https://hubium.dev' });
|
|
226
|
+
const result = await handlers.callTool({ params: { name: 'hubium.whoami' } });
|
|
227
|
+
const data = parseResult(result);
|
|
228
|
+
expect(data.ok).toBe(false);
|
|
229
|
+
expect(data.error).toBe('missing_token');
|
|
230
|
+
});
|
|
231
|
+
it('hubium.whoami returns actor and workspace defaults', async () => {
|
|
232
|
+
const { handlers, manager } = createHandlers({
|
|
233
|
+
token: 'token',
|
|
234
|
+
whoamiResponse: { actor: { id: 'agent-1', actor_type: 'agent' } },
|
|
235
|
+
});
|
|
236
|
+
await manager.save({ base_url: 'https://hubium.dev', workspace_id: 'w1' });
|
|
237
|
+
const result = await handlers.callTool({ params: { name: 'hubium.whoami' } });
|
|
238
|
+
const data = parseResult(result);
|
|
239
|
+
expect(data.ok).toBe(true);
|
|
240
|
+
expect(data.actor).toEqual({ id: 'agent-1', actor_type: 'agent' });
|
|
241
|
+
expect(data.base_url).toBe('https://hubium.dev');
|
|
242
|
+
expect(data.workspace_defaults.workspace_id).toBe('w1');
|
|
243
|
+
expect(result.content[0].text).not.toContain('token');
|
|
244
|
+
});
|
|
245
|
+
it('hubium.search workspace precedence uses input over context', async () => {
|
|
246
|
+
const { handlers, manager, getLastSearchParams } = createHandlers({ token: 'token' });
|
|
247
|
+
await manager.save({ base_url: 'https://hubium.dev', workspace_ids: ['context'] });
|
|
248
|
+
await handlers.callTool({
|
|
249
|
+
params: {
|
|
250
|
+
name: 'hubium.search',
|
|
251
|
+
arguments: { query: 'test', workspace_id: 'explicit' },
|
|
252
|
+
},
|
|
253
|
+
});
|
|
254
|
+
expect(getLastSearchParams()).toMatchObject({ workspace_id: 'explicit' });
|
|
255
|
+
});
|
|
256
|
+
it('hubium.search falls back to whoami when no context', async () => {
|
|
257
|
+
const { handlers, manager, getLastSearchParams } = createHandlers({
|
|
258
|
+
token: 'token',
|
|
259
|
+
whoamiResponse: { actor: { id: 'agent-1' }, workspaces: [{ id: 'whoami' }] },
|
|
260
|
+
});
|
|
261
|
+
await manager.save({ base_url: 'https://hubium.dev' });
|
|
262
|
+
const result = await handlers.callTool({
|
|
263
|
+
params: { name: 'hubium.search', arguments: { query: 'test' } },
|
|
264
|
+
});
|
|
265
|
+
const data = parseResult(result);
|
|
266
|
+
expect(Array.isArray(data.items)).toBe(true);
|
|
267
|
+
expect(getLastSearchParams()).toMatchObject({ workspace_id: 'whoami' });
|
|
268
|
+
});
|
|
269
|
+
it('hubium.search uses context before whoami', async () => {
|
|
270
|
+
const { handlers, manager, getLastSearchParams } = createHandlers({
|
|
271
|
+
token: 'token',
|
|
272
|
+
whoamiResponse: { actor: { id: 'agent-1' }, workspaces: [{ id: 'whoami' }] },
|
|
273
|
+
});
|
|
274
|
+
await manager.save({ base_url: 'https://hubium.dev', workspace_id: 'context' });
|
|
275
|
+
await handlers.callTool({
|
|
276
|
+
params: { name: 'hubium.search', arguments: { query: 'test' } },
|
|
277
|
+
});
|
|
278
|
+
expect(getLastSearchParams()).toMatchObject({ workspace_id: 'context' });
|
|
279
|
+
});
|
|
280
|
+
it('hubium.search rejects both workspace_id and workspace_ids', async () => {
|
|
281
|
+
const { handlers } = createHandlers();
|
|
282
|
+
const result = await handlers.callTool({
|
|
283
|
+
params: {
|
|
284
|
+
name: 'hubium.search',
|
|
285
|
+
arguments: { query: 'test', workspace_id: 'w1', workspace_ids: ['w2'] },
|
|
286
|
+
},
|
|
287
|
+
});
|
|
288
|
+
const data = parseResult(result);
|
|
289
|
+
expect(data.error).toContain('Provide only one of workspace_id or workspace_ids');
|
|
290
|
+
});
|
|
291
|
+
it('hubium.search stores followup token in preview', async () => {
|
|
292
|
+
const { handlers, manager, tokenStore } = createHandlers({
|
|
293
|
+
searchResponse: {
|
|
294
|
+
items: [],
|
|
295
|
+
total: 0,
|
|
296
|
+
limit: 10,
|
|
297
|
+
offset: 0,
|
|
298
|
+
next_offset: null,
|
|
299
|
+
has_more: false,
|
|
300
|
+
access: {
|
|
301
|
+
mode: 'preview',
|
|
302
|
+
is_preview: true,
|
|
303
|
+
auth_required_for_full: true,
|
|
304
|
+
reason: 'ok',
|
|
305
|
+
},
|
|
306
|
+
search_session: {
|
|
307
|
+
id: 'session-1',
|
|
308
|
+
results_count: 0,
|
|
309
|
+
results_hash: 'hash',
|
|
310
|
+
results_changed: false,
|
|
311
|
+
new_case_ids: [],
|
|
312
|
+
removed_case_ids: [],
|
|
313
|
+
followup_token: 'followup',
|
|
314
|
+
is_followup: false,
|
|
315
|
+
status: 'open',
|
|
316
|
+
last_seen_at: null,
|
|
317
|
+
},
|
|
318
|
+
},
|
|
319
|
+
});
|
|
320
|
+
await manager.save({ base_url: 'https://hubium.dev' });
|
|
321
|
+
await handlers.callTool({
|
|
322
|
+
params: { name: 'hubium.search', arguments: { query: 'test', mode: 'preview' } },
|
|
323
|
+
});
|
|
324
|
+
expect(tokenStore.getFollowupToken()).toBe('followup');
|
|
325
|
+
});
|
|
326
|
+
it('clears followup token on context set and clear', async () => {
|
|
327
|
+
const { handlers, tokenStore } = createHandlers();
|
|
328
|
+
tokenStore.setFollowupToken('followup');
|
|
329
|
+
await handlers.callTool({
|
|
330
|
+
params: { name: 'hubium.set_context', arguments: { base_url: 'https://hubium.dev' } },
|
|
331
|
+
});
|
|
332
|
+
expect(tokenStore.getFollowupToken()).toBeNull();
|
|
333
|
+
tokenStore.setFollowupToken('followup');
|
|
334
|
+
await handlers.callTool({ params: { name: 'hubium.clear_context' } });
|
|
335
|
+
expect(tokenStore.getFollowupToken()).toBeNull();
|
|
336
|
+
});
|
|
337
|
+
it('hubium.search does not leak secrets in errors', async () => {
|
|
338
|
+
const error = new HubiumClientError('oops SECRET_TOKEN', { kind: 'network' });
|
|
339
|
+
const { handlers, manager } = createHandlers({ token: 'SECRET_TOKEN', searchError: error });
|
|
340
|
+
await manager.save({ base_url: 'https://hubium.dev' });
|
|
341
|
+
const result = await handlers.callTool({
|
|
342
|
+
params: { name: 'hubium.search', arguments: { query: 'test' } },
|
|
343
|
+
});
|
|
344
|
+
expect(result.content[0].text).not.toContain('SECRET_TOKEN');
|
|
345
|
+
});
|
|
346
|
+
it('hubium.search redacts env token in errors when tokenStore is empty (SEC-RED-01)', async () => {
|
|
347
|
+
const prev = { ...process.env };
|
|
348
|
+
try {
|
|
349
|
+
process.env.HUBIUM_TOKEN = 'SECRET_TOKEN';
|
|
350
|
+
const error = new HubiumClientError('oops SECRET_TOKEN', { kind: 'network' });
|
|
351
|
+
const { handlers, manager } = createHandlers({ token: null, searchError: error });
|
|
352
|
+
await manager.save({ base_url: 'https://hubium.dev' });
|
|
353
|
+
const result = await handlers.callTool({
|
|
354
|
+
params: { name: 'hubium.search', arguments: { query: 'test' } },
|
|
355
|
+
});
|
|
356
|
+
expect(result.content[0].text).not.toContain('SECRET_TOKEN');
|
|
357
|
+
}
|
|
358
|
+
finally {
|
|
359
|
+
process.env = prev;
|
|
360
|
+
}
|
|
361
|
+
});
|
|
362
|
+
it('hubium.search redacts HUBIUM_API_TOKEN in errors when tokenStore is empty (SEC-RED-02)', async () => {
|
|
363
|
+
const prev = { ...process.env };
|
|
364
|
+
try {
|
|
365
|
+
process.env.HUBIUM_API_TOKEN = 'SECRET_TOKEN';
|
|
366
|
+
const error = new HubiumClientError('oops SECRET_TOKEN', { kind: 'network' });
|
|
367
|
+
const { handlers, manager } = createHandlers({ token: null, searchError: error });
|
|
368
|
+
await manager.save({ base_url: 'https://hubium.dev' });
|
|
369
|
+
const result = await handlers.callTool({
|
|
370
|
+
params: { name: 'hubium.search', arguments: { query: 'test' } },
|
|
371
|
+
});
|
|
372
|
+
expect(result.content[0].text).not.toContain('SECRET_TOKEN');
|
|
373
|
+
}
|
|
374
|
+
finally {
|
|
375
|
+
process.env = prev;
|
|
376
|
+
}
|
|
377
|
+
});
|
|
378
|
+
it('hubium.search redacts followup token in errors (SEC-RED-03)', async () => {
|
|
379
|
+
const error = new HubiumClientError('oops FOLLOWUP_SECRET', { kind: 'network' });
|
|
380
|
+
const { handlers, manager, tokenStore } = createHandlers({
|
|
381
|
+
token: 'token',
|
|
382
|
+
searchError: error,
|
|
383
|
+
});
|
|
384
|
+
tokenStore.setFollowupToken('FOLLOWUP_SECRET');
|
|
385
|
+
await manager.save({ base_url: 'https://hubium.dev' });
|
|
386
|
+
const result = await handlers.callTool({
|
|
387
|
+
params: { name: 'hubium.search', arguments: { query: 'test' } },
|
|
388
|
+
});
|
|
389
|
+
expect(result.content[0].text).not.toContain('FOLLOWUP_SECRET');
|
|
390
|
+
});
|
|
391
|
+
it('hubium.get_case uses input workspace over context', async () => {
|
|
392
|
+
const { handlers, manager, getLastGetCase } = createHandlers({ token: 'token' });
|
|
393
|
+
await manager.save({ base_url: 'https://hubium.dev', workspace_id: 'context' });
|
|
394
|
+
await handlers.callTool({
|
|
395
|
+
params: {
|
|
396
|
+
name: 'hubium.get_case',
|
|
397
|
+
arguments: { case_id: '123', workspace_id: 'explicit' },
|
|
398
|
+
},
|
|
399
|
+
});
|
|
400
|
+
const last = getLastGetCase();
|
|
401
|
+
expect(last?.options).toMatchObject({ workspace_id: 'explicit' });
|
|
402
|
+
});
|
|
403
|
+
it('hubium.get_case uses context before whoami', async () => {
|
|
404
|
+
const { handlers, manager, getLastGetCase } = createHandlers({
|
|
405
|
+
token: 'token',
|
|
406
|
+
whoamiResponse: { actor: { id: 'agent-1' }, workspaces: [{ id: 'whoami' }] },
|
|
407
|
+
});
|
|
408
|
+
await manager.save({ base_url: 'https://hubium.dev', workspace_id: 'context' });
|
|
409
|
+
await handlers.callTool({
|
|
410
|
+
params: { name: 'hubium.get_case', arguments: { case_id: '123' } },
|
|
411
|
+
});
|
|
412
|
+
const last = getLastGetCase();
|
|
413
|
+
expect(last?.options).toMatchObject({ workspace_id: 'context' });
|
|
414
|
+
});
|
|
415
|
+
it('hubium.get_case falls back to whoami when no context', async () => {
|
|
416
|
+
const { handlers, manager, getLastGetCase } = createHandlers({
|
|
417
|
+
token: 'token',
|
|
418
|
+
whoamiResponse: { actor: { id: 'agent-1' }, workspaces: [{ id: 'whoami' }] },
|
|
419
|
+
});
|
|
420
|
+
await manager.save({ base_url: 'https://hubium.dev' });
|
|
421
|
+
await handlers.callTool({
|
|
422
|
+
params: { name: 'hubium.get_case', arguments: { case_id: '123' } },
|
|
423
|
+
});
|
|
424
|
+
const last = getLastGetCase();
|
|
425
|
+
expect(last?.options).toMatchObject({ workspace_id: 'whoami' });
|
|
426
|
+
});
|
|
427
|
+
it('hubium.get_case returns backend response', async () => {
|
|
428
|
+
const { handlers, manager } = createHandlers({
|
|
429
|
+
getCaseResponse: { case: { id: 42 }, access: { mode: 'full', is_preview: false, auth_required_for_full: false, reason: 'ok' } },
|
|
430
|
+
});
|
|
431
|
+
await manager.save({ base_url: 'https://hubium.dev' });
|
|
432
|
+
const result = await handlers.callTool({
|
|
433
|
+
params: { name: 'hubium.get_case', arguments: { case_id: '42' } },
|
|
434
|
+
});
|
|
435
|
+
const data = parseResult(result);
|
|
436
|
+
expect(data.case?.id).toBe(42);
|
|
437
|
+
});
|
|
438
|
+
it('hubium.get_case surfaces forbidden', async () => {
|
|
439
|
+
const { handlers, manager } = createHandlers({
|
|
440
|
+
getCaseError: new HubiumClientError('forbidden', { kind: 'http', status: 403 }),
|
|
441
|
+
});
|
|
442
|
+
await manager.save({ base_url: 'https://hubium.dev' });
|
|
443
|
+
const result = await handlers.callTool({
|
|
444
|
+
params: { name: 'hubium.get_case', arguments: { case_id: '42' } },
|
|
445
|
+
});
|
|
446
|
+
const data = parseResult(result);
|
|
447
|
+
expect(data.error).toBe('hubium_client_error');
|
|
448
|
+
expect(data.status).toBe(403);
|
|
449
|
+
});
|
|
450
|
+
it('hubium.get_case surfaces not found', async () => {
|
|
451
|
+
const { handlers, manager } = createHandlers({
|
|
452
|
+
getCaseError: new HubiumClientError('not found', { kind: 'http', status: 404 }),
|
|
453
|
+
});
|
|
454
|
+
await manager.save({ base_url: 'https://hubium.dev' });
|
|
455
|
+
const result = await handlers.callTool({
|
|
456
|
+
params: { name: 'hubium.get_case', arguments: { case_id: '42' } },
|
|
457
|
+
});
|
|
458
|
+
const data = parseResult(result);
|
|
459
|
+
expect(data.error).toBe('hubium_client_error');
|
|
460
|
+
expect(data.status).toBe(404);
|
|
461
|
+
});
|
|
462
|
+
it('hubium.get_case does not leak secrets in errors', async () => {
|
|
463
|
+
const { handlers, manager } = createHandlers({
|
|
464
|
+
token: 'SECRET_TOKEN',
|
|
465
|
+
getCaseError: new HubiumClientError('oops SECRET_TOKEN', { kind: 'network' }),
|
|
466
|
+
});
|
|
467
|
+
await manager.save({ base_url: 'https://hubium.dev' });
|
|
468
|
+
const result = await handlers.callTool({
|
|
469
|
+
params: { name: 'hubium.get_case', arguments: { case_id: '42' } },
|
|
470
|
+
});
|
|
471
|
+
expect(result.content[0].text).not.toContain('SECRET_TOKEN');
|
|
472
|
+
});
|
|
473
|
+
it('hubium.get_case redacts followup token in errors (SEC-RED-04)', async () => {
|
|
474
|
+
const error = new HubiumClientError('oops FOLLOWUP_SECRET', { kind: 'network' });
|
|
475
|
+
const { handlers, manager, tokenStore } = createHandlers({
|
|
476
|
+
token: 'token',
|
|
477
|
+
getCaseError: error,
|
|
478
|
+
});
|
|
479
|
+
tokenStore.setFollowupToken('FOLLOWUP_SECRET');
|
|
480
|
+
await manager.save({ base_url: 'https://hubium.dev' });
|
|
481
|
+
const result = await handlers.callTool({
|
|
482
|
+
params: { name: 'hubium.get_case', arguments: { case_id: '42' } },
|
|
483
|
+
});
|
|
484
|
+
expect(result.content[0].text).not.toContain('FOLLOWUP_SECRET');
|
|
485
|
+
});
|
|
486
|
+
it('hubium.create_case rejects missing visibility_intent', async () => {
|
|
487
|
+
const { handlers, manager } = createHandlers({ token: 'token' });
|
|
488
|
+
await manager.save({ base_url: 'https://hubium.dev', workspace_id: 'w1' });
|
|
489
|
+
const result = await handlers.callTool({
|
|
490
|
+
params: { name: 'hubium.create_case', arguments: { title: 'Title' } },
|
|
491
|
+
});
|
|
492
|
+
const data = parseResult(result);
|
|
493
|
+
expect(data.error).toContain('visibility_intent is required');
|
|
494
|
+
});
|
|
495
|
+
it('hubium.create_case rejects missing token', async () => {
|
|
496
|
+
const { handlers, manager } = createHandlers({ token: null });
|
|
497
|
+
await manager.save({ base_url: 'https://hubium.dev', workspace_id: 'w1' });
|
|
498
|
+
const result = await handlers.callTool({
|
|
499
|
+
params: {
|
|
500
|
+
name: 'hubium.create_case',
|
|
501
|
+
arguments: { title: 'Title', visibility_intent: 'public' },
|
|
502
|
+
},
|
|
503
|
+
});
|
|
504
|
+
const data = parseResult(result);
|
|
505
|
+
expect(data.error).toBe('missing_token');
|
|
506
|
+
});
|
|
507
|
+
it('hubium.create_case rejects workspace_ids input', async () => {
|
|
508
|
+
const { handlers, manager } = createHandlers({ token: 'token' });
|
|
509
|
+
await manager.save({ base_url: 'https://hubium.dev', workspace_id: 'w1' });
|
|
510
|
+
const result = await handlers.callTool({
|
|
511
|
+
params: {
|
|
512
|
+
name: 'hubium.create_case',
|
|
513
|
+
arguments: { title: 'Title', visibility_intent: 'public', workspace_ids: ['w1'] },
|
|
514
|
+
},
|
|
515
|
+
});
|
|
516
|
+
const data = parseResult(result);
|
|
517
|
+
expect(data.error).toContain('workspace_ids is not supported');
|
|
518
|
+
});
|
|
519
|
+
it('hubium.create_case rejects when context has workspace_ids', async () => {
|
|
520
|
+
const { handlers, manager } = createHandlers({ token: 'token' });
|
|
521
|
+
await manager.save({ base_url: 'https://hubium.dev', workspace_ids: ['w1', 'w2'] });
|
|
522
|
+
const result = await handlers.callTool({
|
|
523
|
+
params: {
|
|
524
|
+
name: 'hubium.create_case',
|
|
525
|
+
arguments: { title: 'Title', visibility_intent: 'public' },
|
|
526
|
+
},
|
|
527
|
+
});
|
|
528
|
+
const data = parseResult(result);
|
|
529
|
+
expect(data.error).toBe('missing_workspace');
|
|
530
|
+
});
|
|
531
|
+
it('hubium.create_case uses explicit workspace over context', async () => {
|
|
532
|
+
const { handlers, manager, getLastCreateCase } = createHandlers({ token: 'token' });
|
|
533
|
+
await manager.save({ base_url: 'https://hubium.dev', workspace_id: 'context' });
|
|
534
|
+
await handlers.callTool({
|
|
535
|
+
params: {
|
|
536
|
+
name: 'hubium.create_case',
|
|
537
|
+
arguments: { title: 'Title', visibility_intent: 'public', workspace_id: 'explicit' },
|
|
538
|
+
},
|
|
539
|
+
});
|
|
540
|
+
expect(getLastCreateCase()?.payload).toMatchObject({ workspace_id: 'explicit' });
|
|
541
|
+
});
|
|
542
|
+
it('hubium.create_case uses context before whoami', async () => {
|
|
543
|
+
const { handlers, manager, getLastCreateCase } = createHandlers({
|
|
544
|
+
token: 'token',
|
|
545
|
+
whoamiResponse: { actor: { id: 'agent-1' }, workspaces: [{ id: 'whoami' }] },
|
|
546
|
+
});
|
|
547
|
+
await manager.save({ base_url: 'https://hubium.dev', workspace_id: 'context' });
|
|
548
|
+
await handlers.callTool({
|
|
549
|
+
params: {
|
|
550
|
+
name: 'hubium.create_case',
|
|
551
|
+
arguments: { title: 'Title', visibility_intent: 'public' },
|
|
552
|
+
},
|
|
553
|
+
});
|
|
554
|
+
expect(getLastCreateCase()?.payload).toMatchObject({ workspace_id: 'context' });
|
|
555
|
+
});
|
|
556
|
+
it('hubium.create_case falls back to whoami when no context', async () => {
|
|
557
|
+
const { handlers, manager, getLastCreateCase } = createHandlers({
|
|
558
|
+
token: 'token',
|
|
559
|
+
whoamiResponse: { actor: { id: 'agent-1' }, workspaces: [{ id: 'whoami' }] },
|
|
560
|
+
});
|
|
561
|
+
await manager.save({ base_url: 'https://hubium.dev' });
|
|
562
|
+
await handlers.callTool({
|
|
563
|
+
params: {
|
|
564
|
+
name: 'hubium.create_case',
|
|
565
|
+
arguments: { title: 'Title', visibility_intent: 'public' },
|
|
566
|
+
},
|
|
567
|
+
});
|
|
568
|
+
expect(getLastCreateCase()?.payload).toMatchObject({ workspace_id: 'whoami' });
|
|
569
|
+
});
|
|
570
|
+
it('hubium.create_case passes idempotency key', async () => {
|
|
571
|
+
const { handlers, manager, getLastCreateCase } = createHandlers({ token: 'token' });
|
|
572
|
+
await manager.save({ base_url: 'https://hubium.dev', workspace_id: 'w1' });
|
|
573
|
+
await handlers.callTool({
|
|
574
|
+
params: {
|
|
575
|
+
name: 'hubium.create_case',
|
|
576
|
+
arguments: {
|
|
577
|
+
title: 'Title',
|
|
578
|
+
visibility_intent: 'public',
|
|
579
|
+
idempotency_key: 'idem',
|
|
580
|
+
},
|
|
581
|
+
},
|
|
582
|
+
});
|
|
583
|
+
expect(getLastCreateCase()?.options).toMatchObject({ idempotencyKey: 'idem' });
|
|
584
|
+
});
|
|
585
|
+
it('hubium.create_case surfaces forbidden', async () => {
|
|
586
|
+
const { handlers, manager } = createHandlers({
|
|
587
|
+
token: 'token',
|
|
588
|
+
createCaseError: new HubiumClientError('forbidden', { kind: 'http', status: 403 }),
|
|
589
|
+
});
|
|
590
|
+
await manager.save({ base_url: 'https://hubium.dev', workspace_id: 'w1' });
|
|
591
|
+
const result = await handlers.callTool({
|
|
592
|
+
params: {
|
|
593
|
+
name: 'hubium.create_case',
|
|
594
|
+
arguments: { title: 'Title', visibility_intent: 'public' },
|
|
595
|
+
},
|
|
596
|
+
});
|
|
597
|
+
const data = parseResult(result);
|
|
598
|
+
expect(data.error).toBe('hubium_client_error');
|
|
599
|
+
expect(data.status).toBe(403);
|
|
600
|
+
});
|
|
601
|
+
it('hubium.create_case does not leak idempotency key', async () => {
|
|
602
|
+
const { handlers, manager } = createHandlers({
|
|
603
|
+
token: 'token',
|
|
604
|
+
createCaseError: new HubiumClientError('oops IDEM', { kind: 'network' }),
|
|
605
|
+
});
|
|
606
|
+
await manager.save({ base_url: 'https://hubium.dev', workspace_id: 'w1' });
|
|
607
|
+
const result = await handlers.callTool({
|
|
608
|
+
params: {
|
|
609
|
+
name: 'hubium.create_case',
|
|
610
|
+
arguments: {
|
|
611
|
+
title: 'Title',
|
|
612
|
+
visibility_intent: 'public',
|
|
613
|
+
idempotency_key: 'IDEM',
|
|
614
|
+
},
|
|
615
|
+
},
|
|
616
|
+
});
|
|
617
|
+
expect(result.content[0].text).not.toContain('IDEM');
|
|
618
|
+
});
|
|
619
|
+
it('hubium.create_case redacts followup token in errors (SEC-RED-04)', async () => {
|
|
620
|
+
const error = new HubiumClientError('oops FOLLOWUP_SECRET', { kind: 'network' });
|
|
621
|
+
const { handlers, manager, tokenStore } = createHandlers({
|
|
622
|
+
token: 'token',
|
|
623
|
+
createCaseError: error,
|
|
624
|
+
});
|
|
625
|
+
tokenStore.setFollowupToken('FOLLOWUP_SECRET');
|
|
626
|
+
await manager.save({ base_url: 'https://hubium.dev', workspace_id: 'w1' });
|
|
627
|
+
const result = await handlers.callTool({
|
|
628
|
+
params: {
|
|
629
|
+
name: 'hubium.create_case',
|
|
630
|
+
arguments: { title: 'Title', visibility_intent: 'public' },
|
|
631
|
+
},
|
|
632
|
+
});
|
|
633
|
+
expect(result.content[0].text).not.toContain('FOLLOWUP_SECRET');
|
|
634
|
+
});
|
|
635
|
+
it('hubium.feedback rejects missing content', async () => {
|
|
636
|
+
const { handlers, manager } = createHandlers({ token: 'token' });
|
|
637
|
+
await manager.save({ base_url: 'https://hubium.dev', workspace_id: 'w1' });
|
|
638
|
+
const result = await handlers.callTool({
|
|
639
|
+
params: {
|
|
640
|
+
name: 'hubium.feedback',
|
|
641
|
+
arguments: { case_id: '1', feedback_type: 'comment', content: ' ' },
|
|
642
|
+
},
|
|
643
|
+
});
|
|
644
|
+
const data = parseResult(result);
|
|
645
|
+
expect(data.error).toContain('content is required');
|
|
646
|
+
});
|
|
647
|
+
it('hubium.feedback rejects missing feedback_type', async () => {
|
|
648
|
+
const { handlers, manager } = createHandlers({ token: 'token' });
|
|
649
|
+
await manager.save({ base_url: 'https://hubium.dev', workspace_id: 'w1' });
|
|
650
|
+
const result = await handlers.callTool({
|
|
651
|
+
params: {
|
|
652
|
+
name: 'hubium.feedback',
|
|
653
|
+
arguments: { case_id: '1', content: 'ok' },
|
|
654
|
+
},
|
|
655
|
+
});
|
|
656
|
+
const data = parseResult(result);
|
|
657
|
+
expect(data.error).toContain('feedback_type is required');
|
|
658
|
+
});
|
|
659
|
+
it('hubium.feedback rejects missing token', async () => {
|
|
660
|
+
const { handlers, manager } = createHandlers({ token: null });
|
|
661
|
+
await manager.save({ base_url: 'https://hubium.dev', workspace_id: 'w1' });
|
|
662
|
+
const result = await handlers.callTool({
|
|
663
|
+
params: {
|
|
664
|
+
name: 'hubium.feedback',
|
|
665
|
+
arguments: { case_id: '1', feedback_type: 'comment', content: 'ok' },
|
|
666
|
+
},
|
|
667
|
+
});
|
|
668
|
+
const data = parseResult(result);
|
|
669
|
+
expect(data.error).toBe('missing_token');
|
|
670
|
+
});
|
|
671
|
+
it('hubium.feedback rejects workspace_ids input', async () => {
|
|
672
|
+
const { handlers, manager } = createHandlers({ token: 'token' });
|
|
673
|
+
await manager.save({ base_url: 'https://hubium.dev', workspace_id: 'w1' });
|
|
674
|
+
const result = await handlers.callTool({
|
|
675
|
+
params: {
|
|
676
|
+
name: 'hubium.feedback',
|
|
677
|
+
arguments: {
|
|
678
|
+
case_id: '1',
|
|
679
|
+
feedback_type: 'comment',
|
|
680
|
+
content: 'ok',
|
|
681
|
+
workspace_ids: ['w1'],
|
|
682
|
+
},
|
|
683
|
+
},
|
|
684
|
+
});
|
|
685
|
+
const data = parseResult(result);
|
|
686
|
+
expect(data.error).toContain('workspace_ids is not supported');
|
|
687
|
+
});
|
|
688
|
+
it('hubium.feedback rejects invalid visibility', async () => {
|
|
689
|
+
const { handlers, manager } = createHandlers({ token: 'token' });
|
|
690
|
+
await manager.save({ base_url: 'https://hubium.dev', workspace_id: 'w1' });
|
|
691
|
+
const result = await handlers.callTool({
|
|
692
|
+
params: {
|
|
693
|
+
name: 'hubium.feedback',
|
|
694
|
+
arguments: {
|
|
695
|
+
case_id: '1',
|
|
696
|
+
feedback_type: 'comment',
|
|
697
|
+
content: 'ok',
|
|
698
|
+
visibility: 'external',
|
|
699
|
+
},
|
|
700
|
+
},
|
|
701
|
+
});
|
|
702
|
+
const data = parseResult(result);
|
|
703
|
+
expect(data.error).toContain('visibility must be one of');
|
|
704
|
+
});
|
|
705
|
+
it('hubium.feedback passes valid visibility through to client', async () => {
|
|
706
|
+
const { handlers, manager, getLastFeedback } = createHandlers({ token: 'token' });
|
|
707
|
+
await manager.save({ base_url: 'https://hubium.dev', workspace_id: 'w1' });
|
|
708
|
+
await handlers.callTool({
|
|
709
|
+
params: {
|
|
710
|
+
name: 'hubium.feedback',
|
|
711
|
+
arguments: {
|
|
712
|
+
case_id: '1',
|
|
713
|
+
feedback_type: 'comment',
|
|
714
|
+
content: 'ok',
|
|
715
|
+
visibility: 'public',
|
|
716
|
+
},
|
|
717
|
+
},
|
|
718
|
+
});
|
|
719
|
+
expect(getLastFeedback()?.payload).toMatchObject({ visibility: 'public' });
|
|
720
|
+
});
|
|
721
|
+
it('safe-mode blocks mutations and auth tools', async () => {
|
|
722
|
+
const { handlers, manager } = createHandlers({ safeMode: true, token: 'token' });
|
|
723
|
+
await manager.save({ base_url: 'https://hubium.dev', workspace_id: 'w1' });
|
|
724
|
+
const createResult = await handlers.callTool({
|
|
725
|
+
params: { name: 'hubium.create_case', arguments: { title: 'Title', visibility_intent: 'public' } },
|
|
726
|
+
});
|
|
727
|
+
expect(parseResult(createResult)).toMatchObject({ error: 'safe_mode' });
|
|
728
|
+
const feedbackResult = await handlers.callTool({
|
|
729
|
+
params: { name: 'hubium.feedback', arguments: { case_id: '1', feedback_type: 'comment', content: 'ok' } },
|
|
730
|
+
});
|
|
731
|
+
expect(parseResult(feedbackResult)).toMatchObject({ error: 'safe_mode' });
|
|
732
|
+
const whoamiResult = await handlers.callTool({ params: { name: 'hubium.whoami' } });
|
|
733
|
+
expect(parseResult(whoamiResult)).toMatchObject({ error: 'safe_mode' });
|
|
734
|
+
});
|
|
735
|
+
it('safe-mode forces preview and anonymous access', async () => {
|
|
736
|
+
const { handlers, manager, getLastSearchOptions, getLastSearchParams, getLastGetCase, tokenStore, getLastClientToken } = createHandlers({
|
|
737
|
+
safeMode: true,
|
|
738
|
+
token: 'token',
|
|
739
|
+
});
|
|
740
|
+
await manager.save({ base_url: 'https://hubium.dev', workspace_id: 'w1' });
|
|
741
|
+
tokenStore.setFollowupToken('followup');
|
|
742
|
+
await handlers.callTool({
|
|
743
|
+
params: { name: 'hubium.search', arguments: { query: 'test', mode: 'full' } },
|
|
744
|
+
});
|
|
745
|
+
const options = getLastSearchOptions();
|
|
746
|
+
expect(options === null || options?.followupToken === undefined).toBe(true);
|
|
747
|
+
expect(getLastClientToken()).toBeUndefined();
|
|
748
|
+
expect(getLastSearchParams()).toMatchObject({ workspace_id: 'w1' });
|
|
749
|
+
await handlers.callTool({
|
|
750
|
+
params: { name: 'hubium.get_case', arguments: { case_id: '1' } },
|
|
751
|
+
});
|
|
752
|
+
expect(getLastGetCase()?.options).toMatchObject({ workspace_id: 'w1' });
|
|
753
|
+
expect(getLastClientToken()).toBeUndefined();
|
|
754
|
+
});
|
|
755
|
+
it('hubium.search passes followup token in full mode when available', async () => {
|
|
756
|
+
const { handlers, manager, tokenStore, getLastSearchOptions } = createHandlers({ token: 'token' });
|
|
757
|
+
await manager.save({ base_url: 'https://hubium.dev', workspace_id: 'w1' });
|
|
758
|
+
tokenStore.setFollowupToken('followup');
|
|
759
|
+
await handlers.callTool({
|
|
760
|
+
params: { name: 'hubium.search', arguments: { query: 'test', mode: 'full' } },
|
|
761
|
+
});
|
|
762
|
+
expect(getLastSearchOptions()).toMatchObject({ followupToken: 'followup' });
|
|
763
|
+
});
|
|
764
|
+
it('hubium.create_case rejects oversized optional fields', async () => {
|
|
765
|
+
const { handlers, manager } = createHandlers({ token: 'token' });
|
|
766
|
+
await manager.save({ base_url: 'https://hubium.dev', workspace_id: 'w1' });
|
|
767
|
+
const basePayload = {
|
|
768
|
+
title: 'Title',
|
|
769
|
+
visibility_intent: 'public',
|
|
770
|
+
};
|
|
771
|
+
const longValue = 'x'.repeat(10001);
|
|
772
|
+
const fields = [
|
|
773
|
+
'description',
|
|
774
|
+
'final_solution',
|
|
775
|
+
'environment',
|
|
776
|
+
'steps_to_reproduce',
|
|
777
|
+
'expected',
|
|
778
|
+
'actual',
|
|
779
|
+
];
|
|
780
|
+
for (const field of fields) {
|
|
781
|
+
const result = await handlers.callTool({
|
|
782
|
+
params: {
|
|
783
|
+
name: 'hubium.create_case',
|
|
784
|
+
arguments: { ...basePayload, [field]: longValue },
|
|
785
|
+
},
|
|
786
|
+
});
|
|
787
|
+
const data = parseResult(result);
|
|
788
|
+
expect(data.error).toBe(`${field} exceeds max length 10000`);
|
|
789
|
+
}
|
|
790
|
+
});
|
|
791
|
+
it('rate limiter blocks after threshold', async () => {
|
|
792
|
+
const limiter = new LocalRateLimiter(1, 60_000);
|
|
793
|
+
const { handlers, manager } = createHandlers({ rateLimiter: limiter });
|
|
794
|
+
await manager.save({ base_url: 'https://hubium.dev', workspace_id: 'w1' });
|
|
795
|
+
await handlers.callTool({
|
|
796
|
+
params: { name: 'hubium.search', arguments: { query: 'test' } },
|
|
797
|
+
});
|
|
798
|
+
const second = await handlers.callTool({
|
|
799
|
+
params: { name: 'hubium.search', arguments: { query: 'test' } },
|
|
800
|
+
});
|
|
801
|
+
const data = parseResult(second);
|
|
802
|
+
expect(data.error).toBe('rate_limited');
|
|
803
|
+
expect(typeof data.retry_after_ms).toBe('number');
|
|
804
|
+
});
|
|
805
|
+
it('payload caps reject oversized inputs', async () => {
|
|
806
|
+
const { handlers, manager } = createHandlers({ token: 'token' });
|
|
807
|
+
await manager.save({ base_url: 'https://hubium.dev', workspace_id: 'w1' });
|
|
808
|
+
const longQuery = 'q'.repeat(2001);
|
|
809
|
+
const searchResult = await handlers.callTool({
|
|
810
|
+
params: { name: 'hubium.search', arguments: { query: longQuery } },
|
|
811
|
+
});
|
|
812
|
+
expect(parseResult(searchResult)).toMatchObject({ error: 'query exceeds max length 2000' });
|
|
813
|
+
const longTitle = 't'.repeat(201);
|
|
814
|
+
const createResult = await handlers.callTool({
|
|
815
|
+
params: {
|
|
816
|
+
name: 'hubium.create_case',
|
|
817
|
+
arguments: { title: longTitle, visibility_intent: 'public' },
|
|
818
|
+
},
|
|
819
|
+
});
|
|
820
|
+
expect(parseResult(createResult)).toMatchObject({ error: 'title exceeds max length 200' });
|
|
821
|
+
const longContent = 'c'.repeat(10001);
|
|
822
|
+
const feedbackResult = await handlers.callTool({
|
|
823
|
+
params: {
|
|
824
|
+
name: 'hubium.feedback',
|
|
825
|
+
arguments: { case_id: '1', feedback_type: 'comment', content: longContent },
|
|
826
|
+
},
|
|
827
|
+
});
|
|
828
|
+
expect(parseResult(feedbackResult)).toMatchObject({ error: 'content exceeds max length 10000' });
|
|
829
|
+
});
|
|
830
|
+
it('hubium.feedback uses explicit workspace over context', async () => {
|
|
831
|
+
const { handlers, manager, getLastFeedback } = createHandlers({ token: 'token' });
|
|
832
|
+
await manager.save({ base_url: 'https://hubium.dev', workspace_id: 'context' });
|
|
833
|
+
await handlers.callTool({
|
|
834
|
+
params: {
|
|
835
|
+
name: 'hubium.feedback',
|
|
836
|
+
arguments: {
|
|
837
|
+
case_id: '1',
|
|
838
|
+
feedback_type: 'comment',
|
|
839
|
+
content: 'ok',
|
|
840
|
+
workspace_id: 'explicit',
|
|
841
|
+
},
|
|
842
|
+
},
|
|
843
|
+
});
|
|
844
|
+
expect(getLastFeedback()?.payload).toMatchObject({ workspace_id: 'explicit' });
|
|
845
|
+
});
|
|
846
|
+
it('hubium.feedback uses context before whoami', async () => {
|
|
847
|
+
const { handlers, manager, getLastFeedback } = createHandlers({
|
|
848
|
+
token: 'token',
|
|
849
|
+
whoamiResponse: { actor: { id: 'agent-1' }, workspaces: [{ id: 'whoami' }] },
|
|
850
|
+
});
|
|
851
|
+
await manager.save({ base_url: 'https://hubium.dev', workspace_id: 'context' });
|
|
852
|
+
await handlers.callTool({
|
|
853
|
+
params: {
|
|
854
|
+
name: 'hubium.feedback',
|
|
855
|
+
arguments: { case_id: '1', feedback_type: 'comment', content: 'ok' },
|
|
856
|
+
},
|
|
857
|
+
});
|
|
858
|
+
expect(getLastFeedback()?.payload).toMatchObject({ workspace_id: 'context' });
|
|
859
|
+
});
|
|
860
|
+
it('hubium.feedback falls back to whoami when no context', async () => {
|
|
861
|
+
const { handlers, manager, getLastFeedback } = createHandlers({
|
|
862
|
+
token: 'token',
|
|
863
|
+
whoamiResponse: { actor: { id: 'agent-1' }, workspaces: [{ id: 'whoami' }] },
|
|
864
|
+
});
|
|
865
|
+
await manager.save({ base_url: 'https://hubium.dev' });
|
|
866
|
+
await handlers.callTool({
|
|
867
|
+
params: {
|
|
868
|
+
name: 'hubium.feedback',
|
|
869
|
+
arguments: { case_id: '1', feedback_type: 'comment', content: 'ok' },
|
|
870
|
+
},
|
|
871
|
+
});
|
|
872
|
+
expect(getLastFeedback()?.payload).toMatchObject({ workspace_id: 'whoami' });
|
|
873
|
+
});
|
|
874
|
+
it('hubium.feedback returns backend response', async () => {
|
|
875
|
+
const { handlers, manager } = createHandlers({
|
|
876
|
+
token: 'token',
|
|
877
|
+
feedbackResponse: { feedback: { id: 2 }, audit_id: 'audit' },
|
|
878
|
+
});
|
|
879
|
+
await manager.save({ base_url: 'https://hubium.dev', workspace_id: 'w1' });
|
|
880
|
+
const result = await handlers.callTool({
|
|
881
|
+
params: {
|
|
882
|
+
name: 'hubium.feedback',
|
|
883
|
+
arguments: { case_id: '1', feedback_type: 'comment', content: 'ok' },
|
|
884
|
+
},
|
|
885
|
+
});
|
|
886
|
+
const data = parseResult(result);
|
|
887
|
+
expect(data.feedback?.id).toBe(2);
|
|
888
|
+
});
|
|
889
|
+
it('hubium.feedback surfaces forbidden', async () => {
|
|
890
|
+
const { handlers, manager } = createHandlers({
|
|
891
|
+
token: 'token',
|
|
892
|
+
feedbackError: new HubiumClientError('forbidden', { kind: 'http', status: 403 }),
|
|
893
|
+
});
|
|
894
|
+
await manager.save({ base_url: 'https://hubium.dev', workspace_id: 'w1' });
|
|
895
|
+
const result = await handlers.callTool({
|
|
896
|
+
params: {
|
|
897
|
+
name: 'hubium.feedback',
|
|
898
|
+
arguments: { case_id: '1', feedback_type: 'comment', content: 'ok' },
|
|
899
|
+
},
|
|
900
|
+
});
|
|
901
|
+
const data = parseResult(result);
|
|
902
|
+
expect(data.error).toBe('hubium_client_error');
|
|
903
|
+
expect(data.status).toBe(403);
|
|
904
|
+
});
|
|
905
|
+
it('hubium.feedback does not leak idempotency key', async () => {
|
|
906
|
+
const { handlers, manager } = createHandlers({
|
|
907
|
+
token: 'token',
|
|
908
|
+
feedbackError: new HubiumClientError('oops IDEM', { kind: 'network' }),
|
|
909
|
+
});
|
|
910
|
+
await manager.save({ base_url: 'https://hubium.dev', workspace_id: 'w1' });
|
|
911
|
+
const result = await handlers.callTool({
|
|
912
|
+
params: {
|
|
913
|
+
name: 'hubium.feedback',
|
|
914
|
+
arguments: {
|
|
915
|
+
case_id: '1',
|
|
916
|
+
feedback_type: 'comment',
|
|
917
|
+
content: 'ok',
|
|
918
|
+
idempotency_key: 'IDEM',
|
|
919
|
+
},
|
|
920
|
+
},
|
|
921
|
+
});
|
|
922
|
+
expect(result.content[0].text).not.toContain('IDEM');
|
|
923
|
+
});
|
|
924
|
+
it('hubium.feedback redacts followup token in errors (SEC-RED-04)', async () => {
|
|
925
|
+
const error = new HubiumClientError('oops FOLLOWUP_SECRET', { kind: 'network' });
|
|
926
|
+
const { handlers, manager, tokenStore } = createHandlers({
|
|
927
|
+
token: 'token',
|
|
928
|
+
feedbackError: error,
|
|
929
|
+
});
|
|
930
|
+
tokenStore.setFollowupToken('FOLLOWUP_SECRET');
|
|
931
|
+
await manager.save({ base_url: 'https://hubium.dev', workspace_id: 'w1' });
|
|
932
|
+
const result = await handlers.callTool({
|
|
933
|
+
params: {
|
|
934
|
+
name: 'hubium.feedback',
|
|
935
|
+
arguments: { case_id: '1', feedback_type: 'comment', content: 'ok' },
|
|
936
|
+
},
|
|
937
|
+
});
|
|
938
|
+
expect(result.content[0].text).not.toContain('FOLLOWUP_SECRET');
|
|
939
|
+
});
|
|
940
|
+
it('OPS-T01: structured stderr logs, redaction, request_id in tool error output', async () => {
|
|
941
|
+
const prev = { ...process.env };
|
|
942
|
+
try {
|
|
943
|
+
process.env.HUBIUM_TOKEN = 'SECRET_TOKEN';
|
|
944
|
+
const error = new HubiumClientError('oops SECRET_TOKEN FOLLOWUP_SECRET IDEM', {
|
|
945
|
+
kind: 'network',
|
|
946
|
+
requestId: 'rid_123',
|
|
947
|
+
});
|
|
948
|
+
const { handlers, manager, tokenStore } = createHandlers({
|
|
949
|
+
token: 'SECRET_TOKEN',
|
|
950
|
+
feedbackError: error,
|
|
951
|
+
});
|
|
952
|
+
tokenStore.setFollowupToken('FOLLOWUP_SECRET');
|
|
953
|
+
await manager.save({ base_url: 'https://hubium.dev', workspace_id: 'w1' });
|
|
954
|
+
let stderr = '';
|
|
955
|
+
const originalWrite = process.stderr.write.bind(process.stderr);
|
|
956
|
+
process.stderr.write = ((chunk, ...args) => {
|
|
957
|
+
stderr += String(chunk);
|
|
958
|
+
return originalWrite(chunk, ...args);
|
|
959
|
+
});
|
|
960
|
+
const result = await handlers.callTool({
|
|
961
|
+
params: {
|
|
962
|
+
name: 'hubium.feedback',
|
|
963
|
+
arguments: {
|
|
964
|
+
case_id: '1',
|
|
965
|
+
feedback_type: 'comment',
|
|
966
|
+
content: 'ok',
|
|
967
|
+
idempotency_key: 'IDEM',
|
|
968
|
+
},
|
|
969
|
+
},
|
|
970
|
+
});
|
|
971
|
+
process.stderr.write = originalWrite;
|
|
972
|
+
expect(stderr).toContain('local_request_id');
|
|
973
|
+
expect(stderr).toContain('duration_ms');
|
|
974
|
+
expect(stderr).toContain('backend_request_id');
|
|
975
|
+
expect(stderr).toContain('rid_123');
|
|
976
|
+
expect(stderr).not.toContain('SECRET_TOKEN');
|
|
977
|
+
expect(stderr).not.toContain('FOLLOWUP_SECRET');
|
|
978
|
+
expect(stderr).not.toContain('IDEM');
|
|
979
|
+
const parsed = parseResult(result);
|
|
980
|
+
expect(parsed.error).toBe('hubium_client_error');
|
|
981
|
+
expect(parsed.requestId ?? parsed.request_id).toBe('rid_123');
|
|
982
|
+
expect(result.content[0].text).not.toContain('SECRET_TOKEN');
|
|
983
|
+
expect(result.content[0].text).not.toContain('FOLLOWUP_SECRET');
|
|
984
|
+
expect(result.content[0].text).not.toContain('IDEM');
|
|
985
|
+
}
|
|
986
|
+
finally {
|
|
987
|
+
process.env = prev;
|
|
988
|
+
}
|
|
989
|
+
});
|
|
990
|
+
});
|
|
991
|
+
//# sourceMappingURL=server.tools.test.js.map
|