assistme 0.1.13 → 0.1.14
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/dist/index.js +145 -11
- package/package.json +1 -1
- package/src/agent/processor.ts +7 -4
- package/src/agent/scheduler.ts +16 -39
- package/src/db/supabase.test.ts +98 -41
- package/src/tools/browser.ts +73 -0
- package/src/tools/index.ts +86 -2
- package/src/utils/config.test.ts +4 -6
package/dist/index.js
CHANGED
|
@@ -63,14 +63,15 @@ function getNextRunTime(cronExpr, timezone, fromDate) {
|
|
|
63
63
|
const daysOfMonth = parseField(domExpr, 1, 31);
|
|
64
64
|
const months = parseField(monExpr, 1, 12);
|
|
65
65
|
const daysOfWeek = parseField(dowExpr, 0, 6);
|
|
66
|
+
const useUTC = timezone === "UTC";
|
|
66
67
|
const candidate = new Date(now.getTime() + 6e4);
|
|
67
68
|
candidate.setSeconds(0, 0);
|
|
68
69
|
for (let i = 0; i < 527040; i++) {
|
|
69
|
-
const m = candidate.getMinutes();
|
|
70
|
-
const h = candidate.getHours();
|
|
71
|
-
const dom = candidate.getDate();
|
|
72
|
-
const mon = candidate.getMonth() + 1;
|
|
73
|
-
const dow = candidate.getDay();
|
|
70
|
+
const m = useUTC ? candidate.getUTCMinutes() : candidate.getMinutes();
|
|
71
|
+
const h = useUTC ? candidate.getUTCHours() : candidate.getHours();
|
|
72
|
+
const dom = useUTC ? candidate.getUTCDate() : candidate.getDate();
|
|
73
|
+
const mon = (useUTC ? candidate.getUTCMonth() : candidate.getMonth()) + 1;
|
|
74
|
+
const dow = useUTC ? candidate.getUTCDay() : candidate.getDay();
|
|
74
75
|
if (minutes.includes(m) && hours.includes(h) && daysOfMonth.includes(dom) && months.includes(mon) && (dowExpr === "*" || daysOfWeek.includes(dow))) {
|
|
75
76
|
return candidate;
|
|
76
77
|
}
|
|
@@ -849,6 +850,76 @@ URL: ${info.url}`;
|
|
|
849
850
|
isConnected() {
|
|
850
851
|
return this.connected && this.ws?.readyState === WebSocket.OPEN;
|
|
851
852
|
}
|
|
853
|
+
// ── Login Detection ────────────────────────────────────────────
|
|
854
|
+
/**
|
|
855
|
+
* Detect if the current page appears to be a login/authentication page.
|
|
856
|
+
* Checks URL patterns, password input fields, and login form actions.
|
|
857
|
+
*/
|
|
858
|
+
async detectLoginPage() {
|
|
859
|
+
try {
|
|
860
|
+
const result = await this.send("Runtime.evaluate", {
|
|
861
|
+
expression: `
|
|
862
|
+
(function() {
|
|
863
|
+
var url = window.location.href.toLowerCase();
|
|
864
|
+
|
|
865
|
+
// URL-based detection
|
|
866
|
+
var loginPatterns = [
|
|
867
|
+
'/login', '/signin', '/sign-in', '/sign_in',
|
|
868
|
+
'/auth/', '/sso/', '/oauth/', '/session/new',
|
|
869
|
+
'/accounts/login', '/users/sign_in',
|
|
870
|
+
'accounts.google.com', 'login.microsoftonline.com',
|
|
871
|
+
'github.com/login', 'github.com/session',
|
|
872
|
+
'login.live.com', 'appleid.apple.com'
|
|
873
|
+
];
|
|
874
|
+
for (var i = 0; i < loginPatterns.length; i++) {
|
|
875
|
+
if (url.indexOf(loginPatterns[i]) !== -1) {
|
|
876
|
+
return JSON.stringify({
|
|
877
|
+
isLoginPage: true,
|
|
878
|
+
reason: 'URL contains login pattern: ' + loginPatterns[i]
|
|
879
|
+
});
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
// Password input detection (visible only)
|
|
884
|
+
var passwordInputs = document.querySelectorAll('input[type="password"]');
|
|
885
|
+
for (var j = 0; j < passwordInputs.length; j++) {
|
|
886
|
+
var input = passwordInputs[j];
|
|
887
|
+
var rect = input.getBoundingClientRect();
|
|
888
|
+
var style = window.getComputedStyle(input);
|
|
889
|
+
if (rect.width > 0 && rect.height > 0 &&
|
|
890
|
+
style.display !== 'none' && style.visibility !== 'hidden') {
|
|
891
|
+
return JSON.stringify({
|
|
892
|
+
isLoginPage: true,
|
|
893
|
+
reason: 'Page contains visible password input field'
|
|
894
|
+
});
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
// Login form action detection
|
|
899
|
+
var formSelectors = [
|
|
900
|
+
'form[action*="login"]', 'form[action*="signin"]',
|
|
901
|
+
'form[action*="session"]', 'form[action*="auth"]',
|
|
902
|
+
'form[action*="authenticate"]'
|
|
903
|
+
];
|
|
904
|
+
var loginForms = document.querySelectorAll(formSelectors.join(','));
|
|
905
|
+
if (loginForms.length > 0) {
|
|
906
|
+
return JSON.stringify({
|
|
907
|
+
isLoginPage: true,
|
|
908
|
+
reason: 'Page contains login form'
|
|
909
|
+
});
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
return JSON.stringify({ isLoginPage: false, reason: '' });
|
|
913
|
+
})()
|
|
914
|
+
`,
|
|
915
|
+
returnByValue: true
|
|
916
|
+
});
|
|
917
|
+
const value = result.result?.value;
|
|
918
|
+
return JSON.parse(value || '{"isLoginPage":false,"reason":""}');
|
|
919
|
+
} catch {
|
|
920
|
+
return { isLoginPage: false, reason: "" };
|
|
921
|
+
}
|
|
922
|
+
}
|
|
852
923
|
};
|
|
853
924
|
function findChromePath() {
|
|
854
925
|
const os = platform();
|
|
@@ -2275,6 +2346,60 @@ ${stderr}` : "";
|
|
|
2275
2346
|
}
|
|
2276
2347
|
|
|
2277
2348
|
// src/tools/index.ts
|
|
2349
|
+
async function detectAndHandleLogin(browser) {
|
|
2350
|
+
const detection = await browser.detectLoginPage();
|
|
2351
|
+
if (!detection.isLoginPage) return null;
|
|
2352
|
+
const pageInfo = await browser.getPageInfo();
|
|
2353
|
+
let siteName;
|
|
2354
|
+
try {
|
|
2355
|
+
siteName = new URL(pageInfo.url).hostname;
|
|
2356
|
+
} catch {
|
|
2357
|
+
siteName = pageInfo.url;
|
|
2358
|
+
}
|
|
2359
|
+
console.log("\n");
|
|
2360
|
+
console.log("\u2501".repeat(60));
|
|
2361
|
+
console.log(" \u{1F510} LOGIN REQUIRED");
|
|
2362
|
+
console.log("\u2501".repeat(60));
|
|
2363
|
+
console.log(` ${detection.reason}`);
|
|
2364
|
+
console.log(` Site: ${siteName}`);
|
|
2365
|
+
console.log(` Please log in using the browser window.`);
|
|
2366
|
+
console.log(` Your session will be saved for future use.`);
|
|
2367
|
+
console.log(` (Waiting up to 120s for you to complete login)`);
|
|
2368
|
+
console.log("\u2501".repeat(60));
|
|
2369
|
+
console.log("\n");
|
|
2370
|
+
const loginUrl = pageInfo.url;
|
|
2371
|
+
const deadline = Date.now() + 12e4;
|
|
2372
|
+
while (Date.now() < deadline) {
|
|
2373
|
+
await new Promise((r) => setTimeout(r, 3e3));
|
|
2374
|
+
try {
|
|
2375
|
+
const currentInfo = await browser.getPageInfo();
|
|
2376
|
+
if (currentInfo.url !== loginUrl) {
|
|
2377
|
+
await new Promise((r) => setTimeout(r, 1500));
|
|
2378
|
+
const newPage = await browser.readPage();
|
|
2379
|
+
return `Login completed. Redirected to: ${currentInfo.url}
|
|
2380
|
+
Session saved in assistme browser profile for future use.
|
|
2381
|
+
|
|
2382
|
+
Current page:
|
|
2383
|
+
${newPage.slice(0, 3e3)}`;
|
|
2384
|
+
}
|
|
2385
|
+
const stillLogin = await browser.detectLoginPage();
|
|
2386
|
+
if (!stillLogin.isLoginPage) {
|
|
2387
|
+
const newPage = await browser.readPage();
|
|
2388
|
+
return `Login completed on ${siteName}.
|
|
2389
|
+
Session saved in assistme browser profile for future use.
|
|
2390
|
+
|
|
2391
|
+
Current page:
|
|
2392
|
+
${newPage.slice(0, 3e3)}`;
|
|
2393
|
+
}
|
|
2394
|
+
} catch {
|
|
2395
|
+
try {
|
|
2396
|
+
await browser.connect();
|
|
2397
|
+
} catch {
|
|
2398
|
+
}
|
|
2399
|
+
}
|
|
2400
|
+
}
|
|
2401
|
+
return `Login wait timed out after 120s. The user may still need to log in at ${siteName}.`;
|
|
2402
|
+
}
|
|
2278
2403
|
async function ensureConnected(browser, tabIndex) {
|
|
2279
2404
|
if (browser.isConnected() && tabIndex === void 0) return;
|
|
2280
2405
|
if (!await browser.isAvailable()) {
|
|
@@ -2316,9 +2441,15 @@ async function executeTool(name, input) {
|
|
|
2316
2441
|
await ensureConnected(browser, input.tab_index);
|
|
2317
2442
|
return browser.isConnected() ? "Connected to browser." : "Failed to connect.";
|
|
2318
2443
|
}
|
|
2319
|
-
case "browser_navigate":
|
|
2444
|
+
case "browser_navigate": {
|
|
2320
2445
|
await ensureConnected(browser);
|
|
2321
|
-
|
|
2446
|
+
const navResult = await browser.navigate(input.url);
|
|
2447
|
+
const loginResult = await detectAndHandleLogin(browser);
|
|
2448
|
+
if (loginResult) {
|
|
2449
|
+
return navResult + "\n\n" + loginResult;
|
|
2450
|
+
}
|
|
2451
|
+
return navResult;
|
|
2452
|
+
}
|
|
2322
2453
|
case "browser_read_page":
|
|
2323
2454
|
await ensureConnected(browser);
|
|
2324
2455
|
return browser.readPage();
|
|
@@ -2832,7 +2963,9 @@ var BASE_SYSTEM_PROMPT = `You are AssistMe, an AI assistant that operates like a
|
|
|
2832
2963
|
KEY PRINCIPLE: You operate the user's real browser, not a headless sandbox. This means:
|
|
2833
2964
|
- The browser has the user's real cookies, logins, and sessions
|
|
2834
2965
|
- When you navigate to amazon.com, you see the user's logged-in Amazon
|
|
2835
|
-
- If a site needs login,
|
|
2966
|
+
- If a site needs login, the browser will auto-detect the login page and prompt the user
|
|
2967
|
+
- After the user logs in, their session is saved in the persistent browser profile (~/.assistme/browser-profile)
|
|
2968
|
+
- Saved sessions persist across assistme restarts \u2014 the user only needs to log in once per site
|
|
2836
2969
|
- You are like a human assistant sitting at the user's computer
|
|
2837
2970
|
- Chrome is automatically managed \u2014 just call browser_connect and it will auto-launch if needed
|
|
2838
2971
|
- NEVER ask the user to manually start Chrome or run any terminal commands for browser setup
|
|
@@ -2859,9 +2992,9 @@ Available capabilities:
|
|
|
2859
2992
|
Workflow for web tasks (e.g. "\u67E5\u4E00\u4E0B kindle \u6700\u65B0\u6B3E\u4EF7\u683C"):
|
|
2860
2993
|
1. browser_connect \u2192 connect to user's Chrome
|
|
2861
2994
|
2. browser_new_tab \u2192 open a new tab
|
|
2862
|
-
3. browser_navigate \u2192 go to the website
|
|
2995
|
+
3. browser_navigate \u2192 go to the website (login pages are auto-detected \u2014 the user will be prompted and their session saved)
|
|
2863
2996
|
4. browser_read_page or browser_screenshot \u2192 read the content
|
|
2864
|
-
5. If login
|
|
2997
|
+
5. If login is needed but not auto-detected \u2192 use browser_request_user_action to ask the user
|
|
2865
2998
|
6. Repeat across multiple sites as needed
|
|
2866
2999
|
7. Summarize findings
|
|
2867
3000
|
|
|
@@ -2869,7 +3002,8 @@ Guidelines:
|
|
|
2869
3002
|
- Always use the real browser for web tasks, never try to fetch URLs programmatically
|
|
2870
3003
|
- Use browser_screenshot when you need to see the visual layout
|
|
2871
3004
|
- Use browser_get_elements to find clickable elements before clicking
|
|
2872
|
-
-
|
|
3005
|
+
- Login pages are auto-detected after navigation \u2014 the user is prompted and sessions are saved automatically
|
|
3006
|
+
- If auto-detection misses a login page, use browser_request_user_action manually
|
|
2873
3007
|
- Be thorough: check multiple sources when comparing prices/products
|
|
2874
3008
|
- Summarize results clearly at the end
|
|
2875
3009
|
- When you learn something about the user (preferences, habits), use memory_store to remember it
|
package/package.json
CHANGED
package/src/agent/processor.ts
CHANGED
|
@@ -37,7 +37,9 @@ const BASE_SYSTEM_PROMPT = `You are AssistMe, an AI assistant that operates like
|
|
|
37
37
|
KEY PRINCIPLE: You operate the user's real browser, not a headless sandbox. This means:
|
|
38
38
|
- The browser has the user's real cookies, logins, and sessions
|
|
39
39
|
- When you navigate to amazon.com, you see the user's logged-in Amazon
|
|
40
|
-
- If a site needs login,
|
|
40
|
+
- If a site needs login, the browser will auto-detect the login page and prompt the user
|
|
41
|
+
- After the user logs in, their session is saved in the persistent browser profile (~/.assistme/browser-profile)
|
|
42
|
+
- Saved sessions persist across assistme restarts — the user only needs to log in once per site
|
|
41
43
|
- You are like a human assistant sitting at the user's computer
|
|
42
44
|
- Chrome is automatically managed — just call browser_connect and it will auto-launch if needed
|
|
43
45
|
- NEVER ask the user to manually start Chrome or run any terminal commands for browser setup
|
|
@@ -64,9 +66,9 @@ Available capabilities:
|
|
|
64
66
|
Workflow for web tasks (e.g. "查一下 kindle 最新款价格"):
|
|
65
67
|
1. browser_connect → connect to user's Chrome
|
|
66
68
|
2. browser_new_tab → open a new tab
|
|
67
|
-
3. browser_navigate → go to the website
|
|
69
|
+
3. browser_navigate → go to the website (login pages are auto-detected — the user will be prompted and their session saved)
|
|
68
70
|
4. browser_read_page or browser_screenshot → read the content
|
|
69
|
-
5. If login
|
|
71
|
+
5. If login is needed but not auto-detected → use browser_request_user_action to ask the user
|
|
70
72
|
6. Repeat across multiple sites as needed
|
|
71
73
|
7. Summarize findings
|
|
72
74
|
|
|
@@ -74,7 +76,8 @@ Guidelines:
|
|
|
74
76
|
- Always use the real browser for web tasks, never try to fetch URLs programmatically
|
|
75
77
|
- Use browser_screenshot when you need to see the visual layout
|
|
76
78
|
- Use browser_get_elements to find clickable elements before clicking
|
|
77
|
-
-
|
|
79
|
+
- Login pages are auto-detected after navigation — the user is prompted and sessions are saved automatically
|
|
80
|
+
- If auto-detection misses a login page, use browser_request_user_action manually
|
|
78
81
|
- Be thorough: check multiple sources when comparing prices/products
|
|
79
82
|
- Summarize results clearly at the end
|
|
80
83
|
- When you learn something about the user (preferences, habits), use memory_store to remember it
|
package/src/agent/scheduler.ts
CHANGED
|
@@ -25,11 +25,7 @@ export interface ScheduledTask {
|
|
|
25
25
|
* Supports: minute hour day-of-month month day-of-week
|
|
26
26
|
* Examples: "0 8 * * *" (daily 8am), "0,30 * * * *" (every 30min)
|
|
27
27
|
*/
|
|
28
|
-
export function getNextRunTime(
|
|
29
|
-
cronExpr: string,
|
|
30
|
-
timezone: string,
|
|
31
|
-
fromDate?: Date
|
|
32
|
-
): Date {
|
|
28
|
+
export function getNextRunTime(cronExpr: string, timezone: string, fromDate?: Date): Date {
|
|
33
29
|
const now = fromDate || new Date();
|
|
34
30
|
const parts = cronExpr.trim().split(/\s+/);
|
|
35
31
|
if (parts.length !== 5) {
|
|
@@ -65,6 +61,8 @@ export function getNextRunTime(
|
|
|
65
61
|
const months = parseField(monExpr, 1, 12);
|
|
66
62
|
const daysOfWeek = parseField(dowExpr, 0, 6); // 0 = Sunday
|
|
67
63
|
|
|
64
|
+
const useUTC = timezone === "UTC";
|
|
65
|
+
|
|
68
66
|
// Find the next matching time after 'now'
|
|
69
67
|
const candidate = new Date(now.getTime() + 60_000); // Start from next minute
|
|
70
68
|
candidate.setSeconds(0, 0);
|
|
@@ -72,11 +70,11 @@ export function getNextRunTime(
|
|
|
72
70
|
// Search up to 366 days ahead
|
|
73
71
|
for (let i = 0; i < 527040; i++) {
|
|
74
72
|
// 366 * 24 * 60
|
|
75
|
-
const m = candidate.getMinutes();
|
|
76
|
-
const h = candidate.getHours();
|
|
77
|
-
const dom = candidate.getDate();
|
|
78
|
-
const mon = candidate.getMonth() + 1;
|
|
79
|
-
const dow = candidate.getDay();
|
|
73
|
+
const m = useUTC ? candidate.getUTCMinutes() : candidate.getMinutes();
|
|
74
|
+
const h = useUTC ? candidate.getUTCHours() : candidate.getHours();
|
|
75
|
+
const dom = useUTC ? candidate.getUTCDate() : candidate.getDate();
|
|
76
|
+
const mon = (useUTC ? candidate.getUTCMonth() : candidate.getMonth()) + 1;
|
|
77
|
+
const dow = useUTC ? candidate.getUTCDay() : candidate.getDay();
|
|
80
78
|
|
|
81
79
|
if (
|
|
82
80
|
minutes.includes(m) &&
|
|
@@ -98,13 +96,9 @@ export function getNextRunTime(
|
|
|
98
96
|
export class Scheduler {
|
|
99
97
|
private timer: ReturnType<typeof setInterval> | null = null;
|
|
100
98
|
private running = false;
|
|
101
|
-
private onScheduledTask:
|
|
102
|
-
| ((task: ScheduledTask) => Promise<void>)
|
|
103
|
-
| null = null;
|
|
99
|
+
private onScheduledTask: ((task: ScheduledTask) => Promise<void>) | null = null;
|
|
104
100
|
|
|
105
|
-
async start(
|
|
106
|
-
onScheduledTask: (task: ScheduledTask) => Promise<void>
|
|
107
|
-
): Promise<void> {
|
|
101
|
+
async start(onScheduledTask: (task: ScheduledTask) => Promise<void>): Promise<void> {
|
|
108
102
|
this.onScheduledTask = onScheduledTask;
|
|
109
103
|
this.running = true;
|
|
110
104
|
|
|
@@ -187,16 +181,10 @@ export class Scheduler {
|
|
|
187
181
|
try {
|
|
188
182
|
await this.onScheduledTask(task);
|
|
189
183
|
|
|
190
|
-
await sb
|
|
191
|
-
.from("agent_scheduled_tasks")
|
|
192
|
-
.update({ last_error: null })
|
|
193
|
-
.eq("id", task.id);
|
|
184
|
+
await sb.from("agent_scheduled_tasks").update({ last_error: null }).eq("id", task.id);
|
|
194
185
|
} catch (err) {
|
|
195
186
|
const errMsg = err instanceof Error ? err.message : String(err);
|
|
196
|
-
await sb
|
|
197
|
-
.from("agent_scheduled_tasks")
|
|
198
|
-
.update({ last_error: errMsg })
|
|
199
|
-
.eq("id", task.id);
|
|
187
|
+
await sb.from("agent_scheduled_tasks").update({ last_error: errMsg }).eq("id", task.id);
|
|
200
188
|
log.error(`Scheduled task "${task.name}" failed: ${errMsg}`);
|
|
201
189
|
}
|
|
202
190
|
} catch (err) {
|
|
@@ -234,9 +222,7 @@ export async function createScheduledTask(
|
|
|
234
222
|
return data as ScheduledTask;
|
|
235
223
|
}
|
|
236
224
|
|
|
237
|
-
export async function listScheduledTasks(
|
|
238
|
-
userId: string
|
|
239
|
-
): Promise<ScheduledTask[]> {
|
|
225
|
+
export async function listScheduledTasks(userId: string): Promise<ScheduledTask[]> {
|
|
240
226
|
const sb = getSupabase();
|
|
241
227
|
const { data, error } = await sb
|
|
242
228
|
.from("agent_scheduled_tasks")
|
|
@@ -248,10 +234,7 @@ export async function listScheduledTasks(
|
|
|
248
234
|
return (data || []) as ScheduledTask[];
|
|
249
235
|
}
|
|
250
236
|
|
|
251
|
-
export async function toggleScheduledTask(
|
|
252
|
-
taskId: string,
|
|
253
|
-
enabled: boolean
|
|
254
|
-
): Promise<void> {
|
|
237
|
+
export async function toggleScheduledTask(taskId: string, enabled: boolean): Promise<void> {
|
|
255
238
|
const sb = getSupabase();
|
|
256
239
|
const update: Record<string, unknown> = { enabled };
|
|
257
240
|
if (enabled) {
|
|
@@ -267,20 +250,14 @@ export async function toggleScheduledTask(
|
|
|
267
250
|
}
|
|
268
251
|
}
|
|
269
252
|
|
|
270
|
-
const { error } = await sb
|
|
271
|
-
.from("agent_scheduled_tasks")
|
|
272
|
-
.update(update)
|
|
273
|
-
.eq("id", taskId);
|
|
253
|
+
const { error } = await sb.from("agent_scheduled_tasks").update(update).eq("id", taskId);
|
|
274
254
|
|
|
275
255
|
if (error) throw new Error(`Failed to toggle schedule: ${error.message}`);
|
|
276
256
|
}
|
|
277
257
|
|
|
278
258
|
export async function deleteScheduledTask(taskId: string): Promise<void> {
|
|
279
259
|
const sb = getSupabase();
|
|
280
|
-
const { error } = await sb
|
|
281
|
-
.from("agent_scheduled_tasks")
|
|
282
|
-
.delete()
|
|
283
|
-
.eq("id", taskId);
|
|
260
|
+
const { error } = await sb.from("agent_scheduled_tasks").delete().eq("id", taskId);
|
|
284
261
|
|
|
285
262
|
if (error) throw new Error(`Failed to delete schedule: ${error.message}`);
|
|
286
263
|
}
|
package/src/db/supabase.test.ts
CHANGED
|
@@ -8,14 +8,28 @@ let finalResult: Record<string, unknown> = { data: [], error: null };
|
|
|
8
8
|
|
|
9
9
|
const chain: Record<string, unknown> = {};
|
|
10
10
|
const chainMethods = [
|
|
11
|
-
"select",
|
|
12
|
-
"
|
|
11
|
+
"select",
|
|
12
|
+
"insert",
|
|
13
|
+
"update",
|
|
14
|
+
"delete",
|
|
15
|
+
"eq",
|
|
16
|
+
"neq",
|
|
17
|
+
"not",
|
|
18
|
+
"or",
|
|
19
|
+
"in",
|
|
20
|
+
"order",
|
|
21
|
+
"limit",
|
|
22
|
+
"single",
|
|
23
|
+
"from",
|
|
24
|
+
"lt",
|
|
13
25
|
];
|
|
14
26
|
for (const method of chainMethods) {
|
|
15
27
|
chain[method] = vi.fn().mockImplementation(() => chain);
|
|
16
28
|
}
|
|
17
29
|
// Make chain thenable — always resolves to current finalResult
|
|
18
|
-
chain.then = function (resolve: (value: unknown) => void) {
|
|
30
|
+
chain.then = function (resolve: (value: unknown) => void) {
|
|
31
|
+
return resolve(finalResult);
|
|
32
|
+
};
|
|
19
33
|
chain.single = vi.fn().mockImplementation(() => ({
|
|
20
34
|
...chain,
|
|
21
35
|
then: (resolve: (value: unknown) => void) => resolve(finalResult),
|
|
@@ -48,7 +62,9 @@ const stableMockClient = {
|
|
|
48
62
|
|
|
49
63
|
function setResult(result: Record<string, unknown>) {
|
|
50
64
|
finalResult = result;
|
|
51
|
-
chain.then = function (resolve: (value: unknown) => void) {
|
|
65
|
+
chain.then = function (resolve: (value: unknown) => void) {
|
|
66
|
+
return resolve(result);
|
|
67
|
+
};
|
|
52
68
|
chain.single = vi.fn().mockImplementation(() => ({
|
|
53
69
|
...chain,
|
|
54
70
|
then: (resolve: (value: unknown) => void) => resolve(result),
|
|
@@ -73,7 +89,10 @@ vi.mock("../utils/config.js", () => ({
|
|
|
73
89
|
|
|
74
90
|
vi.mock("../utils/logger.js", () => ({
|
|
75
91
|
log: {
|
|
76
|
-
debug: vi.fn(),
|
|
92
|
+
debug: vi.fn(),
|
|
93
|
+
info: vi.fn(),
|
|
94
|
+
warn: vi.fn(),
|
|
95
|
+
error: vi.fn(),
|
|
77
96
|
},
|
|
78
97
|
}));
|
|
79
98
|
|
|
@@ -81,9 +100,9 @@ vi.mock("fs", async (importOriginal) => {
|
|
|
81
100
|
const original = (await importOriginal()) as Record<string, unknown>;
|
|
82
101
|
return {
|
|
83
102
|
...original,
|
|
84
|
-
existsSync: vi.fn().mockReturnValue(
|
|
103
|
+
existsSync: vi.fn().mockReturnValue(true),
|
|
85
104
|
mkdirSync: vi.fn(),
|
|
86
|
-
readFileSync: vi.fn().mockReturnValue("
|
|
105
|
+
readFileSync: vi.fn().mockReturnValue(JSON.stringify({ mcp_token: "am_test_token_123" })),
|
|
87
106
|
writeFileSync: vi.fn(),
|
|
88
107
|
};
|
|
89
108
|
});
|
|
@@ -131,7 +150,14 @@ describe("Supabase DB Layer", () => {
|
|
|
131
150
|
|
|
132
151
|
const result = await createSession("user-123", "Test", "/tmp", "0.1.0");
|
|
133
152
|
|
|
134
|
-
expect(
|
|
153
|
+
expect(mockRpc).toHaveBeenCalledWith(
|
|
154
|
+
"mcp_create_session",
|
|
155
|
+
expect.objectContaining({
|
|
156
|
+
p_session_name: "Test",
|
|
157
|
+
p_workspace_path: "/tmp",
|
|
158
|
+
p_version: "0.1.0",
|
|
159
|
+
})
|
|
160
|
+
);
|
|
135
161
|
expect(result.id).toBe("sess-001");
|
|
136
162
|
expect(result.status).toBe("online");
|
|
137
163
|
});
|
|
@@ -139,9 +165,9 @@ describe("Supabase DB Layer", () => {
|
|
|
139
165
|
it("throws on DB error", async () => {
|
|
140
166
|
setResult({ data: null, error: { message: "insert failed" } });
|
|
141
167
|
|
|
142
|
-
await expect(
|
|
143
|
-
|
|
144
|
-
)
|
|
168
|
+
await expect(createSession("user-123", "Test", "/tmp", "0.1.0")).rejects.toThrow(
|
|
169
|
+
"Failed to create session"
|
|
170
|
+
);
|
|
145
171
|
});
|
|
146
172
|
});
|
|
147
173
|
|
|
@@ -149,8 +175,12 @@ describe("Supabase DB Layer", () => {
|
|
|
149
175
|
it("updates the heartbeat timestamp", async () => {
|
|
150
176
|
setResult({ error: null });
|
|
151
177
|
await updateHeartbeat("sess-001");
|
|
152
|
-
expect(
|
|
153
|
-
|
|
178
|
+
expect(mockRpc).toHaveBeenCalledWith(
|
|
179
|
+
"mcp_heartbeat",
|
|
180
|
+
expect.objectContaining({
|
|
181
|
+
p_session_id: "sess-001",
|
|
182
|
+
})
|
|
183
|
+
);
|
|
154
184
|
});
|
|
155
185
|
});
|
|
156
186
|
|
|
@@ -158,8 +188,12 @@ describe("Supabase DB Layer", () => {
|
|
|
158
188
|
it("sets session to offline", async () => {
|
|
159
189
|
setResult({ error: null });
|
|
160
190
|
await endSession("sess-001");
|
|
161
|
-
expect(
|
|
162
|
-
|
|
191
|
+
expect(mockRpc).toHaveBeenCalledWith(
|
|
192
|
+
"mcp_end_session",
|
|
193
|
+
expect.objectContaining({
|
|
194
|
+
p_session_id: "sess-001",
|
|
195
|
+
})
|
|
196
|
+
);
|
|
163
197
|
});
|
|
164
198
|
});
|
|
165
199
|
|
|
@@ -167,7 +201,13 @@ describe("Supabase DB Layer", () => {
|
|
|
167
201
|
it("sets session to busy", async () => {
|
|
168
202
|
setResult({ error: null });
|
|
169
203
|
await setSessionBusy("sess-001", true);
|
|
170
|
-
expect(
|
|
204
|
+
expect(mockRpc).toHaveBeenCalledWith(
|
|
205
|
+
"mcp_set_session_busy",
|
|
206
|
+
expect.objectContaining({
|
|
207
|
+
p_session_id: "sess-001",
|
|
208
|
+
p_busy: true,
|
|
209
|
+
})
|
|
210
|
+
);
|
|
171
211
|
});
|
|
172
212
|
});
|
|
173
213
|
|
|
@@ -208,9 +248,12 @@ describe("Supabase DB Layer", () => {
|
|
|
208
248
|
it("updates status to running", async () => {
|
|
209
249
|
setResult({ error: null });
|
|
210
250
|
await claimTask("msg-001");
|
|
211
|
-
expect(
|
|
212
|
-
|
|
213
|
-
|
|
251
|
+
expect(mockRpc).toHaveBeenCalledWith(
|
|
252
|
+
"mcp_claim_task",
|
|
253
|
+
expect.objectContaining({
|
|
254
|
+
p_message_id: "msg-001",
|
|
255
|
+
})
|
|
256
|
+
);
|
|
214
257
|
});
|
|
215
258
|
|
|
216
259
|
it("throws on DB error", async () => {
|
|
@@ -223,7 +266,13 @@ describe("Supabase DB Layer", () => {
|
|
|
223
266
|
it("updates status to completed with result", async () => {
|
|
224
267
|
setResult({ data: { metadata: {} }, error: null });
|
|
225
268
|
await completeTask("msg-001", "Task completed successfully");
|
|
226
|
-
expect(
|
|
269
|
+
expect(mockRpc).toHaveBeenCalledWith(
|
|
270
|
+
"mcp_complete_task",
|
|
271
|
+
expect.objectContaining({
|
|
272
|
+
p_message_id: "msg-001",
|
|
273
|
+
p_result: "Task completed successfully",
|
|
274
|
+
})
|
|
275
|
+
);
|
|
227
276
|
});
|
|
228
277
|
});
|
|
229
278
|
|
|
@@ -231,7 +280,13 @@ describe("Supabase DB Layer", () => {
|
|
|
231
280
|
it("updates status to failed with error", async () => {
|
|
232
281
|
setResult({ data: { metadata: {} }, error: null });
|
|
233
282
|
await failTask("msg-001", "Something went wrong");
|
|
234
|
-
expect(
|
|
283
|
+
expect(mockRpc).toHaveBeenCalledWith(
|
|
284
|
+
"mcp_fail_task",
|
|
285
|
+
expect.objectContaining({
|
|
286
|
+
p_message_id: "msg-001",
|
|
287
|
+
p_error: "Something went wrong",
|
|
288
|
+
})
|
|
289
|
+
);
|
|
235
290
|
});
|
|
236
291
|
});
|
|
237
292
|
|
|
@@ -242,42 +297,44 @@ describe("Supabase DB Layer", () => {
|
|
|
242
297
|
await emitEvent("msg-001", "text_delta", { text: "hello" });
|
|
243
298
|
await emitEvent("msg-001", "text_delta", { text: "world" });
|
|
244
299
|
|
|
245
|
-
expect(
|
|
300
|
+
expect(mockRpc).toHaveBeenCalledWith(
|
|
301
|
+
"mcp_emit_event",
|
|
302
|
+
expect.objectContaining({
|
|
303
|
+
p_message_id: "msg-001",
|
|
304
|
+
p_event_type: "text_delta",
|
|
305
|
+
})
|
|
306
|
+
);
|
|
246
307
|
});
|
|
247
308
|
});
|
|
248
309
|
|
|
249
310
|
describe("loginWithToken()", () => {
|
|
250
|
-
it("
|
|
251
|
-
|
|
252
|
-
JSON.stringify({
|
|
253
|
-
access_token: "test-access",
|
|
254
|
-
refresh_token: "test-refresh",
|
|
255
|
-
})
|
|
256
|
-
).toString("base64");
|
|
311
|
+
it("validates am_ token via RPC and returns user ID", async () => {
|
|
312
|
+
setResult({ data: [{ out_user_id: "user-123" }], error: null });
|
|
257
313
|
|
|
258
|
-
const userId = await loginWithToken(
|
|
314
|
+
const userId = await loginWithToken("am_valid_test_token");
|
|
259
315
|
expect(userId).toBe("user-123");
|
|
316
|
+
expect(mockRpc).toHaveBeenCalledWith(
|
|
317
|
+
"validate_mcp_token",
|
|
318
|
+
expect.objectContaining({
|
|
319
|
+
p_token_hash: expect.any(String),
|
|
320
|
+
})
|
|
321
|
+
);
|
|
260
322
|
});
|
|
261
323
|
|
|
262
|
-
it("throws on invalid token format", async () => {
|
|
263
|
-
await expect(loginWithToken("not-
|
|
264
|
-
"Invalid token format"
|
|
265
|
-
);
|
|
324
|
+
it("throws on invalid token format (no am_ prefix)", async () => {
|
|
325
|
+
await expect(loginWithToken("not-am-token")).rejects.toThrow("Invalid token format");
|
|
266
326
|
});
|
|
267
327
|
|
|
268
|
-
it("throws on
|
|
269
|
-
|
|
270
|
-
JSON.stringify({ access_token: "only-one" })
|
|
271
|
-
).toString("base64");
|
|
328
|
+
it("throws on RPC validation failure", async () => {
|
|
329
|
+
setResult({ data: null, error: { message: "validation failed" } });
|
|
272
330
|
|
|
273
|
-
await expect(loginWithToken(
|
|
274
|
-
"Invalid token format"
|
|
275
|
-
);
|
|
331
|
+
await expect(loginWithToken("am_bad_token")).rejects.toThrow("Token validation failed");
|
|
276
332
|
});
|
|
277
333
|
});
|
|
278
334
|
|
|
279
335
|
describe("getCurrentUserId()", () => {
|
|
280
336
|
it("returns user ID when authenticated", async () => {
|
|
337
|
+
setResult({ data: [{ out_user_id: "user-123" }], error: null });
|
|
281
338
|
const userId = await getCurrentUserId();
|
|
282
339
|
expect(userId).toBe("user-123");
|
|
283
340
|
});
|
package/src/tools/browser.ts
CHANGED
|
@@ -583,6 +583,79 @@ export class BrowserController {
|
|
|
583
583
|
isConnected(): boolean {
|
|
584
584
|
return this.connected && this.ws?.readyState === WebSocket.OPEN;
|
|
585
585
|
}
|
|
586
|
+
|
|
587
|
+
// ── Login Detection ────────────────────────────────────────────
|
|
588
|
+
|
|
589
|
+
/**
|
|
590
|
+
* Detect if the current page appears to be a login/authentication page.
|
|
591
|
+
* Checks URL patterns, password input fields, and login form actions.
|
|
592
|
+
*/
|
|
593
|
+
async detectLoginPage(): Promise<{ isLoginPage: boolean; reason: string }> {
|
|
594
|
+
try {
|
|
595
|
+
const result = await this.send("Runtime.evaluate", {
|
|
596
|
+
expression: `
|
|
597
|
+
(function() {
|
|
598
|
+
var url = window.location.href.toLowerCase();
|
|
599
|
+
|
|
600
|
+
// URL-based detection
|
|
601
|
+
var loginPatterns = [
|
|
602
|
+
'/login', '/signin', '/sign-in', '/sign_in',
|
|
603
|
+
'/auth/', '/sso/', '/oauth/', '/session/new',
|
|
604
|
+
'/accounts/login', '/users/sign_in',
|
|
605
|
+
'accounts.google.com', 'login.microsoftonline.com',
|
|
606
|
+
'github.com/login', 'github.com/session',
|
|
607
|
+
'login.live.com', 'appleid.apple.com'
|
|
608
|
+
];
|
|
609
|
+
for (var i = 0; i < loginPatterns.length; i++) {
|
|
610
|
+
if (url.indexOf(loginPatterns[i]) !== -1) {
|
|
611
|
+
return JSON.stringify({
|
|
612
|
+
isLoginPage: true,
|
|
613
|
+
reason: 'URL contains login pattern: ' + loginPatterns[i]
|
|
614
|
+
});
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
// Password input detection (visible only)
|
|
619
|
+
var passwordInputs = document.querySelectorAll('input[type="password"]');
|
|
620
|
+
for (var j = 0; j < passwordInputs.length; j++) {
|
|
621
|
+
var input = passwordInputs[j];
|
|
622
|
+
var rect = input.getBoundingClientRect();
|
|
623
|
+
var style = window.getComputedStyle(input);
|
|
624
|
+
if (rect.width > 0 && rect.height > 0 &&
|
|
625
|
+
style.display !== 'none' && style.visibility !== 'hidden') {
|
|
626
|
+
return JSON.stringify({
|
|
627
|
+
isLoginPage: true,
|
|
628
|
+
reason: 'Page contains visible password input field'
|
|
629
|
+
});
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// Login form action detection
|
|
634
|
+
var formSelectors = [
|
|
635
|
+
'form[action*="login"]', 'form[action*="signin"]',
|
|
636
|
+
'form[action*="session"]', 'form[action*="auth"]',
|
|
637
|
+
'form[action*="authenticate"]'
|
|
638
|
+
];
|
|
639
|
+
var loginForms = document.querySelectorAll(formSelectors.join(','));
|
|
640
|
+
if (loginForms.length > 0) {
|
|
641
|
+
return JSON.stringify({
|
|
642
|
+
isLoginPage: true,
|
|
643
|
+
reason: 'Page contains login form'
|
|
644
|
+
});
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
return JSON.stringify({ isLoginPage: false, reason: '' });
|
|
648
|
+
})()
|
|
649
|
+
`,
|
|
650
|
+
returnByValue: true,
|
|
651
|
+
});
|
|
652
|
+
|
|
653
|
+
const value = (result as CDPEvalResult).result?.value as string;
|
|
654
|
+
return JSON.parse(value || '{"isLoginPage":false,"reason":""}');
|
|
655
|
+
} catch {
|
|
656
|
+
return { isLoginPage: false, reason: "" };
|
|
657
|
+
}
|
|
658
|
+
}
|
|
586
659
|
}
|
|
587
660
|
|
|
588
661
|
// ── Chrome Auto-Launch ──────────────────────────────────────────────
|
package/src/tools/index.ts
CHANGED
|
@@ -270,6 +270,81 @@ export function getToolDefinitions(): ToolDefinition[] {
|
|
|
270
270
|
];
|
|
271
271
|
}
|
|
272
272
|
|
|
273
|
+
/**
|
|
274
|
+
* After navigation, detect if the page requires login and automatically prompt
|
|
275
|
+
* the user to log in. Waits for login completion and returns context about the
|
|
276
|
+
* login result. Returns null if no login was needed.
|
|
277
|
+
*
|
|
278
|
+
* The browser uses a persistent profile (~/.assistme/browser-profile), so once
|
|
279
|
+
* the user logs in, their session is saved and reused in future sessions.
|
|
280
|
+
*/
|
|
281
|
+
async function detectAndHandleLogin(browser: BrowserController): Promise<string | null> {
|
|
282
|
+
const detection = await browser.detectLoginPage();
|
|
283
|
+
if (!detection.isLoginPage) return null;
|
|
284
|
+
|
|
285
|
+
const pageInfo = await browser.getPageInfo();
|
|
286
|
+
let siteName: string;
|
|
287
|
+
try {
|
|
288
|
+
siteName = new URL(pageInfo.url).hostname;
|
|
289
|
+
} catch {
|
|
290
|
+
siteName = pageInfo.url;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Notify user via terminal
|
|
294
|
+
console.log("\n");
|
|
295
|
+
console.log("\u2501".repeat(60));
|
|
296
|
+
console.log(" \uD83D\uDD10 LOGIN REQUIRED");
|
|
297
|
+
console.log("\u2501".repeat(60));
|
|
298
|
+
console.log(` ${detection.reason}`);
|
|
299
|
+
console.log(` Site: ${siteName}`);
|
|
300
|
+
console.log(` Please log in using the browser window.`);
|
|
301
|
+
console.log(` Your session will be saved for future use.`);
|
|
302
|
+
console.log(` (Waiting up to 120s for you to complete login)`);
|
|
303
|
+
console.log("\u2501".repeat(60));
|
|
304
|
+
console.log("\n");
|
|
305
|
+
|
|
306
|
+
// Wait for user to log in — poll for URL change or login form disappearing
|
|
307
|
+
const loginUrl = pageInfo.url;
|
|
308
|
+
const deadline = Date.now() + 120_000;
|
|
309
|
+
|
|
310
|
+
while (Date.now() < deadline) {
|
|
311
|
+
await new Promise((r) => setTimeout(r, 3000));
|
|
312
|
+
try {
|
|
313
|
+
const currentInfo = await browser.getPageInfo();
|
|
314
|
+
// URL changed = user likely logged in and was redirected
|
|
315
|
+
if (currentInfo.url !== loginUrl) {
|
|
316
|
+
// Give the page a moment to settle after redirect
|
|
317
|
+
await new Promise((r) => setTimeout(r, 1500));
|
|
318
|
+
const newPage = await browser.readPage();
|
|
319
|
+
return (
|
|
320
|
+
`Login completed. Redirected to: ${currentInfo.url}\n` +
|
|
321
|
+
`Session saved in assistme browser profile for future use.\n\n` +
|
|
322
|
+
`Current page:\n${newPage.slice(0, 3000)}`
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
// Check if login form disappeared (user logged in on same page)
|
|
326
|
+
const stillLogin = await browser.detectLoginPage();
|
|
327
|
+
if (!stillLogin.isLoginPage) {
|
|
328
|
+
const newPage = await browser.readPage();
|
|
329
|
+
return (
|
|
330
|
+
`Login completed on ${siteName}.\n` +
|
|
331
|
+
`Session saved in assistme browser profile for future use.\n\n` +
|
|
332
|
+
`Current page:\n${newPage.slice(0, 3000)}`
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
} catch {
|
|
336
|
+
// Connection may drop during navigation — reconnect
|
|
337
|
+
try {
|
|
338
|
+
await browser.connect();
|
|
339
|
+
} catch {
|
|
340
|
+
/* best effort */
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
return `Login wait timed out after 120s. The user may still need to log in at ${siteName}.`;
|
|
346
|
+
}
|
|
347
|
+
|
|
273
348
|
/**
|
|
274
349
|
* Ensure Chrome is running with CDP and we have an active WebSocket connection.
|
|
275
350
|
* Called lazily by every browser tool so the user never has to call browser_connect explicitly.
|
|
@@ -320,9 +395,18 @@ export async function executeTool(name: string, input: Record<string, unknown>):
|
|
|
320
395
|
await ensureConnected(browser, input.tab_index as number | undefined);
|
|
321
396
|
return browser.isConnected() ? "Connected to browser." : "Failed to connect.";
|
|
322
397
|
}
|
|
323
|
-
case "browser_navigate":
|
|
398
|
+
case "browser_navigate": {
|
|
324
399
|
await ensureConnected(browser);
|
|
325
|
-
|
|
400
|
+
const navResult = await browser.navigate(input.url as string);
|
|
401
|
+
|
|
402
|
+
// Auto-detect login pages and prompt user if needed
|
|
403
|
+
const loginResult = await detectAndHandleLogin(browser);
|
|
404
|
+
if (loginResult) {
|
|
405
|
+
return navResult + "\n\n" + loginResult;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
return navResult;
|
|
409
|
+
}
|
|
326
410
|
case "browser_read_page":
|
|
327
411
|
await ensureConnected(browser);
|
|
328
412
|
return browser.readPage();
|
package/src/utils/config.test.ts
CHANGED
|
@@ -28,9 +28,7 @@ vi.mock("conf", () => {
|
|
|
28
28
|
};
|
|
29
29
|
});
|
|
30
30
|
|
|
31
|
-
const { getConfig, setConfig, clearConfig, getConfigPath } = await import(
|
|
32
|
-
"./config.js"
|
|
33
|
-
);
|
|
31
|
+
const { getConfig, setConfig, clearConfig, getConfigPath } = await import("./config.js");
|
|
34
32
|
|
|
35
33
|
describe("config module", () => {
|
|
36
34
|
const originalEnv = { ...process.env };
|
|
@@ -74,10 +72,10 @@ describe("config module", () => {
|
|
|
74
72
|
expect(config.supabaseUrl).toBe("https://config.supabase.co");
|
|
75
73
|
});
|
|
76
74
|
|
|
77
|
-
it("returns empty string for unset values", () => {
|
|
75
|
+
it("returns defaults for supabase and empty string for unset optional values", () => {
|
|
78
76
|
const config = getConfig();
|
|
79
|
-
expect(config.supabaseUrl).
|
|
80
|
-
expect(config.supabaseAnonKey).
|
|
77
|
+
expect(config.supabaseUrl).toContain("supabase.co");
|
|
78
|
+
expect(config.supabaseAnonKey).toBeTruthy();
|
|
81
79
|
expect(config.anthropicApiKey).toBe("");
|
|
82
80
|
});
|
|
83
81
|
|