agentgate-mcp 0.2.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/ARCHITECTURE.md +56 -0
- package/MCP_TOOLS.md +45 -0
- package/README.md +142 -0
- package/package.json +51 -0
- package/services/_template.service.json +34 -0
- package/src/browser-runtime.js +411 -0
- package/src/cli.js +117 -0
- package/src/config.js +49 -0
- package/src/db.js +89 -0
- package/src/integrations/captcha-solver.js +128 -0
- package/src/integrations/gmail-watcher.js +129 -0
- package/src/logger.js +120 -0
- package/src/mcp-server.js +204 -0
- package/src/orchestrator.js +107 -0
- package/src/playwright-engine.js +391 -0
- package/src/registry.js +47 -0
- package/src/scaffold.js +103 -0
- package/src/setup.js +109 -0
- package/src/signup-engine.js +24 -0
- package/src/vault.js +105 -0
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import { BrowserRuntime } from './browser-runtime.js';
|
|
3
|
+
import { CaptchaSolver } from './integrations/captcha-solver.js';
|
|
4
|
+
import { GmailWatcher } from './integrations/gmail-watcher.js';
|
|
5
|
+
import { createLogger } from './logger.js';
|
|
6
|
+
|
|
7
|
+
const log = createLogger('playwright');
|
|
8
|
+
|
|
9
|
+
async function importPlaywright() {
|
|
10
|
+
try {
|
|
11
|
+
return await import('playwright');
|
|
12
|
+
} catch {
|
|
13
|
+
throw new Error(
|
|
14
|
+
'Playwright is not installed. Run `npm i playwright` and `npx playwright install chromium`.'
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Common Google OAuth button selectors
|
|
20
|
+
const GOOGLE_SIGN_IN_SELECTORS = [
|
|
21
|
+
'button:has-text("Continue with Google")',
|
|
22
|
+
'button:has-text("Sign in with Google")',
|
|
23
|
+
'button:has-text("Sign up with Google")',
|
|
24
|
+
'a:has-text("Continue with Google")',
|
|
25
|
+
'a:has-text("Sign in with Google")',
|
|
26
|
+
'a:has-text("Sign up with Google")',
|
|
27
|
+
'button:has-text("Google")',
|
|
28
|
+
'a:has-text("Google")',
|
|
29
|
+
'[data-provider="google"]',
|
|
30
|
+
'[data-action="google"]',
|
|
31
|
+
'[aria-label*="Google"]',
|
|
32
|
+
'.google-login',
|
|
33
|
+
'#google-signin',
|
|
34
|
+
'[class*="google"]'
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
// Common "Create API Key" button selectors
|
|
38
|
+
const CREATE_KEY_SELECTORS = [
|
|
39
|
+
'button:has-text("Create API Key")',
|
|
40
|
+
'button:has-text("Create API key")',
|
|
41
|
+
'button:has-text("Create new key")',
|
|
42
|
+
'button:has-text("Generate API Key")',
|
|
43
|
+
'button:has-text("Generate Key")',
|
|
44
|
+
'button:has-text("Generate new key")',
|
|
45
|
+
'button:has-text("Create key")',
|
|
46
|
+
'button:has-text("New Key")',
|
|
47
|
+
'button:has-text("Add Key")',
|
|
48
|
+
'button:has-text("Create secret key")',
|
|
49
|
+
'a:has-text("Create API Key")',
|
|
50
|
+
'a:has-text("Create key")',
|
|
51
|
+
'a:has-text("Generate Key")',
|
|
52
|
+
'a:has-text("New Key")',
|
|
53
|
+
'[data-testid*="create"][data-testid*="key"]',
|
|
54
|
+
'[data-testid*="generate"][data-testid*="key"]'
|
|
55
|
+
];
|
|
56
|
+
|
|
57
|
+
// Selectors that might contain a displayed API key
|
|
58
|
+
const KEY_EXTRACT_SELECTORS = [
|
|
59
|
+
'input[type="text"][readonly]',
|
|
60
|
+
'input[readonly][value]',
|
|
61
|
+
'code',
|
|
62
|
+
'pre',
|
|
63
|
+
'.api-key',
|
|
64
|
+
'[data-testid*="key"]',
|
|
65
|
+
'[data-testid*="secret"]',
|
|
66
|
+
'[class*="api-key"]',
|
|
67
|
+
'[class*="apikey"]',
|
|
68
|
+
'[class*="secret"]',
|
|
69
|
+
'input[value*="sk-"]',
|
|
70
|
+
'input[value*="sk_"]',
|
|
71
|
+
'input[value*="key_"]',
|
|
72
|
+
'input[value*="pk_"]',
|
|
73
|
+
'.mono',
|
|
74
|
+
'[class*="monospace"]',
|
|
75
|
+
'samp'
|
|
76
|
+
];
|
|
77
|
+
|
|
78
|
+
export class PlaywrightEngine {
|
|
79
|
+
constructor({ db, vault, browserProfileDir }) {
|
|
80
|
+
this.db = db;
|
|
81
|
+
this.vault = vault;
|
|
82
|
+
this.browserProfileDir = browserProfileDir;
|
|
83
|
+
this.gmailWatcher = new GmailWatcher({ vault });
|
|
84
|
+
this.captchaSolver = new CaptchaSolver({ vault });
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Open a visible browser for the user to sign into Google.
|
|
89
|
+
* Saves the session to the persistent browser profile.
|
|
90
|
+
*/
|
|
91
|
+
async login() {
|
|
92
|
+
const playwright = await importPlaywright();
|
|
93
|
+
|
|
94
|
+
log.info('Opening browser for Google login');
|
|
95
|
+
fs.mkdirSync(this.browserProfileDir, { recursive: true });
|
|
96
|
+
|
|
97
|
+
const context = await playwright.chromium.launchPersistentContext(
|
|
98
|
+
this.browserProfileDir,
|
|
99
|
+
{
|
|
100
|
+
headless: false,
|
|
101
|
+
viewport: { width: 1280, height: 900 },
|
|
102
|
+
args: ['--disable-blink-features=AutomationControlled']
|
|
103
|
+
}
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
const page = context.pages()[0] || await context.newPage();
|
|
107
|
+
await page.goto('https://accounts.google.com');
|
|
108
|
+
|
|
109
|
+
log.info('Waiting for user to complete Google sign-in...');
|
|
110
|
+
|
|
111
|
+
// Wait until user is logged in — detect by URL change or profile element
|
|
112
|
+
try {
|
|
113
|
+
await page.waitForURL(
|
|
114
|
+
(url) => url.href.includes('myaccount.google.com') || url.href.includes('google.com/search'),
|
|
115
|
+
{ timeout: 300_000 } // 5 minutes
|
|
116
|
+
);
|
|
117
|
+
log.info('Google sign-in detected');
|
|
118
|
+
} catch {
|
|
119
|
+
// User might close browser manually — that's fine, profile is still saved
|
|
120
|
+
log.info('Login window closed or timed out — profile saved if login completed');
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
await context.close();
|
|
124
|
+
log.info('Browser profile saved');
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Create an API key for any service.
|
|
129
|
+
* Uses the persistent browser profile (Google session) for authentication.
|
|
130
|
+
*
|
|
131
|
+
* If a service recipe exists in the registry, uses its workflow.
|
|
132
|
+
* Otherwise, uses smart navigation.
|
|
133
|
+
*/
|
|
134
|
+
async createKey(serviceDef) {
|
|
135
|
+
const hasProfile = fs.existsSync(this.browserProfileDir);
|
|
136
|
+
if (!hasProfile) {
|
|
137
|
+
throw new Error('No browser profile found. Run `agentgate login` first to sign in with Google.');
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const playwright = await importPlaywright();
|
|
141
|
+
const headless = serviceDef.runtime?.headless !== false;
|
|
142
|
+
|
|
143
|
+
log.info(`Launching browser for ${serviceDef.name}`, { headless });
|
|
144
|
+
|
|
145
|
+
let context;
|
|
146
|
+
try {
|
|
147
|
+
context = await playwright.chromium.launchPersistentContext(
|
|
148
|
+
this.browserProfileDir,
|
|
149
|
+
{
|
|
150
|
+
headless,
|
|
151
|
+
viewport: { width: 1366, height: 900 },
|
|
152
|
+
args: ['--disable-blink-features=AutomationControlled']
|
|
153
|
+
}
|
|
154
|
+
);
|
|
155
|
+
} catch (error) {
|
|
156
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
157
|
+
if (message.includes('Permission denied') || message.includes('Operation not permitted')) {
|
|
158
|
+
throw new Error(
|
|
159
|
+
'Playwright could not launch browser (permission denied). Run AgentGate outside restricted sandbox.'
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
throw error;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
try {
|
|
166
|
+
const page = context.pages()[0] || await context.newPage();
|
|
167
|
+
|
|
168
|
+
// If service has a pre-built workflow (recipe), use it
|
|
169
|
+
if (Array.isArray(serviceDef.workflow) && serviceDef.workflow.length > 0) {
|
|
170
|
+
return await this.runWorkflow(page, serviceDef);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Otherwise, smart navigation
|
|
174
|
+
return await this.smartCreateKey(page, serviceDef);
|
|
175
|
+
} finally {
|
|
176
|
+
await context.close();
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Run a pre-built workflow recipe.
|
|
182
|
+
*/
|
|
183
|
+
async runWorkflow(page, serviceDef) {
|
|
184
|
+
const state = {
|
|
185
|
+
service: serviceDef,
|
|
186
|
+
vault: this.vault.getPayload(),
|
|
187
|
+
generated: {},
|
|
188
|
+
result: { apiKey: null },
|
|
189
|
+
scratch: {}
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
const runtime = new BrowserRuntime({
|
|
193
|
+
page,
|
|
194
|
+
state,
|
|
195
|
+
db: this.db,
|
|
196
|
+
gmailWatcher: this.gmailWatcher,
|
|
197
|
+
captchaSolver: this.captchaSolver
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
log.info(`Running ${serviceDef.workflow.length} workflow actions for ${serviceDef.name}`);
|
|
201
|
+
await runtime.run(serviceDef.workflow);
|
|
202
|
+
|
|
203
|
+
if (!state.result.apiKey) {
|
|
204
|
+
throw new Error(
|
|
205
|
+
`Workflow completed for ${serviceDef.name} but no API key was captured in result.apiKey`
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return {
|
|
210
|
+
apiKey: state.result.apiKey,
|
|
211
|
+
metadata: { flow: 'recipe', service: serviceDef.name, createdAt: new Date().toISOString() }
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Smart navigation: sign up via Google, navigate to API keys, extract key.
|
|
217
|
+
* Works for any service without a pre-built recipe.
|
|
218
|
+
*/
|
|
219
|
+
async smartCreateKey(page, serviceDef) {
|
|
220
|
+
const signupUrl = serviceDef.signup_url;
|
|
221
|
+
const apiKeyUrl = serviceDef.api_key_url;
|
|
222
|
+
const name = serviceDef.name;
|
|
223
|
+
|
|
224
|
+
// Step 1: Navigate to signup/main page
|
|
225
|
+
log.info(`Smart navigation: going to ${signupUrl}`);
|
|
226
|
+
await page.goto(signupUrl, { waitUntil: 'domcontentloaded', timeout: 30_000 });
|
|
227
|
+
await page.waitForTimeout(2_000);
|
|
228
|
+
|
|
229
|
+
// Step 2: Try to find and click Google sign-in
|
|
230
|
+
const clickedGoogle = await this.tryClickFirst(page, GOOGLE_SIGN_IN_SELECTORS, 3_000);
|
|
231
|
+
if (clickedGoogle) {
|
|
232
|
+
log.info('Clicked Google sign-in button');
|
|
233
|
+
// Wait for OAuth flow to complete (popup or redirect)
|
|
234
|
+
await page.waitForTimeout(5_000);
|
|
235
|
+
// Handle popup if it appeared
|
|
236
|
+
await this.handleGooglePopup(page);
|
|
237
|
+
} else {
|
|
238
|
+
log.info('No Google sign-in button found — may already be logged in');
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Step 3: Navigate to API keys page if URL provided
|
|
242
|
+
if (apiKeyUrl) {
|
|
243
|
+
log.info(`Navigating to API keys page: ${apiKeyUrl}`);
|
|
244
|
+
await page.goto(apiKeyUrl, { waitUntil: 'domcontentloaded', timeout: 30_000 });
|
|
245
|
+
await page.waitForTimeout(2_000);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Step 4: Try to click "Create API Key" button
|
|
249
|
+
const clickedCreate = await this.tryClickFirst(page, CREATE_KEY_SELECTORS, 5_000);
|
|
250
|
+
if (clickedCreate) {
|
|
251
|
+
log.info('Clicked create API key button');
|
|
252
|
+
await page.waitForTimeout(3_000);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Step 5: Try to confirm any dialog (some services show a confirmation)
|
|
256
|
+
await this.tryClickFirst(page, [
|
|
257
|
+
'button:has-text("Create")',
|
|
258
|
+
'button:has-text("Confirm")',
|
|
259
|
+
'button:has-text("Generate")',
|
|
260
|
+
'button:has-text("Done")',
|
|
261
|
+
'button:has-text("Copy")'
|
|
262
|
+
], 2_000);
|
|
263
|
+
await page.waitForTimeout(2_000);
|
|
264
|
+
|
|
265
|
+
// Step 6: Extract the API key
|
|
266
|
+
const apiKey = await this.extractApiKey(page);
|
|
267
|
+
if (!apiKey) {
|
|
268
|
+
// Take a screenshot for debugging
|
|
269
|
+
const screenshotPath = `/tmp/agentgate-${name}-${Date.now()}.png`;
|
|
270
|
+
await page.screenshot({ path: screenshotPath, fullPage: true });
|
|
271
|
+
throw new Error(
|
|
272
|
+
`Could not extract API key for ${name}. Screenshot saved to ${screenshotPath}. ` +
|
|
273
|
+
`You may need to create a service recipe in services/${name}.json for this service.`
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
log.info(`API key extracted for ${name}`);
|
|
278
|
+
return {
|
|
279
|
+
apiKey,
|
|
280
|
+
metadata: { flow: 'smart', service: name, createdAt: new Date().toISOString() }
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Try to click the first matching selector from a list.
|
|
286
|
+
*/
|
|
287
|
+
async tryClickFirst(page, selectors, timeout = 3_000) {
|
|
288
|
+
for (const selector of selectors) {
|
|
289
|
+
try {
|
|
290
|
+
const el = await page.waitForSelector(selector, { timeout, state: 'visible' });
|
|
291
|
+
if (el) {
|
|
292
|
+
await el.click();
|
|
293
|
+
return true;
|
|
294
|
+
}
|
|
295
|
+
} catch {
|
|
296
|
+
// Not found, try next
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
return false;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Handle Google OAuth popup window if it appeared.
|
|
304
|
+
*/
|
|
305
|
+
async handleGooglePopup(page) {
|
|
306
|
+
const context = page.context();
|
|
307
|
+
const pages = context.pages();
|
|
308
|
+
const popup = pages.find((p) => p.url().includes('accounts.google.com'));
|
|
309
|
+
if (popup && popup !== page) {
|
|
310
|
+
log.info('Handling Google OAuth popup');
|
|
311
|
+
try {
|
|
312
|
+
// If already logged in, the popup might auto-select the account
|
|
313
|
+
// Try clicking the first account if visible
|
|
314
|
+
await popup.waitForSelector('[data-email]', { timeout: 5_000 });
|
|
315
|
+
await popup.click('[data-email]');
|
|
316
|
+
await page.waitForTimeout(3_000);
|
|
317
|
+
} catch {
|
|
318
|
+
// Popup may have auto-closed
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Try multiple strategies to extract an API key from the page.
|
|
325
|
+
*/
|
|
326
|
+
async extractApiKey(page) {
|
|
327
|
+
// Strategy 1: Check common selectors
|
|
328
|
+
for (const selector of KEY_EXTRACT_SELECTORS) {
|
|
329
|
+
try {
|
|
330
|
+
const elements = await page.$$(selector);
|
|
331
|
+
for (const el of elements) {
|
|
332
|
+
// Try input value first
|
|
333
|
+
const value = await el.inputValue().catch(() => null);
|
|
334
|
+
if (value && this.looksLikeApiKey(value)) {
|
|
335
|
+
return value.trim();
|
|
336
|
+
}
|
|
337
|
+
// Try text content
|
|
338
|
+
const text = await el.textContent().catch(() => null);
|
|
339
|
+
if (text && this.looksLikeApiKey(text.trim())) {
|
|
340
|
+
return text.trim();
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
} catch {
|
|
344
|
+
// Selector not found
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// Strategy 2: Scan full page text for key patterns
|
|
349
|
+
try {
|
|
350
|
+
const bodyText = await page.textContent('body');
|
|
351
|
+
if (bodyText) {
|
|
352
|
+
const patterns = [
|
|
353
|
+
/\b(sk-[A-Za-z0-9_\-]{20,})\b/,
|
|
354
|
+
/\b(sk_[A-Za-z0-9_\-]{20,})\b/,
|
|
355
|
+
/\b(pk_[A-Za-z0-9_\-]{20,})\b/,
|
|
356
|
+
/\b(key_[A-Za-z0-9_\-]{20,})\b/,
|
|
357
|
+
/\b(api_[A-Za-z0-9_\-]{20,})\b/,
|
|
358
|
+
/\b(token_[A-Za-z0-9_\-]{20,})\b/,
|
|
359
|
+
/\b(AKIA[A-Z0-9]{16})\b/, // AWS-style
|
|
360
|
+
/(?:api.?key|token|secret)[:\s"'=]+([A-Za-z0-9_\-]{20,})/i,
|
|
361
|
+
/\b([A-Za-z0-9_\-]{32,})\b/ // Last resort: any long alphanumeric string
|
|
362
|
+
];
|
|
363
|
+
|
|
364
|
+
for (const pattern of patterns) {
|
|
365
|
+
const match = bodyText.match(pattern);
|
|
366
|
+
if (match && match[1] && this.looksLikeApiKey(match[1])) {
|
|
367
|
+
return match[1];
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
} catch {
|
|
372
|
+
// Page text extraction failed
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
return null;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Heuristic: does this string look like an API key?
|
|
380
|
+
*/
|
|
381
|
+
looksLikeApiKey(str) {
|
|
382
|
+
if (!str || typeof str !== 'string') return false;
|
|
383
|
+
const trimmed = str.trim();
|
|
384
|
+
if (trimmed.length < 10) return false;
|
|
385
|
+
if (trimmed.length > 256) return false;
|
|
386
|
+
if (trimmed.includes(' ') || trimmed.includes('\n')) return false;
|
|
387
|
+
// Must have some mix of characters (not just "Loading..." or "Click here")
|
|
388
|
+
if (/^[a-z\s]+$/i.test(trimmed)) return false;
|
|
389
|
+
return true;
|
|
390
|
+
}
|
|
391
|
+
}
|
package/src/registry.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
export class ServiceRegistry {
|
|
5
|
+
constructor(registryDir) {
|
|
6
|
+
this.registryDir = registryDir;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
listServices() {
|
|
10
|
+
if (!fs.existsSync(this.registryDir)) {
|
|
11
|
+
return [];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const files = fs
|
|
15
|
+
.readdirSync(this.registryDir)
|
|
16
|
+
.filter((name) => name.endsWith('.json') && !name.startsWith('_'))
|
|
17
|
+
.sort();
|
|
18
|
+
|
|
19
|
+
return files.map((file) => {
|
|
20
|
+
const fullPath = path.join(this.registryDir, file);
|
|
21
|
+
const raw = fs.readFileSync(fullPath, 'utf8');
|
|
22
|
+
const parsed = JSON.parse(raw);
|
|
23
|
+
this.validateService(parsed, file);
|
|
24
|
+
return parsed;
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
getService(name) {
|
|
29
|
+
const service = this.listServices().find((item) => item.name === name);
|
|
30
|
+
return service ?? null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
validateService(service, fileName) {
|
|
34
|
+
if (!service || typeof service !== 'object') {
|
|
35
|
+
throw new Error(`Invalid service definition in ${fileName}: expected object`);
|
|
36
|
+
}
|
|
37
|
+
if (!service.name || typeof service.name !== 'string') {
|
|
38
|
+
throw new Error(`Invalid service definition in ${fileName}: missing name`);
|
|
39
|
+
}
|
|
40
|
+
if (!service.signup_url || typeof service.signup_url !== 'string') {
|
|
41
|
+
throw new Error(`Invalid service definition in ${fileName}: missing signup_url`);
|
|
42
|
+
}
|
|
43
|
+
if (!Array.isArray(service.workflow)) {
|
|
44
|
+
throw new Error(`Invalid service definition in ${fileName}: workflow must be an array`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
package/src/scaffold.js
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
function sanitizeServiceName(input) {
|
|
5
|
+
return String(input || '')
|
|
6
|
+
.trim()
|
|
7
|
+
.toLowerCase()
|
|
8
|
+
.replace(/^https?:\/\//, '')
|
|
9
|
+
.replace(/^www\./, '')
|
|
10
|
+
.split('/')[0]
|
|
11
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
12
|
+
.replace(/^-+|-+$/g, '');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function deriveNameFromUrl(url) {
|
|
16
|
+
try {
|
|
17
|
+
const host = new URL(url).hostname;
|
|
18
|
+
return sanitizeServiceName(host.split('.').slice(-2, -1)[0] || host);
|
|
19
|
+
} catch {
|
|
20
|
+
return '';
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function buildTemplate({ name, url }) {
|
|
25
|
+
return {
|
|
26
|
+
name,
|
|
27
|
+
signup_url: url,
|
|
28
|
+
requires: {
|
|
29
|
+
google: false,
|
|
30
|
+
card: false
|
|
31
|
+
},
|
|
32
|
+
runtime: {
|
|
33
|
+
headless: true
|
|
34
|
+
},
|
|
35
|
+
workflow: [
|
|
36
|
+
{ type: 'goto', url: '{{service.signup_url}}' },
|
|
37
|
+
{ type: 'wait_for', selector: 'body' },
|
|
38
|
+
{ type: 'fill', selector: "input[type='email']", value: '{{generated.emailAlias}}' },
|
|
39
|
+
{ type: 'fill', selector: "input[type='password']", value: '{{generated.password}}' },
|
|
40
|
+
{ type: 'click', selector: "button[type='submit']" },
|
|
41
|
+
{ type: 'store_alias' },
|
|
42
|
+
{ type: 'wait_for_email_code', target: 'scratch.emailCode', timeoutMs: 120000 },
|
|
43
|
+
{ type: 'fill', selector: "input[name='code']", value: '{{scratch.emailCode}}' },
|
|
44
|
+
{ type: 'click', selector: "button[type='submit']" },
|
|
45
|
+
{ type: 'solve_captcha', target: 'scratch.captchaToken', provider: 'capsolver' },
|
|
46
|
+
{ type: 'goto', url: '{{service.signup_url}}' },
|
|
47
|
+
{ type: 'extract_text', selector: 'body', target: 'scratch.pageText' },
|
|
48
|
+
{
|
|
49
|
+
type: 'regex_extract',
|
|
50
|
+
source: 'scratch.pageText',
|
|
51
|
+
pattern: '(?:api[_-]?key|token)[^A-Za-z0-9]+([A-Za-z0-9_\\-]{20,})',
|
|
52
|
+
target: 'result.apiKey'
|
|
53
|
+
},
|
|
54
|
+
{ type: 'assert_present', path: 'result.apiKey' }
|
|
55
|
+
]
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function runScaffold({ registryDir, argv, stdout, stderr }) {
|
|
60
|
+
const arg1 = argv[0] || '';
|
|
61
|
+
const arg2 = argv[1] || '';
|
|
62
|
+
|
|
63
|
+
let name = '';
|
|
64
|
+
let url = '';
|
|
65
|
+
|
|
66
|
+
if (arg1.startsWith('http://') || arg1.startsWith('https://')) {
|
|
67
|
+
url = arg1;
|
|
68
|
+
name = sanitizeServiceName(arg2) || deriveNameFromUrl(url);
|
|
69
|
+
} else {
|
|
70
|
+
name = sanitizeServiceName(arg1);
|
|
71
|
+
url = arg2;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (!name || !url) {
|
|
75
|
+
stderr.write('Usage: node src/cli.js scaffold <service-name> <signup-url>\n');
|
|
76
|
+
stderr.write(' or: node src/cli.js scaffold <signup-url> [service-name]\n');
|
|
77
|
+
return 1;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
let parsed;
|
|
81
|
+
try {
|
|
82
|
+
parsed = new URL(url);
|
|
83
|
+
} catch {
|
|
84
|
+
stderr.write(`Invalid URL: ${url}\n`);
|
|
85
|
+
return 1;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const normalizedUrl = parsed.toString();
|
|
89
|
+
fs.mkdirSync(registryDir, { recursive: true });
|
|
90
|
+
const filePath = path.join(registryDir, `${name}.json`);
|
|
91
|
+
|
|
92
|
+
if (fs.existsSync(filePath)) {
|
|
93
|
+
stderr.write(`Service definition already exists: ${filePath}\n`);
|
|
94
|
+
return 1;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const template = buildTemplate({ name, url: normalizedUrl });
|
|
98
|
+
fs.writeFileSync(filePath, `${JSON.stringify(template, null, 2)}\n`, { mode: 0o600 });
|
|
99
|
+
|
|
100
|
+
stdout.write(`Created service scaffold: ${filePath}\n`);
|
|
101
|
+
stdout.write('Next: edit selectors and workflow actions for the target service.\n');
|
|
102
|
+
return 0;
|
|
103
|
+
}
|
package/src/setup.js
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import readline from 'node:readline/promises';
|
|
3
|
+
import { stdin as input, stdout as output } from 'node:process';
|
|
4
|
+
|
|
5
|
+
export async function runSetup({ vault, settingsFile }) {
|
|
6
|
+
const rl = readline.createInterface({ input, output });
|
|
7
|
+
|
|
8
|
+
try {
|
|
9
|
+
const googleEmail = await getValue(rl, {
|
|
10
|
+
env: 'AGENTGATE_GOOGLE_EMAIL',
|
|
11
|
+
prompt: 'Google account email: '
|
|
12
|
+
});
|
|
13
|
+
const gmailToken = await getValue(rl, {
|
|
14
|
+
env: 'AGENTGATE_GMAIL_TOKEN',
|
|
15
|
+
prompt: 'Gmail OAuth token (paste token for now): ',
|
|
16
|
+
allowEmpty: true
|
|
17
|
+
});
|
|
18
|
+
const cardHolder = await getValue(rl, {
|
|
19
|
+
env: 'AGENTGATE_CARD_HOLDER',
|
|
20
|
+
prompt: 'Card holder name: ',
|
|
21
|
+
allowEmpty: true
|
|
22
|
+
});
|
|
23
|
+
const cardNumber = await getValue(rl, {
|
|
24
|
+
env: 'AGENTGATE_CARD_NUMBER',
|
|
25
|
+
prompt: 'Card number (numbers only): ',
|
|
26
|
+
allowEmpty: true
|
|
27
|
+
});
|
|
28
|
+
const cardExpMonth = await getValue(rl, {
|
|
29
|
+
env: 'AGENTGATE_CARD_EXP_MONTH',
|
|
30
|
+
prompt: 'Card expiry month (MM): ',
|
|
31
|
+
allowEmpty: true
|
|
32
|
+
});
|
|
33
|
+
const cardExpYear = await getValue(rl, {
|
|
34
|
+
env: 'AGENTGATE_CARD_EXP_YEAR',
|
|
35
|
+
prompt: 'Card expiry year (YYYY): ',
|
|
36
|
+
allowEmpty: true
|
|
37
|
+
});
|
|
38
|
+
const cardCvc = await getValue(rl, {
|
|
39
|
+
env: 'AGENTGATE_CARD_CVC',
|
|
40
|
+
prompt: 'Card CVC: ',
|
|
41
|
+
allowEmpty: true
|
|
42
|
+
});
|
|
43
|
+
const cardZip = await getValue(rl, {
|
|
44
|
+
env: 'AGENTGATE_CARD_ZIP',
|
|
45
|
+
prompt: 'Billing ZIP/postal code: ',
|
|
46
|
+
allowEmpty: true
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
vault.setGoogleAccount({
|
|
50
|
+
email: googleEmail,
|
|
51
|
+
cookies: null,
|
|
52
|
+
gmailOAuthToken: gmailToken || null
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
vault.setBilling({
|
|
56
|
+
cardHolder: cardHolder || null,
|
|
57
|
+
cardNumber: cardNumber || null,
|
|
58
|
+
cardLast4: cardNumber ? cardNumber.slice(-4) : null,
|
|
59
|
+
cardExpMonth: cardExpMonth || null,
|
|
60
|
+
cardExpYear: cardExpYear || null,
|
|
61
|
+
cardCvc: cardCvc || null,
|
|
62
|
+
cardZip: cardZip || null
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
const settings = {
|
|
66
|
+
initializedAt: new Date().toISOString(),
|
|
67
|
+
googleEmail,
|
|
68
|
+
setupVersion: 2
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
fs.writeFileSync(settingsFile, JSON.stringify(settings, null, 2), { mode: 0o600 });
|
|
72
|
+
|
|
73
|
+
output.write('\nSetup complete. Use this MCP config snippet:\n\n');
|
|
74
|
+
output.write(
|
|
75
|
+
JSON.stringify(
|
|
76
|
+
{
|
|
77
|
+
mcpServers: {
|
|
78
|
+
agentgate: {
|
|
79
|
+
command: 'node',
|
|
80
|
+
args: ['src/cli.js', 'serve']
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
null,
|
|
85
|
+
2
|
|
86
|
+
)
|
|
87
|
+
);
|
|
88
|
+
output.write('\n');
|
|
89
|
+
} finally {
|
|
90
|
+
rl.close();
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function getValue(rl, { env, prompt, allowEmpty = false }) {
|
|
95
|
+
const fromEnv = process.env[env];
|
|
96
|
+
if (typeof fromEnv === 'string') {
|
|
97
|
+
const value = fromEnv.trim();
|
|
98
|
+
if (!value && !allowEmpty) {
|
|
99
|
+
throw new Error(`Environment variable ${env} cannot be empty.`);
|
|
100
|
+
}
|
|
101
|
+
return value;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const value = (await rl.question(prompt)).trim();
|
|
105
|
+
if (!value && !allowEmpty) {
|
|
106
|
+
throw new Error(`Value required for prompt: ${prompt}`);
|
|
107
|
+
}
|
|
108
|
+
return value;
|
|
109
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { PlaywrightEngine } from './playwright-engine.js';
|
|
2
|
+
import { createLogger } from './logger.js';
|
|
3
|
+
|
|
4
|
+
const log = createLogger('signup-engine');
|
|
5
|
+
|
|
6
|
+
export class SignupEngine extends PlaywrightEngine {
|
|
7
|
+
async createKey(serviceDef) {
|
|
8
|
+
log.info(`Creating key for ${serviceDef.name}`, {
|
|
9
|
+
mode: serviceDef.workflow?.length > 0 ? 'recipe' : 'smart',
|
|
10
|
+
signup_url: serviceDef.signup_url
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
try {
|
|
14
|
+
const result = await super.createKey(serviceDef);
|
|
15
|
+
log.info(`Key created for ${serviceDef.name}`);
|
|
16
|
+
return result;
|
|
17
|
+
} catch (error) {
|
|
18
|
+
log.error(`Failed to create key for ${serviceDef.name}`, {
|
|
19
|
+
error: error instanceof Error ? error.message : String(error)
|
|
20
|
+
});
|
|
21
|
+
throw error;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|