import { randomUUID } from 'node:crypto' import { lstat, mkdir, readFile, realpath } from 'node:fs/promises' import { isAbsolute, join, relative, resolve } from 'node:path' import { z } from 'zod' import { browserProfileIdSchema, browserProfileNameSchema } from '../../shared/capability-contracts' import { isMissingFileError, writeJsonFileAtomically } from '../settings-file-utils' const MAX_PROFILES = 32 const MAX_REFERENCES = 64 const MAX_STORE_BYTES = 256 * 1024 const boundedPathSchema = z .string() .trim() .min(1) .max(2_048) .refine((value) => isAbsolute(value), 'Browser executable path must be absolute') .refine( (value) => resolve(value) === value, 'Browser executable path must be normalized' ) export const browserExecutableMetadataSchema = z .object({ executablePath: boundedPathSchema, displayName: browserProfileNameSchema, source: z.literal('user-selected') }) .strict() export const browserProfileReferenceSchema = z .object({ kind: z.enum(['capability', 'automation']), id: z.string().trim().min(1).max(128).regex(/^[a-zA-Z0-9._:-]+$/u) }) .strict() export const browserProfileSchema = z .object({ id: browserProfileIdSchema, name: browserProfileNameSchema, mode: z.literal('managed-isolated'), browser: browserExecutableMetadataSchema.optional(), references: z.array(browserProfileReferenceSchema).max(MAX_REFERENCES) }) .strict() export const browserProfileStateSchema = z .object({ version: z.literal(2), profiles: z.array(browserProfileSchema).max(MAX_PROFILES), defaultProfileId: browserProfileIdSchema.nullable() }) .strict() .superRefine((state, context) => { const ids = new Set() for (const [index, profile] of state.profiles.entries()) { if (ids.has(profile.id)) { context.addIssue({ code: 'custom', path: ['profiles', index, 'id'], message: 'Browser profile IDs must be unique' }) } ids.add(profile.id) } if (state.defaultProfileId && !ids.has(state.defaultProfileId)) { context.addIssue({ code: 'custom', path: ['defaultProfileId'], message: 'Default browser profile must exist' }) } }) const version1ProfileSchema = z .object({ id: browserProfileIdSchema, name: browserProfileNameSchema, browser: browserExecutableMetadataSchema.optional() }) .strict() const version1StateSchema = z .object({ version: z.literal(1), profiles: z.array(version1ProfileSchema).max(MAX_PROFILES), defaultProfileId: browserProfileIdSchema.nullable().optional() }) .strict() export type BrowserExecutableMetadata = z.infer< typeof browserExecutableMetadataSchema > export type BrowserProfileReference = z.infer< typeof browserProfileReferenceSchema > export type BrowserProfile = z.infer export type BrowserProfileState = z.infer const emptyState = (): BrowserProfileState => ({ version: 2, profiles: [], defaultProfileId: null }) function migrateState( value: unknown ): { state: BrowserProfileState; migrated: boolean } { const version = z .object({ version: z.union([z.literal(1), z.literal(2)]) }) .passthrough() .parse(value).version if (version === 2) { return { state: browserProfileStateSchema.parse(value), migrated: false } } const legacy = version1StateSchema.parse(value) return { state: browserProfileStateSchema.parse({ version: 2, profiles: legacy.profiles.map((profile) => ({ ...profile, mode: 'managed-isolated', references: [] })), defaultProfileId: legacy.defaultProfileId ?? legacy.profiles[0]?.id ?? null }), migrated: true } } export interface BrowserProfileStore { load(): Promise save(state: BrowserProfileState): Promise } export class MemoryBrowserProfileStore implements BrowserProfileStore { private value: unknown constructor(initialValue?: unknown) { this.value = initialValue } async load(): Promise { return structuredClone(this.value) } async save(state: BrowserProfileState): Promise { this.value = structuredClone(state) } } export class FileBrowserProfileStore implements BrowserProfileStore { private readonly fileName = 'browser-profiles.json' constructor(private readonly ownedRoot: string) { if (!isAbsolute(ownedRoot)) { throw new Error('Browser profile storage root must be absolute') } } private async prepareRoot(): Promise<{ root: string; filePath: string }> { await mkdir(this.ownedRoot, { recursive: true, mode: 0o700 }) const rootDetails = await lstat(this.ownedRoot) if (!rootDetails.isDirectory() || rootDetails.isSymbolicLink()) { throw new Error('Browser profile storage root must be a real directory') } const root = await realpath(this.ownedRoot) const filePath = join(root, this.fileName) const fromRoot = relative(root, filePath) if ( fromRoot.startsWith('..') || isAbsolute(fromRoot) || fromRoot === '' ) { throw new Error('Browser profile storage path escapes its owned root') } return { root, filePath } } async load(): Promise { const { filePath } = await this.prepareRoot() try { const details = await lstat(filePath) if (details.isSymbolicLink() || !details.isFile()) { throw new Error('Browser profile storage file must be a regular file') } if (details.size > MAX_STORE_BYTES) { throw new Error('Browser profile storage file is too large') } return JSON.parse(await readFile(filePath, 'utf8')) as unknown } catch (error) { if (isMissingFileError(error)) { return undefined } throw error } } async save(state: BrowserProfileState): Promise { const { filePath } = await this.prepareRoot() try { const targetDetails = await lstat(filePath) if (targetDetails.isSymbolicLink() || !targetDetails.isFile()) { throw new Error('Browser profile storage file must be a regular file') } } catch (error) { if (!isMissingFileError(error)) { throw error } } await writeJsonFileAtomically( filePath, browserProfileStateSchema.parse(state) ) } } export class BrowserProfileService { private state?: BrowserProfileState private updateQueue: Promise = Promise.resolve() constructor(private readonly store: BrowserProfileStore) {} private async getState(): Promise { if (!this.state) { const loaded = await this.store.load() const result = loaded === undefined ? { state: emptyState(), migrated: false } : migrateState(loaded) if (result.migrated) { await this.store.save(result.state) } this.state = result.state } return this.state } private async update( operation: (state: BrowserProfileState) => BrowserProfileState ): Promise { let result: BrowserProfileState | undefined const queued = this.updateQueue.then(async () => { const next = browserProfileStateSchema.parse( operation(structuredClone(await this.getState())) ) await this.store.save(next) this.state = next result = next }) this.updateQueue = queued.catch(() => undefined) await queued if (!result) { throw new Error('Browser profile update failed') } return structuredClone(result) } async getSnapshot(): Promise { return structuredClone(await this.getState()) } async createProfile(name: string): Promise { const parsedName = browserProfileNameSchema.parse(name) return this.update((state) => { if (state.profiles.length >= MAX_PROFILES) { throw new Error('Browser profile limit reached') } const profile: BrowserProfile = { id: randomUUID(), name: parsedName, mode: 'managed-isolated', references: [] } state.profiles.push(profile) state.defaultProfileId ??= profile.id return state }) } async renameProfile( profileId: string, name: string ): Promise { const id = browserProfileIdSchema.parse(profileId) const parsedName = browserProfileNameSchema.parse(name) return this.update((state) => { const profile = state.profiles.find((candidate) => candidate.id === id) if (!profile) { throw new Error('Browser profile not found') } profile.name = parsedName return state }) } async selectBrowser( profileId: string, browser?: BrowserExecutableMetadata ): Promise { const id = browserProfileIdSchema.parse(profileId) const parsedBrowser = browserExecutableMetadataSchema .optional() .parse(browser) return this.update((state) => { const profile = state.profiles.find((candidate) => candidate.id === id) if (!profile) { throw new Error('Browser profile not found') } profile.browser = parsedBrowser return state }) } async setDefaultProfile(profileId: string): Promise { const id = browserProfileIdSchema.parse(profileId) return this.update((state) => { if (!state.profiles.some((profile) => profile.id === id)) { throw new Error('Browser profile not found') } state.defaultProfileId = id return state }) } async addReference( profileId: string, reference: BrowserProfileReference ): Promise { const id = browserProfileIdSchema.parse(profileId) const parsedReference = browserProfileReferenceSchema.parse(reference) return this.update((state) => { const profile = state.profiles.find((candidate) => candidate.id === id) if (!profile) { throw new Error('Browser profile not found') } if ( !profile.references.some( (candidate) => candidate.kind === parsedReference.kind && candidate.id === parsedReference.id ) ) { profile.references.push(parsedReference) } return state }) } async removeReference( profileId: string, reference: BrowserProfileReference ): Promise { const id = browserProfileIdSchema.parse(profileId) const parsedReference = browserProfileReferenceSchema.parse(reference) return this.update((state) => { const profile = state.profiles.find((candidate) => candidate.id === id) if (!profile) { throw new Error('Browser profile not found') } profile.references = profile.references.filter( (candidate) => candidate.kind !== parsedReference.kind || candidate.id !== parsedReference.id ) return state }) } async deleteProfile(profileId: string): Promise { const id = browserProfileIdSchema.parse(profileId) return this.update((state) => { const profile = state.profiles.find((candidate) => candidate.id === id) if (!profile) { throw new Error('Browser profile not found') } if (profile.references.length > 0) { throw new Error('Referenced browser profiles cannot be deleted') } state.profiles = state.profiles.filter( (candidate) => candidate.id !== id ) if (state.defaultProfileId === id) { state.defaultProfileId = state.profiles[0]?.id ?? null } return state }) } }