mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-09 22:31:08 +00:00
Merge remote-tracking branch 'origin/main' into hf-provider
This commit is contained in:
commit
cff18931a9
27 changed files with 581 additions and 377 deletions
|
|
@ -21,6 +21,12 @@ const nextConfig: NextConfig = {
|
|||
destination: "https://roocode.com/:path*",
|
||||
permanent: true,
|
||||
},
|
||||
// Redirect cloud waitlist to Notion page
|
||||
{
|
||||
source: "/cloud-waitlist",
|
||||
destination: "https://shard-dogwood-daf.notion.site/238fd1401b0a8087b858e1ad431507cf?pvs=105",
|
||||
permanent: false,
|
||||
},
|
||||
]
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -61,18 +61,11 @@ export function NavBar({ stars, downloads }: NavBarProps) {
|
|||
className="text-muted-foreground transition-transform duration-200 hover:scale-105 hover:text-foreground">
|
||||
Enterprise
|
||||
</Link>
|
||||
<a
|
||||
href={EXTERNAL_LINKS.SECURITY}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-muted-foreground transition-transform duration-200 hover:scale-105 hover:text-foreground">
|
||||
Security
|
||||
</a>
|
||||
<a
|
||||
href={EXTERNAL_LINKS.DOCUMENTATION}
|
||||
target="_blank"
|
||||
className="text-muted-foreground transition-transform duration-200 hover:scale-105 hover:text-foreground">
|
||||
Documentation
|
||||
Docs
|
||||
</a>
|
||||
<a
|
||||
href={EXTERNAL_LINKS.CAREERS}
|
||||
|
|
@ -80,6 +73,19 @@ export function NavBar({ stars, downloads }: NavBarProps) {
|
|||
className="text-muted-foreground transition-transform duration-200 hover:scale-105 hover:text-foreground">
|
||||
Careers
|
||||
</a>
|
||||
<div className="flex items-center rounded-full bg-gradient-to-r from-blue-400 to-cyan-400 p-0.5 text-xs">
|
||||
<div className="rounded-full bg-background px-2 py-1.5">
|
||||
<span className="text-muted-foreground border-r-2 border-foreground/50 pr-1.5">
|
||||
Roo Code Cloud is coming
|
||||
</span>
|
||||
<a
|
||||
href="/cloud-waitlist"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium text-primary hover:underline pl-1.5">
|
||||
Sign up
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div className="hidden md:flex md:items-center md:space-x-4">
|
||||
|
|
@ -119,6 +125,19 @@ export function NavBar({ stars, downloads }: NavBarProps) {
|
|||
<div
|
||||
className={`absolute left-0 right-0 top-16 z-50 transform border-b border-border bg-background shadow-lg backdrop-blur-none transition-all duration-200 md:hidden ${isMenuOpen ? "translate-y-0 opacity-100" : "pointer-events-none -translate-y-2 opacity-0"}`}>
|
||||
<nav className="flex flex-col py-2">
|
||||
<div className="mx-5 mb-2 flex items-center rounded-full bg-gradient-to-r from-blue-400 to-cyan-400 p-0.5 text-xs">
|
||||
<div className="flex-grow text-center rounded-full bg-background px-2 py-1.5">
|
||||
<span className="text-muted-foreground border-r-2 border-foreground/50 pr-3">
|
||||
Roo Code Cloud is coming
|
||||
</span>
|
||||
<a
|
||||
href="/cloud-waitlist"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium text-primary hover:underline pl-3">
|
||||
Sign up
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<ScrollButton
|
||||
targetId="features"
|
||||
className="w-full px-8 py-3 text-left text-sm font-medium text-foreground/80 transition-colors hover:bg-accent hover:text-foreground"
|
||||
|
|
@ -162,7 +181,7 @@ export function NavBar({ stars, downloads }: NavBarProps) {
|
|||
target="_blank"
|
||||
className="w-full px-8 py-3 text-left text-sm font-medium text-foreground/80 transition-colors hover:bg-accent hover:text-foreground"
|
||||
onClick={() => setIsMenuOpen(false)}>
|
||||
Documentation
|
||||
Docs
|
||||
</a>
|
||||
<a
|
||||
href={EXTERNAL_LINKS.CAREERS}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import { useDeepCompareEffect, useEvent, useMount } from "react-use"
|
|||
import debounce from "debounce"
|
||||
import { Virtuoso, type VirtuosoHandle } from "react-virtuoso"
|
||||
import removeMd from "remove-markdown"
|
||||
import { Trans } from "react-i18next"
|
||||
import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
|
||||
import useSound from "use-sound"
|
||||
import { LRUCache } from "lru-cache"
|
||||
|
|
@ -32,12 +31,12 @@ import {
|
|||
parseCommand,
|
||||
} from "@src/utils/command-validation"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { buildDocLink } from "@src/utils/docLinks"
|
||||
import { useAppTranslation } from "@src/i18n/TranslationContext"
|
||||
import { useExtensionState } from "@src/context/ExtensionStateContext"
|
||||
import { useSelectedModel } from "@src/components/ui/hooks/useSelectedModel"
|
||||
import RooHero from "@src/components/welcome/RooHero"
|
||||
import RooTips from "@src/components/welcome/RooTips"
|
||||
import RooCloudCTA from "@src/components/welcome/RooCloudCTA"
|
||||
import { StandardTooltip } from "@src/components/ui"
|
||||
import { useAutoApprovalState } from "@src/hooks/useAutoApprovalState"
|
||||
import { useAutoApprovalToggles } from "@src/hooks/useAutoApprovalToggles"
|
||||
|
|
@ -115,6 +114,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
|
|||
historyPreviewCollapsed, // Added historyPreviewCollapsed
|
||||
soundEnabled,
|
||||
soundVolume,
|
||||
cloudIsAuthenticated,
|
||||
} = useExtensionState()
|
||||
|
||||
const messagesRef = useRef(messages)
|
||||
|
|
@ -1696,20 +1696,9 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
|
|||
|
||||
<RooHero />
|
||||
{telemetrySetting === "unset" && <TelemetryBanner />}
|
||||
<p className="text-vscode-editor-foreground leading-tight font-vscode-font-family text-center text-balance max-w-[380px] mx-auto my-0">
|
||||
<Trans
|
||||
i18nKey="chat:about"
|
||||
components={{
|
||||
DocsLink: (
|
||||
<a href={buildDocLink("", "welcome")} target="_blank" rel="noopener noreferrer">
|
||||
the docs
|
||||
</a>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
|
||||
<div className="mb-2.5">
|
||||
<RooTips cycle={false} />
|
||||
{cloudIsAuthenticated || taskHistory.length < 4 ? <RooTips /> : <RooCloudCTA />}
|
||||
</div>
|
||||
{/* Show the task history preview if expanded and tasks exist */}
|
||||
{taskHistory.length > 0 && isExpanded && <HistoryPreview />}
|
||||
|
|
|
|||
|
|
@ -85,6 +85,40 @@ vi.mock("@src/components/modals/Announcement", () => ({
|
|||
},
|
||||
}))
|
||||
|
||||
// Mock RooCloudCTA component
|
||||
vi.mock("@src/components/welcome/RooCloudCTA", () => ({
|
||||
default: function MockRooCloudCTA() {
|
||||
return (
|
||||
<div data-testid="roo-cloud-cta">
|
||||
<div>rooCloudCTA.title</div>
|
||||
<div>rooCloudCTA.description</div>
|
||||
<div>rooCloudCTA.joinWaitlist</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock RooTips component
|
||||
vi.mock("@src/components/welcome/RooTips", () => ({
|
||||
default: function MockRooTips() {
|
||||
return <div data-testid="roo-tips">Tips content</div>
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock RooHero component
|
||||
vi.mock("@src/components/welcome/RooHero", () => ({
|
||||
default: function MockRooHero() {
|
||||
return <div data-testid="roo-hero">Hero content</div>
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock TelemetryBanner component
|
||||
vi.mock("../common/TelemetryBanner", () => ({
|
||||
default: function MockTelemetryBanner() {
|
||||
return null // Don't render anything to avoid interference
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock i18n
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
|
|
@ -191,6 +225,8 @@ const mockPostMessage = (state: Partial<ExtensionState>) => {
|
|||
shouldShowAnnouncement: false,
|
||||
allowedCommands: [],
|
||||
alwaysAllowExecute: false,
|
||||
cloudIsAuthenticated: false,
|
||||
telemetrySetting: "enabled",
|
||||
...state,
|
||||
},
|
||||
},
|
||||
|
|
@ -1310,3 +1346,164 @@ describe("ChatView - Version Indicator Tests", () => {
|
|||
expect(versionButton).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe("ChatView - RooCloudCTA Display Tests", () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it("does not show RooCloudCTA when user is authenticated to Cloud", () => {
|
||||
const { queryByTestId, getByTestId } = renderChatView()
|
||||
|
||||
// Hydrate state with user authenticated to cloud and some task history
|
||||
mockPostMessage({
|
||||
cloudIsAuthenticated: true,
|
||||
taskHistory: [
|
||||
{ id: "1", ts: Date.now() - 4000 },
|
||||
{ id: "2", ts: Date.now() - 3000 },
|
||||
{ id: "3", ts: Date.now() - 2000 },
|
||||
{ id: "4", ts: Date.now() - 1000 },
|
||||
{ id: "5", ts: Date.now() },
|
||||
],
|
||||
clineMessages: [], // No active task
|
||||
})
|
||||
|
||||
// Should not show RooCloudCTA but should show RooTips
|
||||
expect(queryByTestId("roo-cloud-cta")).not.toBeInTheDocument()
|
||||
expect(getByTestId("roo-tips")).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("does not show RooCloudCTA when user has only run 3 tasks in their history", () => {
|
||||
const { queryByTestId, getByTestId } = renderChatView()
|
||||
|
||||
// Hydrate state with user not authenticated and only 3 tasks in history
|
||||
mockPostMessage({
|
||||
cloudIsAuthenticated: false,
|
||||
taskHistory: [
|
||||
{ id: "1", ts: Date.now() - 2000 },
|
||||
{ id: "2", ts: Date.now() - 1000 },
|
||||
{ id: "3", ts: Date.now() },
|
||||
],
|
||||
clineMessages: [], // No active task
|
||||
})
|
||||
|
||||
// Should not show RooCloudCTA but should show RooTips
|
||||
expect(queryByTestId("roo-cloud-cta")).not.toBeInTheDocument()
|
||||
expect(getByTestId("roo-tips")).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("shows RooCloudCTA when user is not authenticated and has run 4 or more tasks", async () => {
|
||||
const { getByTestId, queryByTestId } = renderChatView()
|
||||
|
||||
// Hydrate state with user not authenticated and 4+ tasks in history
|
||||
mockPostMessage({
|
||||
cloudIsAuthenticated: false,
|
||||
taskHistory: [
|
||||
{ id: "1", ts: Date.now() - 3000 },
|
||||
{ id: "2", ts: Date.now() - 2000 },
|
||||
{ id: "3", ts: Date.now() - 1000 },
|
||||
{ id: "4", ts: Date.now() },
|
||||
],
|
||||
clineMessages: [], // No active task
|
||||
})
|
||||
|
||||
// Should show RooCloudCTA and not RooTips
|
||||
await waitFor(() => {
|
||||
expect(getByTestId("roo-cloud-cta")).toBeInTheDocument()
|
||||
})
|
||||
expect(queryByTestId("roo-tips")).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("shows RooCloudCTA when user is not authenticated and has run 5 tasks", async () => {
|
||||
const { getByTestId, queryByTestId } = renderChatView()
|
||||
|
||||
// Hydrate state with user not authenticated and 5 tasks in history
|
||||
mockPostMessage({
|
||||
cloudIsAuthenticated: false,
|
||||
taskHistory: [
|
||||
{ id: "1", ts: Date.now() - 4000 },
|
||||
{ id: "2", ts: Date.now() - 3000 },
|
||||
{ id: "3", ts: Date.now() - 2000 },
|
||||
{ id: "4", ts: Date.now() - 1000 },
|
||||
{ id: "5", ts: Date.now() },
|
||||
],
|
||||
clineMessages: [], // No active task
|
||||
})
|
||||
|
||||
// Should show RooCloudCTA and not RooTips
|
||||
await waitFor(() => {
|
||||
expect(getByTestId("roo-cloud-cta")).toBeInTheDocument()
|
||||
})
|
||||
expect(queryByTestId("roo-tips")).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("does not show RooCloudCTA when there is an active task (regardless of auth status)", async () => {
|
||||
const { queryByTestId } = renderChatView()
|
||||
|
||||
// Hydrate state with user not authenticated, 4+ tasks, but with an active task
|
||||
mockPostMessage({
|
||||
cloudIsAuthenticated: false,
|
||||
taskHistory: [
|
||||
{ id: "1", ts: Date.now() - 3000 },
|
||||
{ id: "2", ts: Date.now() - 2000 },
|
||||
{ id: "3", ts: Date.now() - 1000 },
|
||||
{ id: "4", ts: Date.now() },
|
||||
],
|
||||
clineMessages: [
|
||||
{
|
||||
type: "say",
|
||||
say: "task",
|
||||
ts: Date.now(),
|
||||
text: "Active task in progress",
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
// Wait for the state to be updated and the task view to be shown
|
||||
await waitFor(() => {
|
||||
// Should not show RooCloudCTA when there's an active task
|
||||
expect(queryByTestId("roo-cloud-cta")).not.toBeInTheDocument()
|
||||
// Should not show RooTips either since the entire welcome screen is hidden during active tasks
|
||||
expect(queryByTestId("roo-tips")).not.toBeInTheDocument()
|
||||
// Should not show RooHero either since the entire welcome screen is hidden during active tasks
|
||||
expect(queryByTestId("roo-hero")).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it("shows RooTips when user is authenticated (instead of RooCloudCTA)", () => {
|
||||
const { queryByTestId, getByTestId } = renderChatView()
|
||||
|
||||
// Hydrate state with user authenticated to cloud
|
||||
mockPostMessage({
|
||||
cloudIsAuthenticated: true,
|
||||
taskHistory: [
|
||||
{ id: "1", ts: Date.now() - 3000 },
|
||||
{ id: "2", ts: Date.now() - 2000 },
|
||||
{ id: "3", ts: Date.now() - 1000 },
|
||||
{ id: "4", ts: Date.now() },
|
||||
],
|
||||
clineMessages: [], // No active task
|
||||
})
|
||||
|
||||
// Should not show RooCloudCTA but should show RooTips
|
||||
expect(queryByTestId("roo-cloud-cta")).not.toBeInTheDocument()
|
||||
expect(getByTestId("roo-tips")).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("shows RooTips when user has fewer than 4 tasks (instead of RooCloudCTA)", () => {
|
||||
const { queryByTestId, getByTestId } = renderChatView()
|
||||
|
||||
// Hydrate state with user not authenticated but fewer than 4 tasks
|
||||
mockPostMessage({
|
||||
cloudIsAuthenticated: false,
|
||||
taskHistory: [
|
||||
{ id: "1", ts: Date.now() - 2000 },
|
||||
{ id: "2", ts: Date.now() - 1000 },
|
||||
{ id: "3", ts: Date.now() },
|
||||
],
|
||||
clineMessages: [], // No active task
|
||||
})
|
||||
|
||||
// Should not show RooCloudCTA but should show RooTips
|
||||
expect(queryByTestId("roo-cloud-cta")).not.toBeInTheDocument()
|
||||
expect(getByTestId("roo-tips")).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
23
webview-ui/src/components/welcome/RooCloudCTA.tsx
Normal file
23
webview-ui/src/components/welcome/RooCloudCTA.tsx
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { useTranslation } from "react-i18next"
|
||||
|
||||
export function RooCloudCTA() {
|
||||
const { t } = useTranslation("chat")
|
||||
|
||||
return (
|
||||
<div className="border border-muted/20 px-4 py-1 text-center flex items-start gap-2">
|
||||
<i className="mr-1 codicon codicon-cloud text-xl! mt-2 text-vscode-descriptionForeground" />
|
||||
<div className="text-left">
|
||||
<p>
|
||||
<strong>{t("rooCloudCTA.title")}</strong>
|
||||
<br />
|
||||
<span>{t("rooCloudCTA.description")}</span>
|
||||
</p>
|
||||
<p>
|
||||
<a href="https://roocode.com/cloud-waitlist">{t("rooCloudCTA.joinWaitlist")}</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default RooCloudCTA
|
||||
|
|
@ -1,7 +1,6 @@
|
|||
import { VSCodeLink } from "@vscode/webview-ui-toolkit/react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { useState, useEffect } from "react"
|
||||
import clsx from "clsx"
|
||||
import { Trans } from "react-i18next"
|
||||
|
||||
import { buildDocLink } from "@src/utils/docLinks"
|
||||
|
||||
|
|
@ -20,63 +19,25 @@ const tips = [
|
|||
},
|
||||
]
|
||||
|
||||
interface RooTipsProps {
|
||||
cycle?: boolean
|
||||
}
|
||||
|
||||
const RooTips = ({ cycle = false }: RooTipsProps) => {
|
||||
const RooTips = () => {
|
||||
const { t } = useTranslation("chat")
|
||||
const [currentTipIndex, setCurrentTipIndex] = useState(Math.floor(Math.random() * tips.length))
|
||||
const [isFading, setIsFading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!cycle) return
|
||||
|
||||
let timeoutId: NodeJS.Timeout | undefined = undefined
|
||||
const intervalId = setInterval(() => {
|
||||
setIsFading(true) // Start fade out
|
||||
timeoutId = setTimeout(() => {
|
||||
setCurrentTipIndex((prevIndex) => (prevIndex + 1) % tips.length)
|
||||
setIsFading(false) // Start fade in
|
||||
}, 1000) // Fade duration
|
||||
}, 11000) // 10s display + 1s fade
|
||||
|
||||
return () => {
|
||||
clearInterval(intervalId)
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId)
|
||||
}
|
||||
}
|
||||
}, [cycle])
|
||||
|
||||
const currentTip = tips[currentTipIndex]
|
||||
const topTwoTips = tips.slice(0, 2)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
"flex flex-col items-center justify-center px-5 py-2.5 gap-4",
|
||||
cycle && "h-[5em] overflow-visible m-5",
|
||||
)}>
|
||||
{/* If we need real estate, we show a compressed version of the tips. Otherwise, we expand it. */}
|
||||
{cycle ? (
|
||||
<>
|
||||
<div className="opacity-70 pb-1"> Did you know about...</div>
|
||||
<div
|
||||
className={clsx(
|
||||
"flex items-center gap-2 text-vscode-editor-foreground font-vscode max-w-[250px] transition-opacity duration-1000 ease-in-out",
|
||||
isFading ? "opacity-0" : "opacity-70",
|
||||
)}>
|
||||
{" "}
|
||||
<span className={`codicon ${currentTip.icon}`}></span>
|
||||
<span>
|
||||
<VSCodeLink href={currentTip.href}>{t(currentTip.titleKey)}</VSCodeLink>:{" "}
|
||||
{t(currentTip.descriptionKey)}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
topTwoTips.map((tip) => (
|
||||
<div>
|
||||
<p className="text-vscode-editor-foreground leading-tight font-vscode-font-family text-center text-balance max-w-[380px] mx-auto my-0">
|
||||
<Trans
|
||||
i18nKey="chat:about"
|
||||
components={{
|
||||
DocsLink: (
|
||||
<a href={buildDocLink("", "welcome")} target="_blank" rel="noopener noreferrer">
|
||||
the docs
|
||||
</a>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
<div className="flex flex-col items-center justify-center px-5 py-2.5 gap-4">
|
||||
{tips.map((tip) => (
|
||||
<div
|
||||
key={tip.titleKey}
|
||||
className="flex items-center gap-2 text-vscode-editor-foreground font-vscode max-w-[250px]">
|
||||
|
|
@ -88,8 +49,8 @@ const RooTips = ({ cycle = false }: RooTipsProps) => {
|
|||
: {t(tip.descriptionKey)}
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,16 @@ vi.mock("react-i18next", () => ({
|
|||
useTranslation: () => ({
|
||||
t: (key: string) => key, // Simple mock that returns the key
|
||||
}),
|
||||
Trans: ({
|
||||
children,
|
||||
components,
|
||||
}: {
|
||||
children?: React.ReactNode
|
||||
components?: Record<string, React.ReactElement>
|
||||
}) => {
|
||||
// Simple mock that renders children or the first component if no children
|
||||
return children || (components && Object.values(components)[0]) || null
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock("@vscode/webview-ui-toolkit/react", () => ({
|
||||
|
|
@ -25,12 +35,12 @@ describe("RooTips Component", () => {
|
|||
|
||||
describe("when cycle is false (default)", () => {
|
||||
beforeEach(() => {
|
||||
render(<RooTips cycle={false} />)
|
||||
render(<RooTips />)
|
||||
})
|
||||
|
||||
test("renders only the top two tips", () => {
|
||||
// Ensure only two tips are present (check by link role)
|
||||
expect(screen.getAllByRole("link")).toHaveLength(2)
|
||||
// Ensure only two tips are present plus the docs link in the Trans component (3 total links)
|
||||
expect(screen.getAllByRole("link")).toHaveLength(3)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
5
webview-ui/src/i18n/locales/ca/chat.json
generated
5
webview-ui/src/i18n/locales/ca/chat.json
generated
|
|
@ -323,6 +323,11 @@
|
|||
"versionIndicator": {
|
||||
"ariaLabel": "Versió {{version}} - Feu clic per veure les notes de llançament"
|
||||
},
|
||||
"rooCloudCTA": {
|
||||
"title": "Roo Code Cloud arribarà aviat!",
|
||||
"description": "Executa agents remots al núvol, accedeix a les teves tasques des de qualsevol lloc, col·labora amb altres i molt més.",
|
||||
"joinWaitlist": "Uneix-te a la llista d'espera per obtenir accés anticipat."
|
||||
},
|
||||
"editMessage": {
|
||||
"placeholder": "Edita el teu missatge..."
|
||||
}
|
||||
|
|
|
|||
5
webview-ui/src/i18n/locales/de/chat.json
generated
5
webview-ui/src/i18n/locales/de/chat.json
generated
|
|
@ -323,6 +323,11 @@
|
|||
"versionIndicator": {
|
||||
"ariaLabel": "Version {{version}} - Klicken Sie, um die Versionshinweise anzuzeigen"
|
||||
},
|
||||
"rooCloudCTA": {
|
||||
"title": "Roo Code Cloud kommt bald!",
|
||||
"description": "Führe Remote-Agenten in der Cloud aus, greife von überall auf deine Aufgaben zu, arbeite mit anderen zusammen und vieles mehr.",
|
||||
"joinWaitlist": "Tritt der Warteliste bei, um frühen Zugang zu erhalten."
|
||||
},
|
||||
"editMessage": {
|
||||
"placeholder": "Bearbeite deine Nachricht..."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -324,5 +324,10 @@
|
|||
},
|
||||
"versionIndicator": {
|
||||
"ariaLabel": "Version {{version}} - Click to view release notes"
|
||||
},
|
||||
"rooCloudCTA": {
|
||||
"title": "Roo Code Cloud is coming soon!",
|
||||
"description": "Run Roomote agents in the cloud, access your tasks from anywhere, collaborate with others, and more.",
|
||||
"joinWaitlist": "Join the waitlist to get early access."
|
||||
}
|
||||
}
|
||||
|
|
|
|||
5
webview-ui/src/i18n/locales/es/chat.json
generated
5
webview-ui/src/i18n/locales/es/chat.json
generated
|
|
@ -323,6 +323,11 @@
|
|||
"versionIndicator": {
|
||||
"ariaLabel": "Versión {{version}} - Haz clic para ver las notas de la versión"
|
||||
},
|
||||
"rooCloudCTA": {
|
||||
"title": "¡Roo Code Cloud llegará pronto!",
|
||||
"description": "Ejecuta agentes remotos en la nube, accede a tus tareas desde cualquier lugar, colabora con otros y mucho más.",
|
||||
"joinWaitlist": "Únete a la lista de espera para obtener acceso anticipado."
|
||||
},
|
||||
"editMessage": {
|
||||
"placeholder": "Edita tu mensaje..."
|
||||
}
|
||||
|
|
|
|||
5
webview-ui/src/i18n/locales/fr/chat.json
generated
5
webview-ui/src/i18n/locales/fr/chat.json
generated
|
|
@ -323,6 +323,11 @@
|
|||
"versionIndicator": {
|
||||
"ariaLabel": "Version {{version}} - Cliquez pour voir les notes de version"
|
||||
},
|
||||
"rooCloudCTA": {
|
||||
"title": "Roo Code Cloud arrive bientôt !",
|
||||
"description": "Exécutez des agents distants dans le cloud, accédez à vos tâches de n'importe où, collaborez avec d'autres et bien plus encore.",
|
||||
"joinWaitlist": "Rejoignez la liste d'attente pour obtenir un accès anticipé."
|
||||
},
|
||||
"editMessage": {
|
||||
"placeholder": "Modifiez votre message..."
|
||||
}
|
||||
|
|
|
|||
5
webview-ui/src/i18n/locales/hi/chat.json
generated
5
webview-ui/src/i18n/locales/hi/chat.json
generated
|
|
@ -323,6 +323,11 @@
|
|||
"versionIndicator": {
|
||||
"ariaLabel": "संस्करण {{version}} - रिलीज़ नोट्स देखने के लिए क्लिक करें"
|
||||
},
|
||||
"rooCloudCTA": {
|
||||
"title": "Roo Code Cloud जल्द आ रहा है!",
|
||||
"description": "क्लाउड में रिमोट एजेंट चलाएं, कहीं से भी अपने कार्यों तक पहुंचें, दूसरों के साथ सहयोग करें, और बहुत कुछ।",
|
||||
"joinWaitlist": "जल्दी पहुंच पाने के लिए प्रतीक्षा सूची में शामिल हों।"
|
||||
},
|
||||
"editMessage": {
|
||||
"placeholder": "अपना संदेश संपादित करें..."
|
||||
}
|
||||
|
|
|
|||
5
webview-ui/src/i18n/locales/id/chat.json
generated
5
webview-ui/src/i18n/locales/id/chat.json
generated
|
|
@ -329,6 +329,11 @@
|
|||
"versionIndicator": {
|
||||
"ariaLabel": "Versi {{version}} - Klik untuk melihat catatan rilis"
|
||||
},
|
||||
"rooCloudCTA": {
|
||||
"title": "Roo Code Cloud segera hadir!",
|
||||
"description": "Jalankan agen jarak jauh di cloud, akses tugas Anda dari mana saja, berkolaborasi dengan orang lain, dan banyak lagi.",
|
||||
"joinWaitlist": "Bergabunglah dengan daftar tunggu untuk mendapatkan akses awal."
|
||||
},
|
||||
"editMessage": {
|
||||
"placeholder": "Edit pesan Anda..."
|
||||
}
|
||||
|
|
|
|||
5
webview-ui/src/i18n/locales/it/chat.json
generated
5
webview-ui/src/i18n/locales/it/chat.json
generated
|
|
@ -323,6 +323,11 @@
|
|||
"versionIndicator": {
|
||||
"ariaLabel": "Versione {{version}} - Clicca per visualizzare le note di rilascio"
|
||||
},
|
||||
"rooCloudCTA": {
|
||||
"title": "Roo Code Cloud arriva presto!",
|
||||
"description": "Esegui agenti remoti nel cloud, accedi alle tue attività da qualsiasi luogo, collabora con altri e molto altro.",
|
||||
"joinWaitlist": "Unisciti alla lista d'attesa per ottenere l'accesso anticipato."
|
||||
},
|
||||
"editMessage": {
|
||||
"placeholder": "Modifica il tuo messaggio..."
|
||||
}
|
||||
|
|
|
|||
5
webview-ui/src/i18n/locales/ja/chat.json
generated
5
webview-ui/src/i18n/locales/ja/chat.json
generated
|
|
@ -323,6 +323,11 @@
|
|||
"versionIndicator": {
|
||||
"ariaLabel": "バージョン {{version}} - クリックしてリリースノートを表示"
|
||||
},
|
||||
"rooCloudCTA": {
|
||||
"title": "Roo Code Cloud が間もなく登場!",
|
||||
"description": "クラウドでリモートエージェントを実行し、どこからでもタスクにアクセスし、他の人と協力し、その他多くの機能を利用できます。",
|
||||
"joinWaitlist": "早期アクセスを取得するためにウェイトリストに参加してください。"
|
||||
},
|
||||
"editMessage": {
|
||||
"placeholder": "メッセージを編集..."
|
||||
}
|
||||
|
|
|
|||
5
webview-ui/src/i18n/locales/ko/chat.json
generated
5
webview-ui/src/i18n/locales/ko/chat.json
generated
|
|
@ -323,6 +323,11 @@
|
|||
"versionIndicator": {
|
||||
"ariaLabel": "버전 {{version}} - 릴리스 노트를 보려면 클릭하세요"
|
||||
},
|
||||
"rooCloudCTA": {
|
||||
"title": "Roo Code Cloud가 곧 출시됩니다!",
|
||||
"description": "클라우드에서 원격 에이전트를 실행하고, 어디서나 작업에 액세스하고, 다른 사람들과 협업하는 등 다양한 기능을 이용하세요.",
|
||||
"joinWaitlist": "얼리 액세스를 받으려면 대기 목록에 가입하세요."
|
||||
},
|
||||
"editMessage": {
|
||||
"placeholder": "메시지 편집..."
|
||||
}
|
||||
|
|
|
|||
5
webview-ui/src/i18n/locales/nl/chat.json
generated
5
webview-ui/src/i18n/locales/nl/chat.json
generated
|
|
@ -323,6 +323,11 @@
|
|||
"versionIndicator": {
|
||||
"ariaLabel": "Versie {{version}} - Klik om release notes te bekijken"
|
||||
},
|
||||
"rooCloudCTA": {
|
||||
"title": "Roo Code Cloud komt binnenkort!",
|
||||
"description": "Voer externe agenten uit in de cloud, krijg overal toegang tot je taken, werk samen met anderen en nog veel meer.",
|
||||
"joinWaitlist": "Sluit je aan bij de wachtlijst voor vroege toegang."
|
||||
},
|
||||
"editMessage": {
|
||||
"placeholder": "Bewerk je bericht..."
|
||||
}
|
||||
|
|
|
|||
5
webview-ui/src/i18n/locales/pl/chat.json
generated
5
webview-ui/src/i18n/locales/pl/chat.json
generated
|
|
@ -323,6 +323,11 @@
|
|||
"versionIndicator": {
|
||||
"ariaLabel": "Wersja {{version}} - Kliknij, aby wyświetlić informacje o wydaniu"
|
||||
},
|
||||
"rooCloudCTA": {
|
||||
"title": "Roo Code Cloud już wkrótce!",
|
||||
"description": "Uruchamiaj zdalne agenty w chmurze, uzyskuj dostęp do swoich zadań z dowolnego miejsca, współpracuj z innymi i wiele więcej.",
|
||||
"joinWaitlist": "Dołącz do listy oczekujących, aby uzyskać wczesny dostęp."
|
||||
},
|
||||
"editMessage": {
|
||||
"placeholder": "Edytuj swoją wiadomość..."
|
||||
}
|
||||
|
|
|
|||
5
webview-ui/src/i18n/locales/pt-BR/chat.json
generated
5
webview-ui/src/i18n/locales/pt-BR/chat.json
generated
|
|
@ -323,6 +323,11 @@
|
|||
"versionIndicator": {
|
||||
"ariaLabel": "Versão {{version}} - Clique para ver as notas de lançamento"
|
||||
},
|
||||
"rooCloudCTA": {
|
||||
"title": "Roo Code Cloud chegará em breve!",
|
||||
"description": "Execute agentes remotos na nuvem, acesse suas tarefas de qualquer lugar, colabore com outros e muito mais.",
|
||||
"joinWaitlist": "Junte-se à lista de espera para obter acesso antecipado."
|
||||
},
|
||||
"editMessage": {
|
||||
"placeholder": "Edite sua mensagem..."
|
||||
}
|
||||
|
|
|
|||
5
webview-ui/src/i18n/locales/ru/chat.json
generated
5
webview-ui/src/i18n/locales/ru/chat.json
generated
|
|
@ -323,6 +323,11 @@
|
|||
"versionIndicator": {
|
||||
"ariaLabel": "Версия {{version}} - Нажмите, чтобы просмотреть примечания к выпуску"
|
||||
},
|
||||
"rooCloudCTA": {
|
||||
"title": "Roo Code Cloud скоро появится!",
|
||||
"description": "Запускайте удаленные агенты в облаке, получайте доступ к своим задачам из любого места, сотрудничайте с другими и многое другое.",
|
||||
"joinWaitlist": "Присоединитесь к списку ожидания для получения раннего доступа."
|
||||
},
|
||||
"editMessage": {
|
||||
"placeholder": "Редактировать сообщение..."
|
||||
}
|
||||
|
|
|
|||
5
webview-ui/src/i18n/locales/tr/chat.json
generated
5
webview-ui/src/i18n/locales/tr/chat.json
generated
|
|
@ -323,6 +323,11 @@
|
|||
"versionIndicator": {
|
||||
"ariaLabel": "Sürüm {{version}} - Sürüm notlarını görüntülemek için tıklayın"
|
||||
},
|
||||
"rooCloudCTA": {
|
||||
"title": "Roo Code Cloud yakında geliyor!",
|
||||
"description": "Bulutta uzak ajanlar çalıştırın, görevlerinize her yerden erişin, başkalarıyla işbirliği yapın ve daha fazlası.",
|
||||
"joinWaitlist": "Erken erişim için bekleme listesine katılın."
|
||||
},
|
||||
"editMessage": {
|
||||
"placeholder": "Mesajını düzenle..."
|
||||
}
|
||||
|
|
|
|||
5
webview-ui/src/i18n/locales/vi/chat.json
generated
5
webview-ui/src/i18n/locales/vi/chat.json
generated
|
|
@ -323,6 +323,11 @@
|
|||
"versionIndicator": {
|
||||
"ariaLabel": "Phiên bản {{version}} - Nhấp để xem ghi chú phát hành"
|
||||
},
|
||||
"rooCloudCTA": {
|
||||
"title": "Roo Code Cloud sắp ra mắt!",
|
||||
"description": "Chạy các agent từ xa trên cloud, truy cập các tác vụ của bạn từ mọi nơi, cộng tác với người khác và nhiều hơn nữa.",
|
||||
"joinWaitlist": "Tham gia danh sách chờ để được truy cập sớm."
|
||||
},
|
||||
"editMessage": {
|
||||
"placeholder": "Chỉnh sửa tin nhắn của bạn..."
|
||||
}
|
||||
|
|
|
|||
5
webview-ui/src/i18n/locales/zh-CN/chat.json
generated
5
webview-ui/src/i18n/locales/zh-CN/chat.json
generated
|
|
@ -323,6 +323,11 @@
|
|||
"versionIndicator": {
|
||||
"ariaLabel": "版本 {{version}} - 点击查看发布说明"
|
||||
},
|
||||
"rooCloudCTA": {
|
||||
"title": "Roo Code Cloud 即将推出!",
|
||||
"description": "在云端运行远程代理,随时随地访问任务,与他人协作等更多功能。",
|
||||
"joinWaitlist": "加入等待列表获取早期访问权限。"
|
||||
},
|
||||
"editMessage": {
|
||||
"placeholder": "编辑消息..."
|
||||
}
|
||||
|
|
|
|||
5
webview-ui/src/i18n/locales/zh-TW/chat.json
generated
5
webview-ui/src/i18n/locales/zh-TW/chat.json
generated
|
|
@ -323,6 +323,11 @@
|
|||
"versionIndicator": {
|
||||
"ariaLabel": "版本 {{version}} - 點擊查看發布說明"
|
||||
},
|
||||
"rooCloudCTA": {
|
||||
"title": "Roo Code Cloud 即將推出!",
|
||||
"description": "在雲端執行遠端代理,隨時隨地存取您的工作,與他人協作等更多功能。",
|
||||
"joinWaitlist": "加入等候名單以獲得早期存取權限。"
|
||||
},
|
||||
"editMessage": {
|
||||
"placeholder": "編輯訊息..."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,8 +6,6 @@ import {
|
|||
parseCommand,
|
||||
isAutoApprovedSingleCommand,
|
||||
isAutoDeniedSingleCommand,
|
||||
isAutoApprovedCommand,
|
||||
isAutoDeniedCommand,
|
||||
findLongestPrefixMatch,
|
||||
getCommandDecision,
|
||||
getSingleCommandDecision,
|
||||
|
|
@ -50,9 +48,124 @@ describe("Command Validation", () => {
|
|||
parseCommand('npm test | Select-String -NotMatch "node_modules" | Select-String "FAIL|Error"'),
|
||||
).toEqual(["npm test", 'Select-String -NotMatch "node_modules"', 'Select-String "FAIL|Error"'])
|
||||
})
|
||||
|
||||
describe("newline handling", () => {
|
||||
it("splits commands by Unix newlines (\\n)", () => {
|
||||
expect(parseCommand("echo hello\ngit status\nnpm install")).toEqual([
|
||||
"echo hello",
|
||||
"git status",
|
||||
"npm install",
|
||||
])
|
||||
})
|
||||
|
||||
it("splits commands by Windows newlines (\\r\\n)", () => {
|
||||
expect(parseCommand("echo hello\r\ngit status\r\nnpm install")).toEqual([
|
||||
"echo hello",
|
||||
"git status",
|
||||
"npm install",
|
||||
])
|
||||
})
|
||||
|
||||
it("splits commands by old Mac newlines (\\r)", () => {
|
||||
expect(parseCommand("echo hello\rgit status\rnpm install")).toEqual([
|
||||
"echo hello",
|
||||
"git status",
|
||||
"npm install",
|
||||
])
|
||||
})
|
||||
|
||||
it("handles mixed line endings", () => {
|
||||
expect(parseCommand("echo hello\ngit status\r\nnpm install\rls -la")).toEqual([
|
||||
"echo hello",
|
||||
"git status",
|
||||
"npm install",
|
||||
"ls -la",
|
||||
])
|
||||
})
|
||||
|
||||
it("ignores empty lines", () => {
|
||||
expect(parseCommand("echo hello\n\n\ngit status\r\n\r\nnpm install")).toEqual([
|
||||
"echo hello",
|
||||
"git status",
|
||||
"npm install",
|
||||
])
|
||||
})
|
||||
|
||||
it("handles newlines with chain operators", () => {
|
||||
expect(parseCommand('npm install && npm test\ngit add .\ngit commit -m "test"')).toEqual([
|
||||
"npm install",
|
||||
"npm test",
|
||||
"git add .",
|
||||
'git commit -m "test"',
|
||||
])
|
||||
})
|
||||
|
||||
it("splits on actual newlines even within quotes", () => {
|
||||
// Note: Since we split by newlines first, actual newlines in the input
|
||||
// will split the command, even if they appear to be within quotes
|
||||
// Using template literal to create actual newline
|
||||
const commandWithNewlineInQuotes = `echo "Hello
|
||||
World"
|
||||
git status`
|
||||
// The quotes get stripped because they're no longer properly paired after splitting
|
||||
expect(parseCommand(commandWithNewlineInQuotes)).toEqual(["echo Hello", "World", "git status"])
|
||||
})
|
||||
|
||||
it("handles quoted strings on single line", () => {
|
||||
// When quotes are on the same line, they are preserved
|
||||
expect(parseCommand('echo "Hello World"\ngit status')).toEqual(['echo "Hello World"', "git status"])
|
||||
})
|
||||
|
||||
it("handles complex multi-line commands", () => {
|
||||
const multiLineCommand = `npm install
|
||||
npm test && npm run build
|
||||
echo "Done" | tee output.log
|
||||
git status; git add .
|
||||
ls -la || echo "Failed"`
|
||||
|
||||
expect(parseCommand(multiLineCommand)).toEqual([
|
||||
"npm install",
|
||||
"npm test",
|
||||
"npm run build",
|
||||
'echo "Done"',
|
||||
"tee output.log",
|
||||
"git status",
|
||||
"git add .",
|
||||
"ls -la",
|
||||
'echo "Failed"',
|
||||
])
|
||||
})
|
||||
|
||||
it("handles newlines with subshells", () => {
|
||||
expect(parseCommand("echo $(date)\nnpm test\ngit status")).toEqual([
|
||||
"echo",
|
||||
"date",
|
||||
"npm test",
|
||||
"git status",
|
||||
])
|
||||
})
|
||||
|
||||
it("handles newlines with redirections", () => {
|
||||
expect(parseCommand("npm test 2>&1\necho done\nls -la > files.txt")).toEqual([
|
||||
"npm test 2>&1",
|
||||
"echo done",
|
||||
"ls -la > files.txt",
|
||||
])
|
||||
})
|
||||
|
||||
it("handles empty input with newlines", () => {
|
||||
expect(parseCommand("\n\n\n")).toEqual([])
|
||||
expect(parseCommand("\r\n\r\n")).toEqual([])
|
||||
expect(parseCommand("\r\r\r")).toEqual([])
|
||||
})
|
||||
|
||||
it("handles whitespace-only lines", () => {
|
||||
expect(parseCommand("echo hello\n \t \ngit status")).toEqual(["echo hello", "git status"])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("isAutoApprovedSingleCommand (legacy behavior)", () => {
|
||||
describe("isAutoApprovedSingleCommand", () => {
|
||||
const allowedCommands = ["npm test", "npm run", "echo"]
|
||||
|
||||
it("matches commands case-insensitively", () => {
|
||||
|
|
@ -78,93 +191,6 @@ describe("Command Validation", () => {
|
|||
expect(isAutoApprovedSingleCommand("npm test", [])).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("isAutoApprovedCommand (legacy behavior)", () => {
|
||||
const allowedCommands = ["npm test", "npm run", "echo", "Select-String"]
|
||||
|
||||
it("validates simple commands", () => {
|
||||
expect(isAutoApprovedCommand("npm test", allowedCommands)).toBe(true)
|
||||
expect(isAutoApprovedCommand("npm run build", allowedCommands)).toBe(true)
|
||||
expect(isAutoApprovedCommand("dangerous", allowedCommands)).toBe(false)
|
||||
})
|
||||
|
||||
it("validates chained commands", () => {
|
||||
expect(isAutoApprovedCommand("npm test && npm run build", allowedCommands)).toBe(true)
|
||||
expect(isAutoApprovedCommand("npm test && dangerous", allowedCommands)).toBe(false)
|
||||
expect(isAutoApprovedCommand('npm test | Select-String "Error"', allowedCommands)).toBe(true)
|
||||
expect(isAutoApprovedCommand("npm test | rm -rf /", allowedCommands)).toBe(false)
|
||||
})
|
||||
|
||||
it("handles quoted content correctly", () => {
|
||||
expect(isAutoApprovedCommand('npm test "param with | inside"', allowedCommands)).toBe(true)
|
||||
expect(isAutoApprovedCommand('echo "hello | world"', allowedCommands)).toBe(true)
|
||||
expect(isAutoApprovedCommand('npm test "param with && inside"', allowedCommands)).toBe(true)
|
||||
})
|
||||
|
||||
it("handles subshell execution attempts", () => {
|
||||
// Without denylist, subshells should be allowed if all subcommands are allowed
|
||||
expect(isAutoApprovedCommand("npm test $(echo hello)", allowedCommands)).toBe(true)
|
||||
expect(isAutoApprovedCommand("npm test `echo world`", allowedCommands)).toBe(true)
|
||||
|
||||
// With denylist, subshells should be blocked regardless of subcommands
|
||||
expect(isAutoApprovedCommand("npm test $(echo hello)", allowedCommands, ["rm"])).toBe(false)
|
||||
expect(isAutoApprovedCommand("npm test `echo world`", allowedCommands, ["rm"])).toBe(false)
|
||||
})
|
||||
|
||||
it("handles PowerShell patterns", () => {
|
||||
expect(isAutoApprovedCommand('npm test 2>&1 | Select-String "Error"', allowedCommands)).toBe(true)
|
||||
expect(
|
||||
isAutoApprovedCommand(
|
||||
'npm test | Select-String -NotMatch "node_modules" | Select-String "FAIL|Error"',
|
||||
allowedCommands,
|
||||
),
|
||||
).toBe(true)
|
||||
expect(isAutoApprovedCommand("npm test | Select-String | dangerous", allowedCommands)).toBe(false)
|
||||
})
|
||||
|
||||
it("handles empty input", () => {
|
||||
expect(isAutoApprovedCommand("", allowedCommands)).toBe(true)
|
||||
expect(isAutoApprovedCommand(" ", allowedCommands)).toBe(true)
|
||||
})
|
||||
|
||||
it("allows all commands when wildcard is present", () => {
|
||||
const wildcardAllowedCommands = ["*"]
|
||||
// Should allow any command, including dangerous ones
|
||||
expect(isAutoApprovedCommand("rm -rf /", wildcardAllowedCommands)).toBe(true)
|
||||
expect(isAutoApprovedCommand("dangerous-command", wildcardAllowedCommands)).toBe(true)
|
||||
expect(isAutoApprovedCommand("npm test && rm -rf /", wildcardAllowedCommands)).toBe(true)
|
||||
// Should allow subshell commands with wildcard when no denylist is present
|
||||
expect(isAutoApprovedCommand("npm test $(echo dangerous)", wildcardAllowedCommands)).toBe(true)
|
||||
expect(isAutoApprovedCommand("npm test `rm -rf /`", wildcardAllowedCommands)).toBe(true)
|
||||
|
||||
// But should block subshells when denylist is present
|
||||
expect(isAutoApprovedCommand("npm test $(echo dangerous)", wildcardAllowedCommands, ["rm"])).toBe(false)
|
||||
expect(isAutoApprovedCommand("npm test `rm -rf /`", wildcardAllowedCommands, ["rm"])).toBe(false)
|
||||
})
|
||||
|
||||
it("respects denylist even with wildcard in allowlist", () => {
|
||||
const wildcardAllowedCommands = ["*"]
|
||||
const deniedCommands = ["rm -rf", "dangerous"]
|
||||
|
||||
// Wildcard should allow most commands
|
||||
expect(isAutoApprovedCommand("npm test", wildcardAllowedCommands, deniedCommands)).toBe(true)
|
||||
expect(isAutoApprovedCommand("echo hello", wildcardAllowedCommands, deniedCommands)).toBe(true)
|
||||
expect(isAutoApprovedCommand("git status", wildcardAllowedCommands, deniedCommands)).toBe(true)
|
||||
|
||||
// But denylist should still block specific commands
|
||||
expect(isAutoApprovedCommand("rm -rf /", wildcardAllowedCommands, deniedCommands)).toBe(false)
|
||||
expect(isAutoApprovedCommand("dangerous-command", wildcardAllowedCommands, deniedCommands)).toBe(false)
|
||||
|
||||
// Chained commands with denied subcommands should be blocked
|
||||
expect(isAutoApprovedCommand("npm test && rm -rf /", wildcardAllowedCommands, deniedCommands)).toBe(false)
|
||||
expect(
|
||||
isAutoApprovedCommand("echo hello && dangerous-command", wildcardAllowedCommands, deniedCommands),
|
||||
).toBe(false)
|
||||
|
||||
// But chained commands with all allowed subcommands should work
|
||||
expect(isAutoApprovedCommand("npm test && echo done", wildcardAllowedCommands, deniedCommands)).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
|
|
@ -395,52 +421,6 @@ echo "Successfully converted $count .jsx files to .tsx"`
|
|||
})
|
||||
})
|
||||
|
||||
describe("isAutoApprovedCommand (legacy behavior)", () => {
|
||||
it("should validate allowed commands", () => {
|
||||
const result = isAutoApprovedCommand("echo hello", ["echo"])
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it("should reject disallowed commands", () => {
|
||||
const result = isAutoApprovedCommand("rm -rf /", ["echo", "ls"])
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it("should not fail validation for commands with simple $RANDOM variable", () => {
|
||||
const commandWithRandom = "echo $RANDOM"
|
||||
|
||||
expect(() => {
|
||||
isAutoApprovedCommand(commandWithRandom, ["echo"])
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it("should not fail validation for commands with simple array indexing using $RANDOM", () => {
|
||||
const commandWithRandomIndex = "echo ${array[$RANDOM]}"
|
||||
|
||||
expect(() => {
|
||||
isAutoApprovedCommand(commandWithRandomIndex, ["echo"])
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it("should return false for the full log generator command due to subshell detection when denylist is present", () => {
|
||||
// This is the exact command from the original error message
|
||||
const logGeneratorCommand = `while true; do \\
|
||||
levels=(INFO WARN ERROR DEBUG); \\
|
||||
msgs=("User logged in" "Connection timeout" "Processing request" "Cache miss" "Database query"); \\
|
||||
level=\${levels[$RANDOM % \${#levels[@]}]}; \\
|
||||
msg=\${msgs[$RANDOM % \${#msgs[@]}]}; \\
|
||||
echo "\$(date '+%Y-%m-%d %H:%M:%S') [$level] $msg"; \\
|
||||
sleep 1; \\
|
||||
done`
|
||||
|
||||
// Without denylist, should allow subshells if all subcommands are allowed (use wildcard)
|
||||
expect(isAutoApprovedCommand(logGeneratorCommand, ["*"])).toBe(true)
|
||||
|
||||
// With denylist, should return false due to subshell detection
|
||||
expect(isAutoApprovedCommand(logGeneratorCommand, ["*"], ["rm"])).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("Denylist Command Validation", () => {
|
||||
describe("findLongestPrefixMatch", () => {
|
||||
it("finds the longest matching prefix", () => {
|
||||
|
|
@ -469,7 +449,7 @@ done`
|
|||
})
|
||||
})
|
||||
|
||||
describe("Legacy isAllowedSingleCommand behavior (now using isAutoApprovedSingleCommand)", () => {
|
||||
describe("isAutoApprovedSingleCommand", () => {
|
||||
const allowedCommands = ["npm", "echo", "git"]
|
||||
const deniedCommands = ["npm test", "git push"]
|
||||
|
||||
|
|
@ -646,71 +626,6 @@ done`
|
|||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("Command-level three-tier validation", () => {
|
||||
const allowedCommands = ["npm", "echo"]
|
||||
const deniedCommands = ["npm test"]
|
||||
|
||||
describe("isAutoApprovedCommand", () => {
|
||||
it("auto-approves commands with all sub-commands auto-approved", () => {
|
||||
expect(isAutoApprovedCommand("npm install", allowedCommands, deniedCommands)).toBe(true)
|
||||
expect(isAutoApprovedCommand("npm install && echo done", allowedCommands, deniedCommands)).toBe(
|
||||
true,
|
||||
)
|
||||
})
|
||||
|
||||
it("does not auto-approve commands with any sub-command not auto-approved", () => {
|
||||
expect(isAutoApprovedCommand("npm test", allowedCommands, deniedCommands)).toBe(false)
|
||||
expect(isAutoApprovedCommand("npm install && npm test", allowedCommands, deniedCommands)).toBe(
|
||||
false,
|
||||
)
|
||||
})
|
||||
|
||||
it("blocks subshell commands only when denylist is present", () => {
|
||||
// Without denylist, should allow subshells
|
||||
expect(isAutoApprovedCommand("npm install $(echo test)", allowedCommands)).toBe(true)
|
||||
expect(isAutoApprovedCommand("npm install `echo test`", allowedCommands)).toBe(true)
|
||||
|
||||
// With denylist, should block subshells
|
||||
expect(isAutoApprovedCommand("npm install $(echo test)", allowedCommands, deniedCommands)).toBe(
|
||||
false,
|
||||
)
|
||||
expect(isAutoApprovedCommand("npm install `echo test`", allowedCommands, deniedCommands)).toBe(
|
||||
false,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("isAutoDeniedCommand", () => {
|
||||
it("auto-denies commands with any sub-command auto-denied", () => {
|
||||
expect(isAutoDeniedCommand("npm test", allowedCommands, deniedCommands)).toBe(true)
|
||||
expect(isAutoDeniedCommand("npm install && npm test", allowedCommands, deniedCommands)).toBe(
|
||||
true,
|
||||
)
|
||||
})
|
||||
|
||||
it("does not auto-deny commands with all sub-commands not auto-denied", () => {
|
||||
expect(isAutoDeniedCommand("npm install", allowedCommands, deniedCommands)).toBe(false)
|
||||
expect(isAutoDeniedCommand("npm install && echo done", allowedCommands, deniedCommands)).toBe(
|
||||
false,
|
||||
)
|
||||
})
|
||||
|
||||
it("auto-denies subshell commands only when denylist is present", () => {
|
||||
// Without denylist, should not auto-deny subshells
|
||||
expect(isAutoDeniedCommand("npm install $(echo test)", allowedCommands)).toBe(false)
|
||||
expect(isAutoDeniedCommand("npm install `echo test`", allowedCommands)).toBe(false)
|
||||
|
||||
// With denylist, should auto-deny subshells
|
||||
expect(isAutoDeniedCommand("npm install $(echo test)", allowedCommands, deniedCommands)).toBe(
|
||||
true,
|
||||
)
|
||||
expect(isAutoDeniedCommand("npm install `echo test`", allowedCommands, deniedCommands)).toBe(
|
||||
true,
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -797,9 +712,19 @@ describe("Unified Command Decision Functions", () => {
|
|||
expect(getCommandDecision("npm install && dangerous", allowedCommands, deniedCommands)).toBe("ask_user")
|
||||
})
|
||||
|
||||
it("returns auto_deny for subshell commands when denylist is present", () => {
|
||||
expect(getCommandDecision("npm install $(echo test)", allowedCommands, deniedCommands)).toBe("auto_deny")
|
||||
expect(getCommandDecision("npm install `echo test`", allowedCommands, deniedCommands)).toBe("auto_deny")
|
||||
it("returns auto_deny for subshell commands only when they contain denied prefixes", () => {
|
||||
// Subshells without denied prefixes should not be auto-denied
|
||||
expect(getCommandDecision("npm install $(echo test)", allowedCommands, deniedCommands)).toBe("auto_approve")
|
||||
expect(getCommandDecision("npm install `echo test`", allowedCommands, deniedCommands)).toBe("auto_approve")
|
||||
|
||||
// Subshells with denied prefixes should be auto-denied
|
||||
expect(getCommandDecision("npm install $(npm test)", allowedCommands, deniedCommands)).toBe("auto_deny")
|
||||
expect(getCommandDecision("npm install `npm test --coverage`", allowedCommands, deniedCommands)).toBe(
|
||||
"auto_deny",
|
||||
)
|
||||
|
||||
// Main command with denied prefix should also be auto-denied
|
||||
expect(getCommandDecision("npm test $(echo hello)", allowedCommands, deniedCommands)).toBe("auto_deny")
|
||||
})
|
||||
|
||||
it("allows subshell commands when no denylist is present", () => {
|
||||
|
|
@ -846,39 +771,6 @@ describe("Unified Command Decision Functions", () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe("Integration with existing functions", () => {
|
||||
it("maintains backward compatibility with existing behavior", () => {
|
||||
const allowedCommands = ["npm", "echo"]
|
||||
const deniedCommands = ["npm test"]
|
||||
|
||||
// Test that new unified functions produce same results as old separate functions
|
||||
const testCommands = [
|
||||
"npm install", // should be auto-approved
|
||||
"npm test", // should be auto-denied
|
||||
"dangerous", // should ask user
|
||||
"echo hello", // should be auto-approved
|
||||
]
|
||||
|
||||
testCommands.forEach((cmd) => {
|
||||
const decision = getCommandDecision(cmd, allowedCommands, deniedCommands)
|
||||
const oldApproved = isAutoApprovedCommand(cmd, allowedCommands, deniedCommands)
|
||||
const oldDenied = isAutoDeniedCommand(cmd, allowedCommands, deniedCommands)
|
||||
|
||||
// Verify consistency
|
||||
if (decision === "auto_approve") {
|
||||
expect(oldApproved).toBe(true)
|
||||
expect(oldDenied).toBe(false)
|
||||
} else if (decision === "auto_deny") {
|
||||
expect(oldApproved).toBe(false)
|
||||
expect(oldDenied).toBe(true)
|
||||
} else if (decision === "ask_user") {
|
||||
expect(oldApproved).toBe(false)
|
||||
expect(oldDenied).toBe(false)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("CommandValidator Integration Tests", () => {
|
||||
describe("CommandValidator Class", () => {
|
||||
let validator: CommandValidator
|
||||
|
|
@ -952,7 +844,12 @@ describe("Unified Command Decision Functions", () => {
|
|||
it("detects subshells correctly", () => {
|
||||
const details = validator.getValidationDetails("npm install $(echo test)")
|
||||
expect(details.hasSubshells).toBe(true)
|
||||
expect(details.decision).toBe("auto_deny") // blocked due to subshells with denylist
|
||||
expect(details.decision).toBe("auto_approve") // not blocked since echo doesn't match denied prefixes
|
||||
|
||||
// Test with denied prefix in subshell
|
||||
const detailsWithDenied = validator.getValidationDetails("npm install $(npm test)")
|
||||
expect(detailsWithDenied.hasSubshells).toBe(true)
|
||||
expect(detailsWithDenied.decision).toBe("auto_deny") // blocked due to npm test in subshell
|
||||
})
|
||||
|
||||
it("handles complex command chains", () => {
|
||||
|
|
@ -1047,6 +944,41 @@ describe("Unified Command Decision Functions", () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe("Subshell edge cases", () => {
|
||||
it("handles multiple subshells correctly", () => {
|
||||
const validator = createCommandValidator(["echo", "npm"], ["rm", "sudo"])
|
||||
|
||||
// Multiple subshells, none with denied prefixes but subshell commands not in allowlist
|
||||
// parseCommand extracts subshells as separate commands, so date and pwd are not allowed
|
||||
expect(validator.validateCommand("echo $(date) $(pwd)")).toBe("ask_user")
|
||||
|
||||
// Multiple subshells, one with denied prefix
|
||||
expect(validator.validateCommand("echo $(date) $(rm file)")).toBe("auto_deny")
|
||||
|
||||
// Nested subshells - inner commands are extracted and not in allowlist
|
||||
expect(validator.validateCommand("echo $(echo $(date))")).toBe("ask_user")
|
||||
expect(validator.validateCommand("echo $(echo $(rm file))")).toBe("auto_deny")
|
||||
})
|
||||
|
||||
it("handles complex commands with subshells", () => {
|
||||
const validator = createCommandValidator(["npm", "git", "echo"], ["git push", "npm publish"])
|
||||
|
||||
// Subshell with allowed command - git status is extracted as separate command
|
||||
// Since "git status" starts with "git" which is allowed, it's approved
|
||||
expect(validator.validateCommand("npm run $(git status)")).toBe("auto_approve")
|
||||
|
||||
// Subshell with denied command
|
||||
expect(validator.validateCommand("npm run $(git push origin)")).toBe("auto_deny")
|
||||
|
||||
// Main command denied, subshell allowed
|
||||
expect(validator.validateCommand("git push $(echo origin)")).toBe("auto_deny")
|
||||
|
||||
// Complex chain with subshells - need echo in allowlist
|
||||
expect(validator.validateCommand("npm install && echo $(git status) && npm test")).toBe("auto_approve")
|
||||
expect(validator.validateCommand("npm install && echo $(git push) && npm test")).toBe("auto_deny")
|
||||
})
|
||||
})
|
||||
|
||||
describe("Real-world integration scenarios", () => {
|
||||
describe("Development workflow validation", () => {
|
||||
let devValidator: CommandValidator
|
||||
|
|
|
|||
|
|
@ -60,17 +60,41 @@ type ShellToken = string | { op: string } | { command: string }
|
|||
|
||||
/**
|
||||
* Split a command string into individual sub-commands by
|
||||
* chaining operators (&&, ||, ;, or |).
|
||||
* chaining operators (&&, ||, ;, or |) and newlines.
|
||||
*
|
||||
* Uses shell-quote to properly handle:
|
||||
* - Quoted strings (preserves quotes)
|
||||
* - Subshell commands ($(cmd) or `cmd`)
|
||||
* - PowerShell redirections (2>&1)
|
||||
* - Chain operators (&&, ||, ;, |)
|
||||
* - Newlines as command separators
|
||||
*/
|
||||
export function parseCommand(command: string): string[] {
|
||||
if (!command?.trim()) return []
|
||||
|
||||
// Split by newlines first (handle different line ending formats)
|
||||
// This regex splits on \r\n (Windows), \n (Unix), or \r (old Mac)
|
||||
const lines = command.split(/\r\n|\r|\n/)
|
||||
const allCommands: string[] = []
|
||||
|
||||
for (const line of lines) {
|
||||
// Skip empty lines
|
||||
if (!line.trim()) continue
|
||||
|
||||
// Process each line through the existing parsing logic
|
||||
const lineCommands = parseCommandLine(line)
|
||||
allCommands.push(...lineCommands)
|
||||
}
|
||||
|
||||
return allCommands
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a single line of commands (internal helper function)
|
||||
*/
|
||||
function parseCommandLine(command: string): string[] {
|
||||
if (!command?.trim()) return []
|
||||
|
||||
// Storage for replaced content
|
||||
const redirections: string[] = []
|
||||
const subshells: string[] = []
|
||||
|
|
@ -351,56 +375,6 @@ export function isAutoDeniedSingleCommand(
|
|||
return longestDeniedMatch.length >= longestAllowedMatch.length
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a command string should be auto-approved.
|
||||
* Only blocks subshell attempts if there's a denylist configured.
|
||||
* Requires all sub-commands to be auto-approved.
|
||||
*/
|
||||
export function isAutoApprovedCommand(command: string, allowedCommands: string[], deniedCommands?: string[]): boolean {
|
||||
if (!command?.trim()) return true
|
||||
|
||||
// Only block subshell execution attempts if there's a denylist configured
|
||||
if ((command.includes("$(") || command.includes("`")) && deniedCommands?.length) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Parse into sub-commands (split by &&, ||, ;, |)
|
||||
const subCommands = parseCommand(command)
|
||||
|
||||
// Ensure every sub-command is auto-approved
|
||||
return subCommands.every((cmd) => {
|
||||
// Remove simple PowerShell-like redirections (e.g. 2>&1) before checking
|
||||
const cmdWithoutRedirection = cmd.replace(/\d*>&\d*/, "").trim()
|
||||
|
||||
return isAutoApprovedSingleCommand(cmdWithoutRedirection, allowedCommands, deniedCommands)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a command string should be auto-denied.
|
||||
* Only blocks subshell attempts if there's a denylist configured.
|
||||
* Auto-denies if any sub-command is auto-denied.
|
||||
*/
|
||||
export function isAutoDeniedCommand(command: string, allowedCommands: string[], deniedCommands?: string[]): boolean {
|
||||
if (!command?.trim()) return false
|
||||
|
||||
// Only block subshell execution attempts if there's a denylist configured
|
||||
if ((command.includes("$(") || command.includes("`")) && deniedCommands?.length) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Parse into sub-commands (split by &&, ||, ;, |)
|
||||
const subCommands = parseCommand(command)
|
||||
|
||||
// Auto-deny if any sub-command is auto-denied
|
||||
return subCommands.some((cmd) => {
|
||||
// Remove simple PowerShell-like redirections (e.g. 2>&1) before checking
|
||||
const cmdWithoutRedirection = cmd.replace(/\d*>&\d*/, "").trim()
|
||||
|
||||
return isAutoDeniedSingleCommand(cmdWithoutRedirection, allowedCommands, deniedCommands)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Command approval decision types
|
||||
*/
|
||||
|
|
@ -456,9 +430,12 @@ export function getCommandDecision(
|
|||
): CommandDecision {
|
||||
if (!command?.trim()) return "auto_approve"
|
||||
|
||||
// Only block subshell execution attempts if there's a denylist configured
|
||||
// Check if subshells contain denied prefixes
|
||||
if ((command.includes("$(") || command.includes("`")) && deniedCommands?.length) {
|
||||
return "auto_deny"
|
||||
const mainCommandLower = command.toLowerCase()
|
||||
if (deniedCommands.some((denied) => mainCommandLower.includes(denied.toLowerCase()))) {
|
||||
return "auto_deny"
|
||||
}
|
||||
}
|
||||
|
||||
// Parse into sub-commands (split by &&, ||, ;, |)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue