agent-browser-loop 0.2.2 → 0.3.1

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.
@@ -0,0 +1,414 @@
1
+ import * as fs from "node:fs";
2
+ import * as os from "node:os";
3
+ import * as path from "node:path";
4
+ import { z } from "zod";
5
+ import type { StorageState } from "./types";
6
+
7
+ // ============================================================================
8
+ // Profile Schema & Types
9
+ // ============================================================================
10
+
11
+ const profileMetaSchema = z.object({
12
+ createdAt: z.string().optional(),
13
+ lastUsedAt: z.string().optional(),
14
+ description: z.string().optional(),
15
+ origins: z.array(z.string()).optional(),
16
+ });
17
+
18
+ const storageStateSchema = z.object({
19
+ cookies: z
20
+ .array(
21
+ z.object({
22
+ name: z.string(),
23
+ value: z.string(),
24
+ domain: z.string(),
25
+ path: z.string(),
26
+ expires: z.number(),
27
+ httpOnly: z.boolean(),
28
+ secure: z.boolean(),
29
+ sameSite: z.enum(["Strict", "Lax", "None"]),
30
+ }),
31
+ )
32
+ .default([]),
33
+ origins: z
34
+ .array(
35
+ z.object({
36
+ origin: z.string(),
37
+ localStorage: z
38
+ .array(z.object({ name: z.string(), value: z.string() }))
39
+ .default([]),
40
+ }),
41
+ )
42
+ .default([]),
43
+ });
44
+
45
+ const profileSchema = z.object({
46
+ _meta: profileMetaSchema.optional(),
47
+ cookies: storageStateSchema.shape.cookies,
48
+ origins: storageStateSchema.shape.origins,
49
+ });
50
+
51
+ export type ProfileMeta = z.infer<typeof profileMetaSchema>;
52
+ export type Profile = z.infer<typeof profileSchema>;
53
+
54
+ export interface ProfileInfo {
55
+ name: string;
56
+ scope: "local" | "local-private" | "global";
57
+ path: string;
58
+ meta?: ProfileMeta;
59
+ }
60
+
61
+ // ============================================================================
62
+ // Path Resolution
63
+ // ============================================================================
64
+
65
+ /**
66
+ * Get the global profiles directory
67
+ * Uses XDG_CONFIG_HOME or falls back to ~/.config
68
+ */
69
+ function getGlobalProfilesDir(): string {
70
+ const xdgConfig = process.env.XDG_CONFIG_HOME;
71
+ const base = xdgConfig || path.join(os.homedir(), ".config");
72
+ return path.join(base, "agent-browser", "profiles");
73
+ }
74
+
75
+ /**
76
+ * Get the local profiles directory (project-scoped)
77
+ */
78
+ function getLocalProfilesDir(cwd?: string): string {
79
+ return path.join(cwd || process.cwd(), ".agent-browser", "profiles");
80
+ }
81
+
82
+ /**
83
+ * Get the local private profiles directory (gitignored)
84
+ */
85
+ function getLocalPrivateProfilesDir(cwd?: string): string {
86
+ return path.join(getLocalProfilesDir(cwd), ".private");
87
+ }
88
+
89
+ /**
90
+ * Resolve profile path by name, checking local first then global
91
+ * Returns null if not found
92
+ */
93
+ export function resolveProfilePath(
94
+ name: string,
95
+ cwd?: string,
96
+ ): { path: string; scope: "local" | "local-private" | "global" } | null {
97
+ // Check local private first
98
+ const localPrivatePath = path.join(
99
+ getLocalPrivateProfilesDir(cwd),
100
+ `${name}.json`,
101
+ );
102
+ if (fs.existsSync(localPrivatePath)) {
103
+ return { path: localPrivatePath, scope: "local-private" };
104
+ }
105
+
106
+ // Check local
107
+ const localPath = path.join(getLocalProfilesDir(cwd), `${name}.json`);
108
+ if (fs.existsSync(localPath)) {
109
+ return { path: localPath, scope: "local" };
110
+ }
111
+
112
+ // Check global
113
+ const globalPath = path.join(getGlobalProfilesDir(), `${name}.json`);
114
+ if (fs.existsSync(globalPath)) {
115
+ return { path: globalPath, scope: "global" };
116
+ }
117
+
118
+ return null;
119
+ }
120
+
121
+ /**
122
+ * Get the path where a new profile should be saved
123
+ */
124
+ export function getProfileSavePath(
125
+ name: string,
126
+ options?: { global?: boolean; private?: boolean; cwd?: string },
127
+ ): string {
128
+ if (options?.global) {
129
+ return path.join(getGlobalProfilesDir(), `${name}.json`);
130
+ }
131
+ if (options?.private) {
132
+ return path.join(getLocalPrivateProfilesDir(options?.cwd), `${name}.json`);
133
+ }
134
+ return path.join(getLocalProfilesDir(options?.cwd), `${name}.json`);
135
+ }
136
+
137
+ // ============================================================================
138
+ // Profile CRUD Operations
139
+ // ============================================================================
140
+
141
+ /**
142
+ * List all available profiles (local + global)
143
+ */
144
+ export function listProfiles(cwd?: string): ProfileInfo[] {
145
+ const profiles: ProfileInfo[] = [];
146
+ const seen = new Set<string>();
147
+
148
+ // Helper to scan a directory
149
+ const scanDir = (
150
+ dir: string,
151
+ scope: "local" | "local-private" | "global",
152
+ ) => {
153
+ if (!fs.existsSync(dir)) return;
154
+ const files = fs.readdirSync(dir);
155
+ for (const file of files) {
156
+ if (!file.endsWith(".json")) continue;
157
+ // Skip .private directory when scanning local
158
+ if (file === ".private") continue;
159
+
160
+ const name = file.replace(/\.json$/, "");
161
+ if (seen.has(name)) continue; // Local takes precedence
162
+ seen.add(name);
163
+
164
+ const profilePath = path.join(dir, file);
165
+ try {
166
+ const content = JSON.parse(fs.readFileSync(profilePath, "utf-8"));
167
+ const parsed = profileSchema.safeParse(content);
168
+ profiles.push({
169
+ name,
170
+ scope,
171
+ path: profilePath,
172
+ meta: parsed.success ? parsed.data._meta : undefined,
173
+ });
174
+ } catch {
175
+ // Invalid profile, still list it
176
+ profiles.push({
177
+ name,
178
+ scope,
179
+ path: profilePath,
180
+ });
181
+ }
182
+ }
183
+ };
184
+
185
+ // Scan in order of precedence
186
+ scanDir(getLocalPrivateProfilesDir(cwd), "local-private");
187
+ scanDir(getLocalProfilesDir(cwd), "local");
188
+ scanDir(getGlobalProfilesDir(), "global");
189
+
190
+ return profiles.sort((a, b) => a.name.localeCompare(b.name));
191
+ }
192
+
193
+ /**
194
+ * Load a profile by name
195
+ */
196
+ export function loadProfile(name: string, cwd?: string): Profile | null {
197
+ const resolved = resolveProfilePath(name, cwd);
198
+ if (!resolved) return null;
199
+
200
+ try {
201
+ const content = JSON.parse(fs.readFileSync(resolved.path, "utf-8"));
202
+ const parsed = profileSchema.parse(content);
203
+ return parsed;
204
+ } catch {
205
+ return null;
206
+ }
207
+ }
208
+
209
+ /**
210
+ * Get storage state from a profile (strips _meta for Playwright)
211
+ */
212
+ export function getStorageStateFromProfile(profile: Profile): StorageState {
213
+ return {
214
+ cookies: profile.cookies,
215
+ origins: profile.origins,
216
+ };
217
+ }
218
+
219
+ /**
220
+ * Load storage state by profile name
221
+ */
222
+ export function loadStorageState(
223
+ name: string,
224
+ cwd?: string,
225
+ ): StorageState | null {
226
+ const profile = loadProfile(name, cwd);
227
+ if (!profile) return null;
228
+ return getStorageStateFromProfile(profile);
229
+ }
230
+
231
+ /**
232
+ * Save a profile
233
+ */
234
+ export function saveProfile(
235
+ name: string,
236
+ storageState: StorageState,
237
+ options?: {
238
+ global?: boolean;
239
+ private?: boolean;
240
+ cwd?: string;
241
+ description?: string;
242
+ origins?: string[];
243
+ },
244
+ ): string {
245
+ const savePath = getProfileSavePath(name, options);
246
+ const dir = path.dirname(savePath);
247
+
248
+ // Ensure directory exists
249
+ if (!fs.existsSync(dir)) {
250
+ fs.mkdirSync(dir, { recursive: true });
251
+ }
252
+
253
+ // If saving to .private, ensure it's gitignored
254
+ if (options?.private) {
255
+ ensurePrivateDirGitignored(options?.cwd);
256
+ }
257
+
258
+ // Check if profile already exists to preserve metadata
259
+ let existingMeta: ProfileMeta | undefined;
260
+ if (fs.existsSync(savePath)) {
261
+ try {
262
+ const existing = JSON.parse(fs.readFileSync(savePath, "utf-8"));
263
+ existingMeta = existing._meta;
264
+ } catch {
265
+ // Ignore
266
+ }
267
+ }
268
+
269
+ const profile: Profile = {
270
+ _meta: {
271
+ createdAt: existingMeta?.createdAt || new Date().toISOString(),
272
+ lastUsedAt: new Date().toISOString(),
273
+ description: options?.description || existingMeta?.description,
274
+ origins: options?.origins || existingMeta?.origins,
275
+ },
276
+ cookies: storageState.cookies,
277
+ origins: storageState.origins,
278
+ };
279
+
280
+ fs.writeFileSync(savePath, JSON.stringify(profile, null, 2));
281
+ return savePath;
282
+ }
283
+
284
+ /**
285
+ * Delete a profile
286
+ */
287
+ export function deleteProfile(name: string, cwd?: string): boolean {
288
+ const resolved = resolveProfilePath(name, cwd);
289
+ if (!resolved) return false;
290
+
291
+ try {
292
+ fs.unlinkSync(resolved.path);
293
+ return true;
294
+ } catch {
295
+ return false;
296
+ }
297
+ }
298
+
299
+ /**
300
+ * Import a profile from an external file
301
+ */
302
+ export function importProfile(
303
+ name: string,
304
+ sourcePath: string,
305
+ options?: { global?: boolean; private?: boolean; cwd?: string },
306
+ ): string {
307
+ const content = fs.readFileSync(sourcePath, "utf-8");
308
+ const parsed = JSON.parse(content);
309
+
310
+ // Validate it's a valid storage state or profile
311
+ const asProfile = profileSchema.safeParse(parsed);
312
+ const asStorageState = storageStateSchema.safeParse(parsed);
313
+
314
+ let storageState: StorageState;
315
+ if (asProfile.success) {
316
+ storageState = getStorageStateFromProfile(asProfile.data);
317
+ } else if (asStorageState.success) {
318
+ storageState = asStorageState.data;
319
+ } else {
320
+ throw new Error(
321
+ `Invalid profile format: ${asProfile.error?.message || asStorageState.error?.message}`,
322
+ );
323
+ }
324
+
325
+ return saveProfile(name, storageState, options);
326
+ }
327
+
328
+ /**
329
+ * Update profile's lastUsedAt timestamp
330
+ */
331
+ export function touchProfile(name: string, cwd?: string): void {
332
+ const resolved = resolveProfilePath(name, cwd);
333
+ if (!resolved) return;
334
+
335
+ try {
336
+ const content = JSON.parse(fs.readFileSync(resolved.path, "utf-8"));
337
+ const parsed = profileSchema.parse(content);
338
+
339
+ parsed._meta = {
340
+ ...parsed._meta,
341
+ lastUsedAt: new Date().toISOString(),
342
+ };
343
+
344
+ fs.writeFileSync(resolved.path, JSON.stringify(parsed, null, 2));
345
+ } catch {
346
+ // Ignore errors
347
+ }
348
+ }
349
+
350
+ // ============================================================================
351
+ // Gitignore Helper
352
+ // ============================================================================
353
+
354
+ /**
355
+ * Ensure .private directory is gitignored
356
+ */
357
+ function ensurePrivateDirGitignored(cwd?: string): void {
358
+ const agentBrowserDir = path.join(cwd || process.cwd(), ".agent-browser");
359
+ const gitignorePath = path.join(agentBrowserDir, ".gitignore");
360
+
361
+ // Check if .gitignore exists and contains .private
362
+ if (fs.existsSync(gitignorePath)) {
363
+ const content = fs.readFileSync(gitignorePath, "utf-8");
364
+ if (content.includes("profiles/.private")) {
365
+ return;
366
+ }
367
+ // Append to existing
368
+ fs.appendFileSync(
369
+ gitignorePath,
370
+ "\n# Private profiles (contains auth tokens)\nprofiles/.private/\n",
371
+ );
372
+ } else {
373
+ // Create new .gitignore
374
+ if (!fs.existsSync(agentBrowserDir)) {
375
+ fs.mkdirSync(agentBrowserDir, { recursive: true });
376
+ }
377
+ fs.writeFileSync(
378
+ gitignorePath,
379
+ "# Private profiles (contains auth tokens)\nprofiles/.private/\n",
380
+ );
381
+ }
382
+ }
383
+
384
+ // ============================================================================
385
+ // Profile Resolution for Browser Options
386
+ // ============================================================================
387
+
388
+ /**
389
+ * Resolve storage state from profile name or path
390
+ * Returns the storage state to pass to browser context
391
+ */
392
+ export function resolveStorageStateOption(
393
+ profile?: string,
394
+ storageStatePath?: string,
395
+ cwd?: string,
396
+ ): StorageState | string | undefined {
397
+ // Profile takes precedence
398
+ if (profile) {
399
+ const storageState = loadStorageState(profile, cwd);
400
+ if (!storageState) {
401
+ throw new Error(`Profile not found: ${profile}`);
402
+ }
403
+ // Touch the profile to update lastUsedAt
404
+ touchProfile(profile, cwd);
405
+ return storageState;
406
+ }
407
+
408
+ // Fall back to storageStatePath
409
+ if (storageStatePath) {
410
+ return storageStatePath;
411
+ }
412
+
413
+ return undefined;
414
+ }
package/src/server.ts CHANGED
@@ -19,7 +19,16 @@ import {
19
19
  } from "./commands";
20
20
  import { createIdGenerator } from "./id";
21
21
  import { log } from "./log";
22
+ import {
23
+ deleteProfile,
24
+ listProfiles,
25
+ loadProfile,
26
+ resolveProfilePath,
27
+ resolveStorageStateOption,
28
+ saveProfile,
29
+ } from "./profiles";
22
30
  import { formatStateText } from "./state";
31
+ import type { StorageState } from "./types";
23
32
 
24
33
  export interface BrowserServerConfig {
25
34
  host?: string;
@@ -232,6 +241,7 @@ function getWaitCondition(data: WaitRequest): WaitCondition {
232
241
  const createSessionBodySchema = z.object({
233
242
  headless: z.boolean().optional(),
234
243
  userDataDir: z.string().optional(),
244
+ profile: z.string().optional(),
235
245
  });
236
246
 
237
247
  const sessionParamsSchema = z.object({
@@ -528,16 +538,43 @@ const waitRoute = createRoute({
528
538
  },
529
539
  });
530
540
 
541
+ const closeSessionBodySchema = z
542
+ .object({
543
+ saveProfile: z.string().optional(),
544
+ global: z.boolean().optional(),
545
+ private: z.boolean().optional(),
546
+ })
547
+ .optional();
548
+
531
549
  const closeRoute = createRoute({
532
550
  method: "post",
533
551
  path: "/session/{sessionId}/close",
534
552
  request: {
535
553
  params: sessionParamsSchema,
554
+ body: {
555
+ required: false,
556
+ content: {
557
+ "application/json": {
558
+ schema: closeSessionBodySchema,
559
+ },
560
+ },
561
+ },
536
562
  },
537
563
  responses: {
538
564
  204: {
539
565
  description: "Session closed",
540
566
  },
567
+ 200: {
568
+ description: "Session closed with profile saved",
569
+ content: {
570
+ "application/json": {
571
+ schema: z.object({
572
+ profileSaved: z.string().optional(),
573
+ profilePath: z.string().optional(),
574
+ }),
575
+ },
576
+ },
577
+ },
541
578
  404: {
542
579
  description: "Session not found",
543
580
  content: {
@@ -745,6 +782,14 @@ export function startBrowserServer(config: BrowserServerConfig) {
745
782
  overrides.userDataDir = body.userDataDir;
746
783
  }
747
784
 
785
+ // Handle profile option
786
+ if (body?.profile) {
787
+ const storageState = resolveStorageStateOption(body.profile);
788
+ if (typeof storageState === "object") {
789
+ overrides.storageState = storageState;
790
+ }
791
+ }
792
+
748
793
  const id = await createNewSession(overrides);
749
794
  return c.json({ sessionId: id }, 200);
750
795
  },
@@ -886,11 +931,29 @@ export function startBrowserServer(config: BrowserServerConfig) {
886
931
  app.openapi(closeRoute, async (c) => {
887
932
  const { sessionId } = c.req.valid("param");
888
933
  const session = getSessionOrThrow(sessions, sessionId);
934
+ const body = c.req.valid("json");
889
935
 
890
936
  return withSession(session, async () => {
937
+ let profilePath: string | undefined;
938
+
939
+ // Save profile before closing if requested
940
+ if (body?.saveProfile) {
941
+ const storageState =
942
+ (await session.browser.saveStorageState()) as StorageState;
943
+ profilePath = saveProfile(body.saveProfile, storageState, {
944
+ global: body.global,
945
+ private: body.private,
946
+ });
947
+ }
948
+
891
949
  await session.browser.stop();
892
950
  sessions.delete(sessionId);
893
951
  idGenerator.release(sessionId);
952
+
953
+ if (profilePath) {
954
+ return c.json({ profileSaved: body?.saveProfile, profilePath }, 200);
955
+ }
956
+
894
957
  return c.body(null, 204);
895
958
  });
896
959
  });
@@ -905,6 +968,91 @@ export function startBrowserServer(config: BrowserServerConfig) {
905
968
  });
906
969
  });
907
970
 
971
+ // ========================================================================
972
+ // Profile Endpoints
973
+ // ========================================================================
974
+
975
+ // GET /profiles - list all profiles
976
+ app.get("/profiles", (c) => {
977
+ const profiles = listProfiles();
978
+ return c.json(profiles);
979
+ });
980
+
981
+ // GET /profiles/:name - get profile contents
982
+ app.get("/profiles/:name", (c) => {
983
+ const name = c.req.param("name");
984
+ const profile = loadProfile(name);
985
+ if (!profile) {
986
+ return c.json({ error: `Profile not found: ${name}` }, 404);
987
+ }
988
+ const resolved = resolveProfilePath(name);
989
+ return c.json({
990
+ name,
991
+ scope: resolved?.scope,
992
+ path: resolved?.path,
993
+ profile,
994
+ });
995
+ });
996
+
997
+ // POST /profiles/:name - save profile from session or body
998
+ app.post("/profiles/:name", async (c) => {
999
+ const name = c.req.param("name");
1000
+ const body = await c.req.json().catch(() => ({}));
1001
+
1002
+ let storageState: StorageState;
1003
+
1004
+ // If sessionId provided, get storage state from that session
1005
+ if (body.sessionId) {
1006
+ const session = sessions.get(body.sessionId);
1007
+ if (!session) {
1008
+ return c.json({ error: `Session not found: ${body.sessionId}` }, 404);
1009
+ }
1010
+ storageState = (await session.browser.saveStorageState()) as StorageState;
1011
+ } else if (body.cookies || body.origins) {
1012
+ // Direct storage state in body
1013
+ storageState = {
1014
+ cookies: body.cookies || [],
1015
+ origins: body.origins || [],
1016
+ };
1017
+ } else {
1018
+ return c.json(
1019
+ {
1020
+ error: "Either sessionId or storage state (cookies/origins) required",
1021
+ },
1022
+ 400,
1023
+ );
1024
+ }
1025
+
1026
+ const savedPath = saveProfile(name, storageState, {
1027
+ global: body.global,
1028
+ private: body.private,
1029
+ description: body.description,
1030
+ });
1031
+
1032
+ return c.json({
1033
+ name,
1034
+ path: savedPath,
1035
+ cookies: storageState.cookies.length,
1036
+ origins: storageState.origins.length,
1037
+ });
1038
+ });
1039
+
1040
+ // DELETE /profiles/:name - delete profile
1041
+ app.delete("/profiles/:name", (c) => {
1042
+ const name = c.req.param("name");
1043
+ const resolved = resolveProfilePath(name);
1044
+ if (!resolved) {
1045
+ return c.json({ error: `Profile not found: ${name}` }, 404);
1046
+ }
1047
+
1048
+ const deleted = deleteProfile(name);
1049
+ if (!deleted) {
1050
+ return c.json({ error: `Failed to delete profile: ${name}` }, 500);
1051
+ }
1052
+
1053
+ return c.json({ deleted: name, path: resolved.path });
1054
+ });
1055
+
908
1056
  const server = Bun.serve({
909
1057
  hostname: host,
910
1058
  port,
package/src/version.ts ADDED
@@ -0,0 +1,19 @@
1
+ // Version is read from package.json at build/runtime
2
+ // This provides a single source of truth for the package version
3
+
4
+ import { readFileSync } from "node:fs";
5
+ import { dirname, join } from "node:path";
6
+
7
+ function loadVersion(): string {
8
+ try {
9
+ // Try to read from package.json relative to this file
10
+ const packagePath = join(dirname(import.meta.dirname), "package.json");
11
+ const pkg = JSON.parse(readFileSync(packagePath, "utf-8"));
12
+ return pkg.version;
13
+ } catch {
14
+ // Fallback if package.json can't be read
15
+ return "0.0.0";
16
+ }
17
+ }
18
+
19
+ export const VERSION = loadVersion();