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 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
- return browser.navigate(input.url);
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, ask the user to log in using browser_request_user_action
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 required \u2192 browser_request_user_action \u2192 wait \u2192 continue
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
- - If a page needs authentication, use browser_request_user_action immediately
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "assistme",
3
- "version": "0.1.13",
3
+ "version": "0.1.14",
4
4
  "description": "AssistMe CLI Agent - AI-powered assistant that controls your real browser",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -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, ask the user to log in using browser_request_user_action
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 required → browser_request_user_action wait continue
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
- - If a page needs authentication, use browser_request_user_action immediately
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
@@ -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
  }
@@ -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", "insert", "update", "delete", "eq", "neq", "not",
12
- "or", "in", "order", "limit", "single", "from", "lt",
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) { return resolve(finalResult); };
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) { return resolve(result); };
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(), info: vi.fn(), warn: vi.fn(), error: 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(false),
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(mockFrom).toHaveBeenCalledWith("agent_sessions");
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
- createSession("user-123", "Test", "/tmp", "0.1.0")
144
- ).rejects.toThrow("Failed to create session");
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(mockFrom).toHaveBeenCalledWith("agent_sessions");
153
- expect(chain.eq).toHaveBeenCalledWith("id", "sess-001");
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(mockFrom).toHaveBeenCalledWith("agent_sessions");
162
- expect(chain.update).toHaveBeenCalled();
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(chain.update).toHaveBeenCalled();
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(mockFrom).toHaveBeenCalledWith("conversation_messages");
212
- expect(chain.eq).toHaveBeenCalledWith("id", "msg-001");
213
- expect(chain.eq).toHaveBeenCalledWith("status", "pending");
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(mockFrom).toHaveBeenCalledWith("conversation_messages");
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(mockFrom).toHaveBeenCalledWith("conversation_messages");
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(mockFrom).toHaveBeenCalledWith("message_events");
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("decodes base64 token and sets session", async () => {
251
- const token = Buffer.from(
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(token);
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-base64")).rejects.toThrow(
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 missing token fields", async () => {
269
- const token = Buffer.from(
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(token)).rejects.toThrow(
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
  });
@@ -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 ──────────────────────────────────────────────
@@ -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
- return browser.navigate(input.url as string);
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();
@@ -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).toBe("");
80
- expect(config.supabaseAnonKey).toBe("");
77
+ expect(config.supabaseUrl).toContain("supabase.co");
78
+ expect(config.supabaseAnonKey).toBeTruthy();
81
79
  expect(config.anthropicApiKey).toBe("");
82
80
  });
83
81