mirror of
https://github.com/browseros-ai/BrowserOS.git
synced 2026-05-13 15:46:22 +00:00
fix: biome & tsc setup across repo (#493)
* fix: biome lint issues * fix: code quality workflow * fix: all lint issues * chore: test lefthook pre-commit hook * chore: test lefthook with agent file * chore: revert test comment from lefthook verification * feat: setup tsgo for typechecking agent * fix: typecheck cli command * fix: early return to prevent errors
This commit is contained in:
5
.github/workflows/code-quality.yml
vendored
5
.github/workflows/code-quality.yml
vendored
@@ -5,7 +5,7 @@ on:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- 'packages/browseros-agent/**'
|
||||
- "packages/browseros-agent/**"
|
||||
|
||||
jobs:
|
||||
biome:
|
||||
@@ -50,6 +50,9 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: bun ci
|
||||
|
||||
- name: Prepare wxt
|
||||
run: VITE_PUBLIC_BROWSEROS_API=http://localhost:3000 bun run --cwd apps/agent wxt prepare
|
||||
|
||||
- name: Run codegen
|
||||
run: bun run --cwd apps/agent codegen
|
||||
|
||||
|
||||
57
lefthook.yml
Normal file
57
lefthook.yml
Normal file
@@ -0,0 +1,57 @@
|
||||
commit-msg:
|
||||
commands:
|
||||
conventional:
|
||||
run: |
|
||||
msg=$(head -1 {1})
|
||||
if [[ ! "$msg" =~ ^(feat|fix|docs|style|refactor|perf|test|chore|ci|build|revert)(\(.+\))?\!?:\ .+ ]]; then
|
||||
echo "Commit message must follow Conventional Commits format:"
|
||||
echo " <type>(<optional scope>): <description>"
|
||||
echo " Types: feat, fix, docs, style, refactor, perf, test, chore, ci, build, revert"
|
||||
echo ""
|
||||
echo "Examples:"
|
||||
echo " feat(auth): add OAuth2 support"
|
||||
echo " fix: resolve null pointer exception"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
pre-commit:
|
||||
commands:
|
||||
biome-check:
|
||||
root: "packages/browseros-agent/"
|
||||
glob: "*.{js,ts,cjs,mjs,d.cts,d.mts,jsx,tsx,json,jsonc}"
|
||||
run: npx @biomejs/biome check --write --no-errors-on-unmatched --files-ignore-unknown=true --colors=off {staged_files}
|
||||
stage_fixed: true
|
||||
|
||||
file-length:
|
||||
root: "packages/browseros-agent/"
|
||||
glob: "*.{ts,tsx}"
|
||||
exclude: "*.{test,spec,d}.ts|*.{test,spec}.tsx|**/__tests__/**|**/tests/**|**/*.generated.*"
|
||||
run: |
|
||||
for file in {staged_files}; do
|
||||
if [[ -f "$file" ]]; then
|
||||
lines=$(wc -l < "$file" | tr -d ' ')
|
||||
if [[ $lines -gt 400 ]]; then
|
||||
echo "⚠️ Warning: $file has $lines lines (threshold: 400)"
|
||||
echo " Consider splitting this file if it has multiple responsibilities."
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
pre-push:
|
||||
commands:
|
||||
branch-name:
|
||||
run: |
|
||||
branch=$(git rev-parse --abbrev-ref HEAD)
|
||||
if [[ "$branch" == "main" || "$branch" == "master" ]]; then
|
||||
exit 0
|
||||
fi
|
||||
if [[ ! "$branch" =~ ^(feat|fix|bugfix|hotfix|release|docs|refactor|test|chore|experiment)/[a-z0-9-]+$ ]]; then
|
||||
echo "⚠️ Warning: Branch name '$branch' doesn't match recommended format."
|
||||
echo " Use: <type>/<short-description>"
|
||||
echo " Types: feat, fix, bugfix, hotfix, release, docs, refactor, test, chore, experiment"
|
||||
echo " Example: feat/add-auth, fix/login-crash"
|
||||
echo ""
|
||||
echo " To rename your branch:"
|
||||
echo " git branch -m <new-name>"
|
||||
echo " git push -u origin <new-name>"
|
||||
fi
|
||||
@@ -176,14 +176,14 @@ function AlertDialogCancel({
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogPortal,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogTrigger,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogPortal,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
}
|
||||
|
||||
@@ -72,4 +72,4 @@ function AlertDescription({
|
||||
)
|
||||
}
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription }
|
||||
export { Alert, AlertDescription, AlertTitle }
|
||||
|
||||
@@ -104,10 +104,10 @@ function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
}
|
||||
|
||||
@@ -251,10 +251,10 @@ function CarouselNext({
|
||||
}
|
||||
|
||||
export {
|
||||
type CarouselApi,
|
||||
Carousel,
|
||||
type CarouselApi,
|
||||
CarouselContent,
|
||||
CarouselItem,
|
||||
CarouselPrevious,
|
||||
CarouselNext,
|
||||
CarouselPrevious,
|
||||
}
|
||||
|
||||
@@ -39,4 +39,4 @@ function CollapsibleContent({
|
||||
)
|
||||
}
|
||||
|
||||
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
|
||||
export { Collapsible, CollapsibleContent, CollapsibleTrigger }
|
||||
|
||||
@@ -198,11 +198,11 @@ function CommandShortcut({
|
||||
export {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandShortcut,
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
CommandShortcut,
|
||||
}
|
||||
|
||||
@@ -283,18 +283,18 @@ function DropdownMenuSubContent({
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
}
|
||||
|
||||
@@ -179,12 +179,12 @@ function FormMessage({ className, ...props }: React.ComponentProps<'p'>) {
|
||||
}
|
||||
|
||||
export {
|
||||
useFormField,
|
||||
Form,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormMessage,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
useFormField,
|
||||
}
|
||||
|
||||
@@ -50,4 +50,4 @@ function HoverCardContent({
|
||||
)
|
||||
}
|
||||
|
||||
export { HoverCard, HoverCardTrigger, HoverCardContent }
|
||||
export { HoverCard, HoverCardContent, HoverCardTrigger }
|
||||
|
||||
@@ -184,7 +184,7 @@ export {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupButton,
|
||||
InputGroupText,
|
||||
InputGroupInput,
|
||||
InputGroupText,
|
||||
InputGroupTextarea,
|
||||
}
|
||||
|
||||
@@ -55,4 +55,4 @@ function PopoverAnchor({
|
||||
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
|
||||
}
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
|
||||
export { Popover, PopoverAnchor, PopoverContent, PopoverTrigger }
|
||||
|
||||
@@ -49,4 +49,4 @@ function ResizableHandle({
|
||||
)
|
||||
}
|
||||
|
||||
export { ResizablePanelGroup, ResizablePanel, ResizableHandle }
|
||||
export { ResizableHandle, ResizablePanel, ResizablePanelGroup }
|
||||
|
||||
@@ -129,11 +129,11 @@ function SheetDescription({
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
}
|
||||
|
||||
@@ -86,4 +86,4 @@ function TabsContent({
|
||||
)
|
||||
}
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
|
||||
export { Tabs, TabsContent, TabsList, TabsTrigger, tabsListVariants }
|
||||
|
||||
@@ -68,4 +68,4 @@ function TooltipContent({
|
||||
)
|
||||
}
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }
|
||||
|
||||
@@ -103,10 +103,10 @@ export const ProviderCard: FC<ProviderCardProps> = ({
|
||||
for better performance.
|
||||
</>
|
||||
)
|
||||
) : provider.baseUrl ? (
|
||||
`${provider.modelId} • ${provider.baseUrl}`
|
||||
) : (
|
||||
provider.baseUrl
|
||||
? `${provider.modelId} • ${provider.baseUrl}`
|
||||
: provider.modelId
|
||||
provider.modelId
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ChevronDown, Loader2, Sparkles, Undo2 } from 'lucide-react'
|
||||
import type { FC } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { toast } from 'sonner'
|
||||
import { z } from 'zod/v3'
|
||||
import { ChatProviderSelector } from '@/components/chat/ChatProviderSelector'
|
||||
import type { Provider } from '@/components/chat/chatComponentTypes'
|
||||
@@ -34,16 +35,15 @@ import {
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { SCHEDULED_TASK_PROMPT_REFINED_EVENT } from '@/lib/constants/analyticsEvents'
|
||||
import { BrowserOSIcon, ProviderIcon } from '@/lib/llm-providers/providerIcons'
|
||||
import {
|
||||
defaultProviderIdStorage,
|
||||
providersStorage,
|
||||
} from '@/lib/llm-providers/storage'
|
||||
import type { LlmProviderConfig, ProviderType } from '@/lib/llm-providers/types'
|
||||
import { SCHEDULED_TASK_PROMPT_REFINED_EVENT } from '@/lib/constants/analyticsEvents'
|
||||
import { track } from '@/lib/metrics/track'
|
||||
import { refinePrompt } from '@/lib/schedules/refine-prompt'
|
||||
import { toast } from 'sonner'
|
||||
import type { ScheduledJob } from './types'
|
||||
|
||||
const formSchema = z
|
||||
@@ -291,7 +291,7 @@ export const NewScheduledTaskDialog: FC<NewScheduledTaskDialogProps> = ({
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-auto gap-1 px-2 py-1 text-xs text-muted-foreground"
|
||||
className="h-auto gap-1 px-2 py-1 text-muted-foreground text-xs"
|
||||
disabled={!queryValue?.trim() || isRefining}
|
||||
onClick={handleRefinePrompt}
|
||||
>
|
||||
@@ -322,7 +322,7 @@ export const NewScheduledTaskDialog: FC<NewScheduledTaskDialogProps> = ({
|
||||
{!isRefining && originalPromptRef.current !== null ? (
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
|
||||
className="flex items-center gap-1 text-muted-foreground text-xs hover:text-foreground"
|
||||
onClick={handleUndoRefine}
|
||||
>
|
||||
<Undo2 className="h-3 w-3" />
|
||||
|
||||
@@ -280,7 +280,11 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
|
||||
|
||||
if (voice.isTranscribing) {
|
||||
return (
|
||||
<button type="button" disabled className="rounded-full p-2 text-muted-foreground">
|
||||
<button
|
||||
type="button"
|
||||
disabled
|
||||
className="rounded-full p-2 text-muted-foreground"
|
||||
>
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
<span className="sr-only">Transcribing</span>
|
||||
</button>
|
||||
@@ -317,7 +321,9 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
|
||||
return (
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!input.trim() || voice?.isRecording || voice?.isTranscribing}
|
||||
disabled={
|
||||
!input.trim() || voice?.isRecording || voice?.isTranscribing
|
||||
}
|
||||
className="cursor-pointer rounded-full bg-[var(--accent-orange)] p-2 text-white shadow-sm transition-all duration-200 hover:bg-[var(--accent-orange-bright)] disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<Send className="h-3.5 w-3.5" />
|
||||
@@ -341,12 +347,10 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
|
||||
anchorRef={textareaRef}
|
||||
/>
|
||||
{voice?.isRecording ? (
|
||||
<div
|
||||
className="flex min-h-[42px] flex-1 items-center justify-center gap-1 rounded-2xl border border-red-500/50 bg-muted/50 px-4 py-2.5 pr-[4.5rem]"
|
||||
>
|
||||
<div className="flex min-h-[42px] flex-1 items-center justify-center gap-1 rounded-2xl border border-red-500/50 bg-muted/50 px-4 py-2.5 pr-[4.5rem]">
|
||||
{voice.audioLevels.map((level, i) => (
|
||||
<div
|
||||
key={i}
|
||||
key={i.toString()}
|
||||
className="w-1 rounded-full bg-red-500 transition-all duration-75"
|
||||
style={{
|
||||
height: `${Math.max(4, Math.min(20, level * 0.6))}px`,
|
||||
|
||||
@@ -74,6 +74,7 @@ export function useOAuthStatus(provider: string): UseOAuthStatusReturn {
|
||||
}, [])
|
||||
|
||||
// Cleanup on unmount
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: cleanup only needs to run on unmount
|
||||
useEffect(() => {
|
||||
return () => stopPolling()
|
||||
}, [])
|
||||
|
||||
@@ -23,4 +23,4 @@ type ScheduleMessagesProtocol = {
|
||||
const { sendMessage, onMessage } =
|
||||
defineExtensionMessaging<ScheduleMessagesProtocol>()
|
||||
|
||||
export { sendMessage as sendScheduleMessage, onMessage as onScheduleMessage }
|
||||
export { onMessage as onScheduleMessage, sendMessage as sendScheduleMessage }
|
||||
|
||||
@@ -12,4 +12,4 @@ type ServerMessagesProtocol = {
|
||||
const { sendMessage, onMessage } =
|
||||
defineExtensionMessaging<ServerMessagesProtocol>()
|
||||
|
||||
export { sendMessage as sendServerMessage, onMessage as onServerMessage }
|
||||
export { onMessage as onServerMessage, sendMessage as sendServerMessage }
|
||||
|
||||
@@ -12,6 +12,6 @@ const { sendMessage, onMessage } =
|
||||
defineExtensionMessaging<OpenSidePanelWithSearchParams>()
|
||||
|
||||
export {
|
||||
sendMessage as openSidePanelWithSearch,
|
||||
onMessage as onOpenSidePanelWithSearch,
|
||||
sendMessage as openSidePanelWithSearch,
|
||||
}
|
||||
|
||||
@@ -45,7 +45,9 @@ async function transcribeAudio(audioBlob: Blob): Promise<string> {
|
||||
const errorBody: { error?: string } = await response
|
||||
.json()
|
||||
.catch(() => ({ error: 'Transcription failed' }))
|
||||
throw new Error(errorBody.error || `Transcription failed: ${response.status}`)
|
||||
throw new Error(
|
||||
errorBody.error || `Transcription failed: ${response.status}`,
|
||||
)
|
||||
}
|
||||
|
||||
const result: TranscribeResponse = await response.json()
|
||||
@@ -81,6 +83,7 @@ export function useVoiceInput(): UseVoiceInputReturn {
|
||||
setAudioLevels(EMPTY_LEVELS)
|
||||
}
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: cleanup only needs to run on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
streamRef.current?.getTracks().forEach((track) => {
|
||||
@@ -171,7 +174,9 @@ export function useVoiceInput(): UseVoiceInputReturn {
|
||||
setIsRecording(true)
|
||||
return true
|
||||
} catch (err) {
|
||||
streamRef.current?.getTracks().forEach((track) => track.stop())
|
||||
streamRef.current?.getTracks().forEach((track) => {
|
||||
track.stop()
|
||||
})
|
||||
streamRef.current = null
|
||||
stopAudioLevelMonitoring()
|
||||
|
||||
|
||||
@@ -9,9 +9,9 @@
|
||||
"build": "bun run codegen && wxt build",
|
||||
"build:dev": "bun --env-file=.env.development wxt build --mode development",
|
||||
"zip": "wxt zip",
|
||||
"compile": "tsc --noEmit",
|
||||
"compile": "tsgo --noEmit",
|
||||
"lint": "bunx biome check",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"typecheck": "tsgo --noEmit",
|
||||
"lint:fix": "bunx biome check --write --unsafe",
|
||||
"clean:cache": "rm -rf node_modules/.cache && rm -rf .output/ && rm -rf .wxt/",
|
||||
"codegen": "bun --env-file=.env.development graphql-codegen --config codegen.ts",
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
"types": ["chrome", "bun"],
|
||||
"allowImportingTsExtensions": true,
|
||||
"jsx": "react-jsx",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
},
|
||||
|
||||
@@ -90,7 +90,8 @@ export class SingleAgentEvaluator implements AgentEvaluator {
|
||||
timeoutMs,
|
||||
capture,
|
||||
async (signal) => {
|
||||
const result = await agent!.toolLoopAgent.generate({
|
||||
if (!agent) throw new Error('Agent was not initialized')
|
||||
const result = await agent.toolLoopAgent.generate({
|
||||
prompt: task.query,
|
||||
abortSignal: signal,
|
||||
|
||||
|
||||
@@ -63,9 +63,9 @@ export class ScreenshotCapture {
|
||||
}
|
||||
|
||||
private async captureDirect(pageId: number): Promise<string | null> {
|
||||
if (!this.browser) return null
|
||||
try {
|
||||
// browser is guaranteed non-null here — captureDirect is only called when this.browser is truthy
|
||||
const result = await this.browser!.screenshot(pageId, {
|
||||
const result = await this.browser.screenshot(pageId, {
|
||||
format: 'png',
|
||||
fullPage: false,
|
||||
})
|
||||
@@ -74,9 +74,9 @@ export class ScreenshotCapture {
|
||||
} catch (error) {
|
||||
// If page ID is invalid, try listing pages and use the first one
|
||||
try {
|
||||
const pages = await this.browser!.listPages()
|
||||
const pages = await this.browser.listPages()
|
||||
if (pages.length > 0) {
|
||||
const result = await this.browser!.screenshot(pages[0].pageId, {
|
||||
const result = await this.browser.screenshot(pages[0].pageId, {
|
||||
format: 'png',
|
||||
fullPage: false,
|
||||
})
|
||||
|
||||
@@ -4,8 +4,8 @@ import type {
|
||||
LanguageModelV3Middleware,
|
||||
} from '@ai-sdk/provider'
|
||||
import { AGENT_LIMITS } from '@browseros/shared/constants/limits'
|
||||
import { LLM_PROVIDERS } from '@browseros/shared/schemas/llm'
|
||||
import type { BrowserContext } from '@browseros/shared/schemas/browser-context'
|
||||
import { LLM_PROVIDERS } from '@browseros/shared/schemas/llm'
|
||||
import {
|
||||
type LanguageModel,
|
||||
type ModelMessage,
|
||||
@@ -204,10 +204,8 @@ export class AiSdkAgent {
|
||||
providerOptions: {
|
||||
openai: {
|
||||
store: false,
|
||||
reasoningEffort:
|
||||
config.resolvedConfig.reasoningEffort || 'high',
|
||||
reasoningSummary:
|
||||
config.resolvedConfig.reasoningSummary || 'auto',
|
||||
reasoningEffort: config.resolvedConfig.reasoningEffort || 'high',
|
||||
reasoningSummary: config.resolvedConfig.reasoningSummary || 'auto',
|
||||
include: ['reasoning.encrypted_content'],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -388,7 +388,7 @@ function getMemoryAndIdentity(
|
||||
section += `
|
||||
|
||||
### Your Personality (SOUL.md)
|
||||
${options?.soulContent ? options.soulContent + '\n' : ''}SOUL.md defines **how you behave** — your personality, tone, communication style, rules, and boundaries. Update it with \`soul_update\` when you learn how the user wants you to act. Use \`soul_read\` to read the current SOUL.md before updating.
|
||||
${options?.soulContent ? `${options.soulContent}\n` : ''}SOUL.md defines **how you behave** — your personality, tone, communication style, rules, and boundaries. Update it with \`soul_update\` when you learn how the user wants you to act. Use \`soul_read\` to read the current SOUL.md before updating.
|
||||
**SOUL.md is NOT for storing facts about the user.** User facts belong in core memory via \`memory_save_core\`.`
|
||||
|
||||
// Soul bootstrap
|
||||
|
||||
@@ -21,12 +21,12 @@ import type { ToolRegistry } from '../tools/tool-registry'
|
||||
|
||||
// Re-export browser context types for consumers
|
||||
export {
|
||||
BrowserContextSchema,
|
||||
CustomMcpServerSchema,
|
||||
TabSchema,
|
||||
type BrowserContext,
|
||||
BrowserContextSchema,
|
||||
type CustomMcpServer,
|
||||
CustomMcpServerSchema,
|
||||
type Tab,
|
||||
TabSchema,
|
||||
}
|
||||
|
||||
export const AgentLLMConfigSchema = LLMConfigSchema.extend({
|
||||
|
||||
@@ -823,14 +823,7 @@ export class Browser {
|
||||
if (coords) {
|
||||
const value = await elements.getInputValue(session, element)
|
||||
if (value) {
|
||||
await mouse.dispatchClick(
|
||||
session,
|
||||
coords.x,
|
||||
coords.y,
|
||||
'left',
|
||||
3,
|
||||
0,
|
||||
)
|
||||
await mouse.dispatchClick(session, coords.x, coords.y, 'left', 3, 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,12 +40,16 @@ async function resolveChatGPTProConfig(
|
||||
): Promise<ResolvedLLMConfig> {
|
||||
const tokenManager = getOAuthTokenManager()
|
||||
if (!tokenManager || !browserosId) {
|
||||
throw new Error('Not authenticated with ChatGPT Plus/Pro. Please login first.')
|
||||
throw new Error(
|
||||
'Not authenticated with ChatGPT Plus/Pro. Please login first.',
|
||||
)
|
||||
}
|
||||
|
||||
const tokens = await tokenManager.refreshIfExpired('chatgpt-pro')
|
||||
if (!tokens) {
|
||||
throw new Error('Not authenticated with ChatGPT Plus/Pro. Please login first.')
|
||||
throw new Error(
|
||||
'Not authenticated with ChatGPT Plus/Pro. Please login first.',
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -16,8 +16,8 @@ import { LLM_PROVIDERS } from '@browseros/shared/schemas/llm'
|
||||
import { createOpenRouter } from '@openrouter/ai-sdk-provider'
|
||||
import type { LanguageModel } from 'ai'
|
||||
import { logger } from '../../logger'
|
||||
import { createCodexFetch } from '../oauth/codex-fetch'
|
||||
import { createOpenRouterCompatibleFetch } from '../../openrouter-fetch'
|
||||
import { createCodexFetch } from '../oauth/codex-fetch'
|
||||
import type { ResolvedLLMConfig } from './types'
|
||||
|
||||
type ProviderFactory = (config: ResolvedLLMConfig) => LanguageModel
|
||||
|
||||
@@ -12,9 +12,9 @@ import { OAUTH_CALLBACK_PORT } from '@browseros/shared/constants/ports'
|
||||
import { logger } from '../../logger'
|
||||
import type { OAuthTokenManager } from './token-manager'
|
||||
|
||||
export function startOAuthCallbackServer(
|
||||
tokenManager: OAuthTokenManager,
|
||||
): { stop: () => void } {
|
||||
export function startOAuthCallbackServer(tokenManager: OAuthTokenManager): {
|
||||
stop: () => void
|
||||
} {
|
||||
const server = Bun.serve({
|
||||
port: OAUTH_CALLBACK_PORT,
|
||||
hostname: '127.0.0.1',
|
||||
@@ -29,8 +29,7 @@ export function startOAuthCallbackServer(
|
||||
const error = url.searchParams.get('error')
|
||||
|
||||
if (error) {
|
||||
const description =
|
||||
url.searchParams.get('error_description') || error
|
||||
const description = url.searchParams.get('error_description') || error
|
||||
logger.warn('OAuth callback received error', { error, description })
|
||||
return htmlResponse(errorPage(description))
|
||||
}
|
||||
|
||||
@@ -60,9 +60,12 @@ export function createCodexFetch(accountId?: string) {
|
||||
}
|
||||
body = JSON.stringify(json)
|
||||
} catch (err) {
|
||||
logger.warn('Failed to inject Codex-required fields into request body', {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
logger.warn(
|
||||
'Failed to inject Codex-required fields into request body',
|
||||
{
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -27,7 +27,10 @@ interface OAuthTokenResponse {
|
||||
|
||||
export class OAuthTokenManager {
|
||||
private readonly pendingFlows = new Map<string, PendingOAuthFlow>()
|
||||
private readonly refreshLocks = new Map<string, Promise<StoredOAuthTokens | null>>()
|
||||
private readonly refreshLocks = new Map<
|
||||
string,
|
||||
Promise<StoredOAuthTokens | null>
|
||||
>()
|
||||
|
||||
constructor(
|
||||
private readonly store: OAuthTokenStore,
|
||||
@@ -107,9 +110,12 @@ export class OAuthTokenManager {
|
||||
|
||||
const data = (await tokenResponse.json()) as OAuthTokenResponse
|
||||
if (!data.refresh_token) {
|
||||
logger.warn('OAuth token response missing refresh_token — token refresh will not be available', {
|
||||
provider: flow.provider,
|
||||
})
|
||||
logger.warn(
|
||||
'OAuth token response missing refresh_token — token refresh will not be available',
|
||||
{
|
||||
provider: flow.provider,
|
||||
},
|
||||
)
|
||||
}
|
||||
const { accountId, email } = parseAccessTokenClaims(data.access_token)
|
||||
|
||||
@@ -161,7 +167,9 @@ export class OAuthTokenManager {
|
||||
): Promise<StoredOAuthTokens> {
|
||||
if (!tokens.refreshToken) {
|
||||
this.store.deleteTokens(this.browserosId, provider)
|
||||
throw new Error(`${provider} session expired (no refresh token). Please re-login.`)
|
||||
throw new Error(
|
||||
`${provider} session expired (no refresh token). Please re-login.`,
|
||||
)
|
||||
}
|
||||
|
||||
const providerConfig = getOAuthProvider(provider)
|
||||
@@ -269,9 +277,7 @@ function parseAccessTokenClaims(accessToken: string): {
|
||||
authClaims?.chatgpt_account_id ??
|
||||
payload.chatgpt_account_id ??
|
||||
payload.account_id,
|
||||
email:
|
||||
profileClaims?.email ??
|
||||
payload.email,
|
||||
email: profileClaims?.email ?? payload.email,
|
||||
}
|
||||
} catch {
|
||||
return {}
|
||||
|
||||
@@ -29,7 +29,11 @@ import { RateLimiter } from './lib/rate-limiter/rate-limiter'
|
||||
import { Sentry } from './lib/sentry'
|
||||
import { seedSoulTemplate } from './lib/soul'
|
||||
import { migrateBuiltinSkills } from './skills/migrate'
|
||||
import { startSkillSync, stopSkillSync, syncBuiltinSkills } from './skills/remote-sync'
|
||||
import {
|
||||
startSkillSync,
|
||||
stopSkillSync,
|
||||
syncBuiltinSkills,
|
||||
} from './skills/remote-sync'
|
||||
import { registry } from './tools/registry'
|
||||
import { VERSION } from './version'
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { readdir, readFile, stat } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import matter from 'gray-matter'
|
||||
import { PATHS } from '@browseros/shared/constants/paths'
|
||||
import matter from 'gray-matter'
|
||||
import { getBuiltinSkillsDir, getSkillsDir } from '../lib/browseros-dir'
|
||||
import { logger } from '../lib/logger'
|
||||
import type { SkillFrontmatter, SkillMeta } from './types'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mkdir, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises'
|
||||
import { mkdir, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { TIMEOUTS } from '@browseros/shared/constants/timeouts'
|
||||
import { EXTERNAL_URLS } from '@browseros/shared/constants/urls'
|
||||
@@ -55,7 +55,9 @@ export async function fetchRemoteCatalog(): Promise<RemoteSkillCatalog | null> {
|
||||
signal: AbortSignal.timeout(TIMEOUTS.SKILLS_FETCH),
|
||||
})
|
||||
if (!response.ok) {
|
||||
logger.warn('Failed to fetch remote skill catalog', { status: response.status })
|
||||
logger.warn('Failed to fetch remote skill catalog', {
|
||||
status: response.status,
|
||||
})
|
||||
return null
|
||||
}
|
||||
const data: unknown = await response.json()
|
||||
@@ -77,11 +79,17 @@ export async function syncBuiltinSkills(): Promise<void> {
|
||||
|
||||
const contentMap = new Map<string, { version: string; content: string }>()
|
||||
for (const skill of DEFAULT_SKILLS) {
|
||||
contentMap.set(skill.id, { version: extractVersion(skill.content), content: skill.content })
|
||||
contentMap.set(skill.id, {
|
||||
version: extractVersion(skill.content),
|
||||
content: skill.content,
|
||||
})
|
||||
}
|
||||
if (catalog) {
|
||||
for (const skill of catalog.skills) {
|
||||
contentMap.set(skill.id, { version: skill.version, content: skill.content })
|
||||
contentMap.set(skill.id, {
|
||||
version: skill.version,
|
||||
content: skill.content,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -50,4 +50,3 @@ export type RemoteSkillCatalog = {
|
||||
version: number
|
||||
skills: RemoteSkillEntry[]
|
||||
}
|
||||
|
||||
|
||||
@@ -50,7 +50,8 @@ describe('Flow tests against live CDN', () => {
|
||||
|
||||
const afterSync = await readFile(skillPath, 'utf-8')
|
||||
assert.ok(
|
||||
afterSync.includes('enabled: "false"') || afterSync.includes("enabled: 'false'"),
|
||||
afterSync.includes('enabled: "false"') ||
|
||||
afterSync.includes("enabled: 'false'"),
|
||||
'disabled state should be preserved',
|
||||
)
|
||||
})
|
||||
@@ -58,7 +59,10 @@ describe('Flow tests against live CDN', () => {
|
||||
it('reinstalls deleted builtin skill', async () => {
|
||||
await rm(join(builtinDir, 'save-page'), { recursive: true })
|
||||
await syncBuiltinSkills()
|
||||
const content = await readFile(join(builtinDir, 'save-page', 'SKILL.md'), 'utf-8')
|
||||
const content = await readFile(
|
||||
join(builtinDir, 'save-page', 'SKILL.md'),
|
||||
'utf-8',
|
||||
)
|
||||
assert.ok(content.includes('name: save-page'))
|
||||
})
|
||||
|
||||
|
||||
@@ -62,7 +62,10 @@ metadata:
|
||||
describe('loader two-directory scanning', () => {
|
||||
it('marks builtin/ skills as builtIn: true', async () => {
|
||||
await mkdir(join(builtinDir, 'summarize-page'), { recursive: true })
|
||||
await writeFile(join(builtinDir, 'summarize-page', 'SKILL.md'), BUILTIN_SKILL)
|
||||
await writeFile(
|
||||
join(builtinDir, 'summarize-page', 'SKILL.md'),
|
||||
BUILTIN_SKILL,
|
||||
)
|
||||
|
||||
const skills = await loadAllSkills()
|
||||
const skill = skills.find((s) => s.id === 'summarize-page')
|
||||
@@ -82,7 +85,10 @@ describe('loader two-directory scanning', () => {
|
||||
|
||||
it('merges skills from both directories', async () => {
|
||||
await mkdir(join(builtinDir, 'summarize-page'), { recursive: true })
|
||||
await writeFile(join(builtinDir, 'summarize-page', 'SKILL.md'), BUILTIN_SKILL)
|
||||
await writeFile(
|
||||
join(builtinDir, 'summarize-page', 'SKILL.md'),
|
||||
BUILTIN_SKILL,
|
||||
)
|
||||
await mkdir(join(testDir, 'my-workflow'), { recursive: true })
|
||||
await writeFile(join(testDir, 'my-workflow', 'SKILL.md'), USER_SKILL)
|
||||
|
||||
@@ -92,7 +98,10 @@ describe('loader two-directory scanning', () => {
|
||||
|
||||
it('skips builtin/ subdirectory when scanning root', async () => {
|
||||
await mkdir(join(builtinDir, 'summarize-page'), { recursive: true })
|
||||
await writeFile(join(builtinDir, 'summarize-page', 'SKILL.md'), BUILTIN_SKILL)
|
||||
await writeFile(
|
||||
join(builtinDir, 'summarize-page', 'SKILL.md'),
|
||||
BUILTIN_SKILL,
|
||||
)
|
||||
|
||||
const skills = await loadAllSkills()
|
||||
const dupes = skills.filter((s) => s.id === 'summarize-page')
|
||||
@@ -102,9 +111,15 @@ describe('loader two-directory scanning', () => {
|
||||
|
||||
it('loadSkills filters out disabled skills', async () => {
|
||||
await mkdir(join(builtinDir, 'summarize-page'), { recursive: true })
|
||||
await writeFile(join(builtinDir, 'summarize-page', 'SKILL.md'), BUILTIN_SKILL)
|
||||
await writeFile(
|
||||
join(builtinDir, 'summarize-page', 'SKILL.md'),
|
||||
BUILTIN_SKILL,
|
||||
)
|
||||
await mkdir(join(builtinDir, 'deep-research'), { recursive: true })
|
||||
await writeFile(join(builtinDir, 'deep-research', 'SKILL.md'), BUILTIN_DISABLED)
|
||||
await writeFile(
|
||||
join(builtinDir, 'deep-research', 'SKILL.md'),
|
||||
BUILTIN_DISABLED,
|
||||
)
|
||||
|
||||
const skills = await loadSkills()
|
||||
assert.strictEqual(skills.length, 1)
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import { afterEach, beforeEach, describe, it, mock } from 'bun:test'
|
||||
import assert from 'node:assert'
|
||||
import { mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises'
|
||||
import {
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readdir,
|
||||
readFile,
|
||||
rm,
|
||||
stat,
|
||||
writeFile,
|
||||
} from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
@@ -43,39 +51,56 @@ describe('migrateBuiltinSkills', () => {
|
||||
|
||||
await migrateBuiltinSkills()
|
||||
|
||||
const content = await readFile(join(builtinDir, 'summarize-page', 'SKILL.md'), 'utf-8')
|
||||
const content = await readFile(
|
||||
join(builtinDir, 'summarize-page', 'SKILL.md'),
|
||||
'utf-8',
|
||||
)
|
||||
assert.strictEqual(content, SKILL_CONTENT)
|
||||
|
||||
const oldExists = await stat(join(testDir, 'summarize-page')).then(() => true).catch(() => false)
|
||||
const oldExists = await stat(join(testDir, 'summarize-page'))
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
assert.strictEqual(oldExists, false)
|
||||
})
|
||||
|
||||
it('does not move user-created skills', async () => {
|
||||
const userContent = '---\nname: my-workflow\ndescription: mine\n---\n# Mine\n'
|
||||
const userContent =
|
||||
'---\nname: my-workflow\ndescription: mine\n---\n# Mine\n'
|
||||
await mkdir(join(testDir, 'my-workflow'), { recursive: true })
|
||||
await writeFile(join(testDir, 'my-workflow', 'SKILL.md'), userContent)
|
||||
|
||||
await migrateBuiltinSkills()
|
||||
|
||||
const content = await readFile(join(testDir, 'my-workflow', 'SKILL.md'), 'utf-8')
|
||||
const content = await readFile(
|
||||
join(testDir, 'my-workflow', 'SKILL.md'),
|
||||
'utf-8',
|
||||
)
|
||||
assert.strictEqual(content, userContent)
|
||||
})
|
||||
|
||||
it('skips if builtin/ already has skills', async () => {
|
||||
await mkdir(join(builtinDir, 'summarize-page'), { recursive: true })
|
||||
await writeFile(join(builtinDir, 'summarize-page', 'SKILL.md'), SKILL_CONTENT)
|
||||
await writeFile(
|
||||
join(builtinDir, 'summarize-page', 'SKILL.md'),
|
||||
SKILL_CONTENT,
|
||||
)
|
||||
await mkdir(join(testDir, 'deep-research'), { recursive: true })
|
||||
await writeFile(join(testDir, 'deep-research', 'SKILL.md'), SKILL_CONTENT)
|
||||
|
||||
await migrateBuiltinSkills()
|
||||
|
||||
const stillInRoot = await stat(join(testDir, 'deep-research')).then(() => true).catch(() => false)
|
||||
const stillInRoot = await stat(join(testDir, 'deep-research'))
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
assert.strictEqual(stillInRoot, true)
|
||||
})
|
||||
|
||||
it('is a no-op for fresh installs', async () => {
|
||||
await migrateBuiltinSkills()
|
||||
const entries = await readdir(builtinDir)
|
||||
assert.strictEqual(entries.filter((e: string) => !e.startsWith('.')).length, 0)
|
||||
assert.strictEqual(
|
||||
entries.filter((e: string) => !e.startsWith('.')).length,
|
||||
0,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import { afterEach, beforeEach, describe, it, mock, spyOn } from 'bun:test'
|
||||
import assert from 'node:assert'
|
||||
import { mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises'
|
||||
import {
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readdir,
|
||||
readFile,
|
||||
rm,
|
||||
stat,
|
||||
writeFile,
|
||||
} from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import type { RemoteSkillCatalog } from '../../src/skills/types'
|
||||
@@ -13,8 +21,9 @@ mock.module('../../src/lib/browseros-dir', () => ({
|
||||
getBuiltinSkillsDir: () => builtinDir,
|
||||
}))
|
||||
|
||||
const { fetchRemoteCatalog, syncBuiltinSkills } =
|
||||
await import('../../src/skills/remote-sync')
|
||||
const { fetchRemoteCatalog, syncBuiltinSkills } = await import(
|
||||
'../../src/skills/remote-sync'
|
||||
)
|
||||
|
||||
function makeCatalog(
|
||||
skills: { id: string; version: string; content: string }[],
|
||||
@@ -63,7 +72,9 @@ afterEach(async () => {
|
||||
|
||||
describe('fetchRemoteCatalog', () => {
|
||||
it('returns null on network failure', async () => {
|
||||
const spy = spyOn(globalThis, 'fetch').mockRejectedValue(new Error('offline'))
|
||||
const spy = spyOn(globalThis, 'fetch').mockRejectedValue(
|
||||
new Error('offline'),
|
||||
)
|
||||
assert.strictEqual(await fetchRemoteCatalog(), null)
|
||||
spy.mockRestore()
|
||||
})
|
||||
@@ -77,7 +88,9 @@ describe('fetchRemoteCatalog', () => {
|
||||
})
|
||||
|
||||
it('returns catalog on success', async () => {
|
||||
const catalog = makeCatalog([{ id: 'test', version: '1.0', content: 'hello' }])
|
||||
const catalog = makeCatalog([
|
||||
{ id: 'test', version: '1.0', content: 'hello' },
|
||||
])
|
||||
const spy = spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response(JSON.stringify(catalog), { status: 200 }),
|
||||
)
|
||||
@@ -89,12 +102,18 @@ describe('fetchRemoteCatalog', () => {
|
||||
describe('syncBuiltinSkills', () => {
|
||||
it('installs from remote into builtin/', async () => {
|
||||
const spy = spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response(JSON.stringify(makeCatalog([
|
||||
{ id: 'new-skill', version: '1.0', content: SKILL_V1 },
|
||||
])), { status: 200 }),
|
||||
new Response(
|
||||
JSON.stringify(
|
||||
makeCatalog([{ id: 'new-skill', version: '1.0', content: SKILL_V1 }]),
|
||||
),
|
||||
{ status: 200 },
|
||||
),
|
||||
)
|
||||
await syncBuiltinSkills()
|
||||
const content = await readFile(join(builtinDir, 'new-skill', 'SKILL.md'), 'utf-8')
|
||||
const content = await readFile(
|
||||
join(builtinDir, 'new-skill', 'SKILL.md'),
|
||||
'utf-8',
|
||||
)
|
||||
assert.strictEqual(content, SKILL_V1)
|
||||
spy.mockRestore()
|
||||
})
|
||||
@@ -104,12 +123,20 @@ describe('syncBuiltinSkills', () => {
|
||||
await writeFile(join(builtinDir, 'test-skill', 'SKILL.md'), SKILL_V1)
|
||||
|
||||
const spy = spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response(JSON.stringify(makeCatalog([
|
||||
{ id: 'test-skill', version: '2.0', content: SKILL_V2 },
|
||||
])), { status: 200 }),
|
||||
new Response(
|
||||
JSON.stringify(
|
||||
makeCatalog([
|
||||
{ id: 'test-skill', version: '2.0', content: SKILL_V2 },
|
||||
]),
|
||||
),
|
||||
{ status: 200 },
|
||||
),
|
||||
)
|
||||
await syncBuiltinSkills()
|
||||
const content = await readFile(join(builtinDir, 'test-skill', 'SKILL.md'), 'utf-8')
|
||||
const content = await readFile(
|
||||
join(builtinDir, 'test-skill', 'SKILL.md'),
|
||||
'utf-8',
|
||||
)
|
||||
assert.strictEqual(content, SKILL_V2)
|
||||
spy.mockRestore()
|
||||
})
|
||||
@@ -119,12 +146,20 @@ describe('syncBuiltinSkills', () => {
|
||||
await writeFile(join(builtinDir, 'test-skill', 'SKILL.md'), SKILL_V1)
|
||||
|
||||
const spy = spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response(JSON.stringify(makeCatalog([
|
||||
{ id: 'test-skill', version: '1.0', content: SKILL_V1 },
|
||||
])), { status: 200 }),
|
||||
new Response(
|
||||
JSON.stringify(
|
||||
makeCatalog([
|
||||
{ id: 'test-skill', version: '1.0', content: SKILL_V1 },
|
||||
]),
|
||||
),
|
||||
{ status: 200 },
|
||||
),
|
||||
)
|
||||
await syncBuiltinSkills()
|
||||
const content = await readFile(join(builtinDir, 'test-skill', 'SKILL.md'), 'utf-8')
|
||||
const content = await readFile(
|
||||
join(builtinDir, 'test-skill', 'SKILL.md'),
|
||||
'utf-8',
|
||||
)
|
||||
assert.strictEqual(content, SKILL_V1)
|
||||
spy.mockRestore()
|
||||
})
|
||||
@@ -135,22 +170,33 @@ describe('syncBuiltinSkills', () => {
|
||||
await writeFile(join(builtinDir, 'test-skill', 'SKILL.md'), disabledV1)
|
||||
|
||||
const spy = spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response(JSON.stringify(makeCatalog([
|
||||
{ id: 'test-skill', version: '2.0', content: SKILL_V2 },
|
||||
])), { status: 200 }),
|
||||
new Response(
|
||||
JSON.stringify(
|
||||
makeCatalog([
|
||||
{ id: 'test-skill', version: '2.0', content: SKILL_V2 },
|
||||
]),
|
||||
),
|
||||
{ status: 200 },
|
||||
),
|
||||
)
|
||||
await syncBuiltinSkills()
|
||||
const content = await readFile(join(builtinDir, 'test-skill', 'SKILL.md'), 'utf-8')
|
||||
const content = await readFile(
|
||||
join(builtinDir, 'test-skill', 'SKILL.md'),
|
||||
'utf-8',
|
||||
)
|
||||
assert.ok(content.includes('v2'), 'should have v2 content')
|
||||
assert.ok(
|
||||
content.includes('enabled: "false"') || content.includes("enabled: 'false'"),
|
||||
content.includes('enabled: "false"') ||
|
||||
content.includes("enabled: 'false'"),
|
||||
'should preserve disabled state',
|
||||
)
|
||||
spy.mockRestore()
|
||||
})
|
||||
|
||||
it('falls back to bundled defaults when offline', async () => {
|
||||
const spy = spyOn(globalThis, 'fetch').mockRejectedValue(new Error('offline'))
|
||||
const spy = spyOn(globalThis, 'fetch').mockRejectedValue(
|
||||
new Error('offline'),
|
||||
)
|
||||
await syncBuiltinSkills()
|
||||
const entries = await readdir(builtinDir)
|
||||
const skills = entries.filter((e: string) => !e.startsWith('.'))
|
||||
@@ -163,12 +209,19 @@ describe('syncBuiltinSkills', () => {
|
||||
await writeFile(join(builtinDir, 'old-skill', 'SKILL.md'), SKILL_V1)
|
||||
|
||||
const spy = spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response(JSON.stringify(makeCatalog([
|
||||
{ id: 'other-skill', version: '1.0', content: SKILL_V2 },
|
||||
])), { status: 200 }),
|
||||
new Response(
|
||||
JSON.stringify(
|
||||
makeCatalog([
|
||||
{ id: 'other-skill', version: '1.0', content: SKILL_V2 },
|
||||
]),
|
||||
),
|
||||
{ status: 200 },
|
||||
),
|
||||
)
|
||||
await syncBuiltinSkills()
|
||||
const exists = await stat(join(builtinDir, 'old-skill')).then(() => true).catch(() => false)
|
||||
const exists = await stat(join(builtinDir, 'old-skill'))
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
assert.strictEqual(exists, false)
|
||||
spy.mockRestore()
|
||||
})
|
||||
@@ -179,24 +232,39 @@ describe('syncBuiltinSkills', () => {
|
||||
await writeFile(join(testDir, 'my-custom', 'SKILL.md'), custom)
|
||||
|
||||
const spy = spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response(JSON.stringify(makeCatalog([
|
||||
{ id: 'test-skill', version: '1.0', content: SKILL_V1 },
|
||||
])), { status: 200 }),
|
||||
new Response(
|
||||
JSON.stringify(
|
||||
makeCatalog([
|
||||
{ id: 'test-skill', version: '1.0', content: SKILL_V1 },
|
||||
]),
|
||||
),
|
||||
{ status: 200 },
|
||||
),
|
||||
)
|
||||
await syncBuiltinSkills()
|
||||
const content = await readFile(join(testDir, 'my-custom', 'SKILL.md'), 'utf-8')
|
||||
const content = await readFile(
|
||||
join(testDir, 'my-custom', 'SKILL.md'),
|
||||
'utf-8',
|
||||
)
|
||||
assert.strictEqual(content, custom)
|
||||
spy.mockRestore()
|
||||
})
|
||||
|
||||
it('rejects path traversal in skill ids', async () => {
|
||||
const spy = spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response(JSON.stringify(makeCatalog([
|
||||
{ id: '../../etc/evil', version: '1.0', content: SKILL_V1 },
|
||||
])), { status: 200 }),
|
||||
new Response(
|
||||
JSON.stringify(
|
||||
makeCatalog([
|
||||
{ id: '../../etc/evil', version: '1.0', content: SKILL_V1 },
|
||||
]),
|
||||
),
|
||||
{ status: 200 },
|
||||
),
|
||||
)
|
||||
await syncBuiltinSkills()
|
||||
const exists = await stat(join(builtinDir, '..', '..', 'etc', 'evil')).then(() => true).catch(() => false)
|
||||
const exists = await stat(join(builtinDir, '..', '..', 'etc', 'evil'))
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
assert.strictEqual(exists, false)
|
||||
spy.mockRestore()
|
||||
})
|
||||
|
||||
@@ -22,8 +22,9 @@ mock.module('../../src/lib/browseros-dir', () => ({
|
||||
getBuiltinSkillsDir: () => builtinDir,
|
||||
}))
|
||||
|
||||
const { createSkill, deleteSkill, getSkill, listSkills, updateSkill } =
|
||||
await import('../../src/skills/service')
|
||||
const { createSkill, deleteSkill, getSkill, updateSkill } = await import(
|
||||
'../../src/skills/service'
|
||||
)
|
||||
|
||||
const BUILTIN_SKILL = `---
|
||||
name: summarize-page
|
||||
@@ -40,14 +41,21 @@ metadata:
|
||||
describe('getSkill', () => {
|
||||
it('finds builtin skill with builtIn: true', async () => {
|
||||
await mkdir(join(builtinDir, 'summarize-page'), { recursive: true })
|
||||
await writeFile(join(builtinDir, 'summarize-page', 'SKILL.md'), BUILTIN_SKILL)
|
||||
await writeFile(
|
||||
join(builtinDir, 'summarize-page', 'SKILL.md'),
|
||||
BUILTIN_SKILL,
|
||||
)
|
||||
const skill = await getSkill('summarize-page')
|
||||
assert.ok(skill)
|
||||
assert.strictEqual(skill.builtIn, true)
|
||||
})
|
||||
|
||||
it('finds user skill with builtIn: false', async () => {
|
||||
await createSkill({ name: 'My Skill', description: 'Custom', content: '# Custom' })
|
||||
await createSkill({
|
||||
name: 'My Skill',
|
||||
description: 'Custom',
|
||||
content: '# Custom',
|
||||
})
|
||||
const skill = await getSkill('my-skill')
|
||||
assert.ok(skill)
|
||||
assert.strictEqual(skill.builtIn, false)
|
||||
@@ -56,7 +64,11 @@ describe('getSkill', () => {
|
||||
|
||||
describe('createSkill', () => {
|
||||
it('creates in user directory with builtIn: false', async () => {
|
||||
const skill = await createSkill({ name: 'My Skill', description: 'Custom', content: '# Custom' })
|
||||
const skill = await createSkill({
|
||||
name: 'My Skill',
|
||||
description: 'Custom',
|
||||
content: '# Custom',
|
||||
})
|
||||
assert.strictEqual(skill.builtIn, false)
|
||||
assert.ok(!skill.location.includes('builtin'))
|
||||
})
|
||||
@@ -65,7 +77,12 @@ describe('createSkill', () => {
|
||||
await mkdir(join(builtinDir, 'my-skill'), { recursive: true })
|
||||
await writeFile(join(builtinDir, 'my-skill', 'SKILL.md'), BUILTIN_SKILL)
|
||||
await assert.rejects(
|
||||
() => createSkill({ name: 'My Skill', description: 'Custom', content: '# Custom' }),
|
||||
() =>
|
||||
createSkill({
|
||||
name: 'My Skill',
|
||||
description: 'Custom',
|
||||
content: '# Custom',
|
||||
}),
|
||||
/already exists/,
|
||||
)
|
||||
})
|
||||
@@ -74,7 +91,10 @@ describe('createSkill', () => {
|
||||
describe('updateSkill', () => {
|
||||
it('updates builtin skill in place', async () => {
|
||||
await mkdir(join(builtinDir, 'summarize-page'), { recursive: true })
|
||||
await writeFile(join(builtinDir, 'summarize-page', 'SKILL.md'), BUILTIN_SKILL)
|
||||
await writeFile(
|
||||
join(builtinDir, 'summarize-page', 'SKILL.md'),
|
||||
BUILTIN_SKILL,
|
||||
)
|
||||
const updated = await updateSkill('summarize-page', { enabled: false })
|
||||
assert.strictEqual(updated.enabled, false)
|
||||
assert.strictEqual(updated.builtIn, true)
|
||||
@@ -83,14 +103,24 @@ describe('updateSkill', () => {
|
||||
|
||||
describe('deleteSkill', () => {
|
||||
it('deletes user skill', async () => {
|
||||
await createSkill({ name: 'My Skill', description: 'Custom', content: '# Custom' })
|
||||
await createSkill({
|
||||
name: 'My Skill',
|
||||
description: 'Custom',
|
||||
content: '# Custom',
|
||||
})
|
||||
await deleteSkill('my-skill')
|
||||
assert.strictEqual(await getSkill('my-skill'), null)
|
||||
})
|
||||
|
||||
it('rejects deleting builtin skill', async () => {
|
||||
await mkdir(join(builtinDir, 'summarize-page'), { recursive: true })
|
||||
await writeFile(join(builtinDir, 'summarize-page', 'SKILL.md'), BUILTIN_SKILL)
|
||||
await assert.rejects(() => deleteSkill('summarize-page'), /Cannot delete built-in skill/)
|
||||
await writeFile(
|
||||
join(builtinDir, 'summarize-page', 'SKILL.md'),
|
||||
BUILTIN_SKILL,
|
||||
)
|
||||
await assert.rejects(
|
||||
() => deleteSkill('summarize-page'),
|
||||
/Cannot delete built-in skill/,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"@sentry/cli": "^2.42.2",
|
||||
"@types/bun": "^1.3.5",
|
||||
"@types/node": "^24.3.3",
|
||||
"@typescript/native-preview": "^7.0.0-dev.20260319.1",
|
||||
"commander": "^14.0.1",
|
||||
"dotenv": "^17.2.3",
|
||||
"globals": "^16.4.0",
|
||||
@@ -1914,6 +1915,22 @@
|
||||
|
||||
"@types/yauzl": ["@types/yauzl@2.10.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q=="],
|
||||
|
||||
"@typescript/native-preview": ["@typescript/native-preview@7.0.0-dev.20260319.1", "", { "optionalDependencies": { "@typescript/native-preview-darwin-arm64": "7.0.0-dev.20260319.1", "@typescript/native-preview-darwin-x64": "7.0.0-dev.20260319.1", "@typescript/native-preview-linux-arm": "7.0.0-dev.20260319.1", "@typescript/native-preview-linux-arm64": "7.0.0-dev.20260319.1", "@typescript/native-preview-linux-x64": "7.0.0-dev.20260319.1", "@typescript/native-preview-win32-arm64": "7.0.0-dev.20260319.1", "@typescript/native-preview-win32-x64": "7.0.0-dev.20260319.1" }, "bin": { "tsgo": "bin/tsgo.js" } }, "sha512-K9evb5u4QmH3Xv2XUg9OWUETYMrIX1C7Hls1ce8DW+Nlbb26NnQ5SPQCt8fGq4FGqZ9BodMwfane1pTd+BWYwQ=="],
|
||||
|
||||
"@typescript/native-preview-darwin-arm64": ["@typescript/native-preview-darwin-arm64@7.0.0-dev.20260319.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-CMpCsOVUJ/BLC9x9VaT/DQviTmfHqw/NVwnyAR6ZHVVR1YJynJ6EJBPPHI4oStgTwVSdhna5uTPRiKhUpzvgCA=="],
|
||||
|
||||
"@typescript/native-preview-darwin-x64": ["@typescript/native-preview-darwin-x64@7.0.0-dev.20260319.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-gUcJwLYodGDC7Xf8aRDSMyL1impp4I+sE4ySTnnibTCzI5Mi2g8Sckkr81TEgMx5L4B10gkMmREuqr//1E+Y3A=="],
|
||||
|
||||
"@typescript/native-preview-linux-arm": ["@typescript/native-preview-linux-arm@7.0.0-dev.20260319.1", "", { "os": "linux", "cpu": "arm" }, "sha512-boLhrOyT6nSWaVARBD7AF99rLPMy7jjQB50KywZMFzJ9Gg9N+MBBHJCzgwqlDK2sfahfPWBSV5JjYfET8AOZMg=="],
|
||||
|
||||
"@typescript/native-preview-linux-arm64": ["@typescript/native-preview-linux-arm64@7.0.0-dev.20260319.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-7JmTBE0URYCmLiHXWZ/wlFoNWMu6YJkHGXXqkR3EGTck8+qi0X+dutRsasKYwGHS4I8Ubn/yWTcJHX5qHf68sQ=="],
|
||||
|
||||
"@typescript/native-preview-linux-x64": ["@typescript/native-preview-linux-x64@7.0.0-dev.20260319.1", "", { "os": "linux", "cpu": "x64" }, "sha512-9LdXkBqR3r8MHmHHwoA7mwv/fkATe2TO+6fLuan/sfmGjob/9y7swyYVvauYzX+aiTbzXjjCkOtEw0klQLxT/A=="],
|
||||
|
||||
"@typescript/native-preview-win32-arm64": ["@typescript/native-preview-win32-arm64@7.0.0-dev.20260319.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-jKJ+lha8EZtxP5chtsuSNAZOvPInJbFExu0ctoiGVgw01sdMUZeyFYz9TSLoa5oNUs0dboydtiHTK/uyrKThNw=="],
|
||||
|
||||
"@typescript/native-preview-win32-x64": ["@typescript/native-preview-win32-x64@7.0.0-dev.20260319.1", "", { "os": "win32", "cpu": "x64" }, "sha512-GonXb3TBp23brWlPMA+uL42qUvBTNkCHFoE653tQK5SQkCvCSO3GYyY9K5GhEdG6kV2vDpG+Y98p1BYTM5z0Qg=="],
|
||||
|
||||
"@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="],
|
||||
|
||||
"@use-gesture/core": ["@use-gesture/core@10.3.1", "", {}, "sha512-WcINiDt8WjqBdUXye25anHiNxPc0VOrlT8F6LLkU6cycrOGUDyY/yyFmsg3k8i5OLvv25llc0QC45GhR/C8llw=="],
|
||||
|
||||
@@ -49,6 +49,7 @@
|
||||
"@sentry/cli": "^2.42.2",
|
||||
"@types/bun": "^1.3.5",
|
||||
"@types/node": "^24.3.3",
|
||||
"@typescript/native-preview": "^7.0.0-dev.20260319.1",
|
||||
"commander": "^14.0.1",
|
||||
"dotenv": "^17.2.3",
|
||||
"globals": "^16.4.0",
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { readdir, readFile, stat } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { PutObjectCommand, S3Client } from '@aws-sdk/client-s3'
|
||||
import type { RemoteSkillCatalog, RemoteSkillEntry } from '../apps/server/src/skills/types'
|
||||
import type {
|
||||
RemoteSkillCatalog,
|
||||
RemoteSkillEntry,
|
||||
} from '../apps/server/src/skills/types'
|
||||
|
||||
const DEFAULTS_DIR = join(import.meta.dir, '../apps/server/src/skills/defaults')
|
||||
const R2_KEY = 'skills/v1/catalog.json'
|
||||
|
||||
Reference in New Issue
Block a user